Initial Commit

This commit is contained in:
handfly 2026-05-02 21:11:50 -04:00
commit 57d32d0b58
47 changed files with 13809 additions and 0 deletions

2
.directory Normal file
View file

@ -0,0 +1,2 @@
[Desktop Entry]
Icon=orange-folder-git

23
CREDITS.md Normal file
View file

@ -0,0 +1,23 @@
# Credits
URIT BBS is a modern reimplementation inspired by **T.A.G.-BBS v1.03**,
originally written by **Patrick E. Hughes** in 1986-87 for the Commodore Amiga.
The original T.A.G.-BBS was a single-user, dial-up bulletin board system that
ran on AmigaOS, communicating with callers over a serial modem connection. It
featured message boards, private mail, file libraries, bulletins, and an
integrated line editor — all in roughly 6,000 lines of C.
URIT BBS carries the spirit of that software forward into the modern era,
replacing the Amiga serial port with TCP/IP (telnet and SSH), flat binary data
files with SQLite, and the single-caller limitation with multi-user support.
## Special Thanks
- **Patrick E. Hughes** — Original author of T.A.G.-BBS. The clean architecture
of the original code, particularly its clear separation between I/O and
application logic, made this reimplementation possible and informed much of
URIT's design.
- All the sysops, users, and developers who built and sustained the BBS
community through the dial-up era and continue to keep it alive today.

24
LICENSE Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

80
README.md Normal file
View file

@ -0,0 +1,80 @@
# URIT BBS
A modern bulletin board system inspired by T.A.G.-BBS (1986), written in Go.
## Features
- Telnet and SSH access
- Multi-user support
- Message boards with security-based access control
- Private mail
- File libraries
- ANSI terminal support
- SQLite storage (zero-configuration)
- Cross-platform: runs on x86, ARM64 (Raspberry Pi), and more
## Building
Requires Go 1.22 or later.
```bash
go build -o urit ./cmd/urit/
```
### Cross-compile for Raspberry Pi
```bash
GOOS=linux GOARCH=arm64 go build -o urit ./cmd/urit/
```
## Quick Start
Initialize a new BBS instance:
```bash
./urit init
```
This creates the database, a sysop account, starter boards, a welcome
bulletin, an empty file library, and sample ANSI screen files. It's the
modern equivalent of the original TAG-BBS's GENERATE program.
Start the server:
```bash
./urit
```
Connect with any telnet client:
```bash
telnet localhost 2323
```
## Usage
```bash
./urit # Start the BBS server (default config: config.toml)
./urit -config PATH # Start with a specific config file
./urit init # Initialize a new BBS instance
./urit init -force # Re-initialize, overwriting existing database
./urit version # Print version and exit
./urit help # Show help
```
## Configuration
See `config.toml` for all available settings. Running `urit init` will
create a default config file if one doesn't exist.
## Project Status
Under active development.
## License
This project is released into the public domain. See [LICENSE](LICENSE) for details.
## Credits
See [CREDITS.md](CREDITS.md) for acknowledgments.

BIN
cmd/urit/data/urit.db Normal file

Binary file not shown.

462
cmd/urit/init.go Normal file
View file

@ -0,0 +1,462 @@
package main
// This file implements the "urit init" subcommand.
//
// It is the modern equivalent of GENERATE.C from the original TAG-BBS.
// The original was a standalone program that read configuration from
// text files (s:Tag_System, s:Tag_Boards, etc.) and pre-allocated
// fixed-size binary data files (User.Data, Board.Keys, Board.Data,
// Mail.Keys, Mail.Data, Library.Keys).
//
// Our version is simpler because SQLite handles storage — we just need
// to create the database, populate it with starter content, and set up
// the screen files directory. The schema is created automatically by
// store.OpenSQLite's migrate() call, so init focuses on seed data.
import (
"bufio"
"fmt"
"os"
"path/filepath"
"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/store"
)
// runInit handles the "urit init" subcommand.
func runInit(args []string) {
// Parse init-specific flags.
configPath := "config.toml"
force := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "-config":
if i+1 < len(args) {
configPath = args[i+1]
i++
} else {
fatal("error: -config requires a path")
}
case "-force":
force = true
case "-help", "--help", "-h":
fmt.Println("Usage: urit init [-config <path>] [-force]")
fmt.Println()
fmt.Println("Initialize a new URIT BBS instance.")
fmt.Println()
fmt.Println("This creates the database, sysop account, starter boards,")
fmt.Println("a welcome bulletin, an empty file library, and sample")
fmt.Println("screen files. Equivalent to GENERATE from the original")
fmt.Println("TAG-BBS.")
fmt.Println()
fmt.Println("Flags:")
fmt.Println(" -config <path> Path to config file (default: config.toml)")
fmt.Println(" -force Overwrite existing database")
return
default:
fatal("unknown flag: %s", args[i])
}
}
fmt.Println()
fmt.Printf("URIT BBS v%s — System Initialization\n", version)
fmt.Println(strings.Repeat("─", 44))
fmt.Println()
// Load or create config.
cfg := loadOrCreateConfig(configPath)
// Safety check: refuse to overwrite an existing database unless forced.
if !force {
if _, err := os.Stat(cfg.Storage.SQLitePath); err == nil {
fatal("Database already exists: %s\nUse -force to overwrite.", cfg.Storage.SQLitePath)
}
} else {
// Remove existing database files so we start clean.
os.Remove(cfg.Storage.SQLitePath)
os.Remove(cfg.Storage.SQLitePath + "-wal")
os.Remove(cfg.Storage.SQLitePath + "-shm")
}
reader := bufio.NewReader(os.Stdin)
// Prompt for sysop account details.
sysopName := promptLine(reader, "Sysop username", "Sysop")
sysopPass := promptPassword(reader)
fmt.Println()
// Create data directory.
dataDir := filepath.Dir(cfg.Storage.SQLitePath)
if err := os.MkdirAll(dataDir, 0755); err != nil {
fatal("Creating data directory: %v", err)
}
fmt.Printf(" Created directory: %s\n", dataDir)
// Create screens directory.
if err := os.MkdirAll(cfg.System.Screens, 0755); err != nil {
fatal("Creating screens directory: %v", err)
}
fmt.Printf(" Created directory: %s\n", cfg.System.Screens)
// Open database — migrate() creates the schema automatically.
// This replaces the flat-file creation loops in GENERATE.C that
// wrote empty User.Data, Board.Keys/Data, Mail.Keys/Data, etc.
db, err := store.OpenSQLite(cfg.Storage.SQLitePath)
if err != nil {
fatal("Creating database: %v", err)
}
defer db.Close()
fmt.Printf(" Created database: %s\n", cfg.Storage.SQLitePath)
// --- Seed data ---
// The original GENERATE.C pre-allocated fixed-size files with empty
// template structs. We just insert actual starter records.
fmt.Println()
fmt.Println("Populating database...")
fmt.Println()
// 1. Sysop account — always user ID 1, SecStatus 255.
// In the original, the sysop was identified by a backdoor login
// or by slot position. Here, ID 1 is sysop by convention, and
// SecStatus 255 grants full access to everything.
hash, err := auth.HashPassword(sysopPass)
if err != nil {
fatal("Hashing sysop password: %v", err)
}
now := time.Now()
sysop := &models.User{
Name: sysopName,
PasswordHash: hash,
Comments: "System operator",
Active: true,
SecStatus: 255,
SecBoard: 255,
SecLibrary: 255,
SecBulletin: 255,
TimeLimit: 86400, // 24 hours — effectively unlimited
LastOn: &now,
}
if err := db.CreateUser(sysop); err != nil {
fatal("Creating sysop account: %v", err)
}
fmt.Printf(" Sysop account: %s (ID #%d)\n", sysop.Name, sysop.ID)
// 2. Starter boards — mirrors the original's Tag_Boards config.
// "General" is the catch-all board, readable by everyone, writable
// by registered users. "Testing" is wide open for experimentation.
boards := []*models.Board{
{
Name: "General",
ReadLow: 0, ReadHigh: 255,
WriteLow: 1, WriteHigh: 255,
MaxPosts: 200,
},
{
Name: "Testing",
ReadLow: 0, ReadHigh: 255,
WriteLow: 0, WriteHigh: 255,
MaxPosts: 100,
},
}
for _, b := range boards {
if err := db.CreateBoard(b); err != nil {
fatal("Creating board %q: %v", b.Name, err)
}
fmt.Printf(" Board: %s (ID #%d)\n", b.Name, b.ID)
}
// 3. Welcome bulletin — points to a screen file we'll create below.
// In the original, bulletins were read from s:Tag_Bulletins config
// and each had a filename + location + read range.
welcomeBulletin := &models.Bulletin{
Name: "Welcome",
FilePath: filepath.Join(cfg.System.Screens, "bulletin-welcome.ans"),
ReadLow: 0,
ReadHigh: 255,
}
if err := db.CreateBulletin(welcomeBulletin); err != nil {
fatal("Creating welcome bulletin: %v", err)
}
fmt.Printf(" Bulletin: %s (ID #%d)\n", welcomeBulletin.Name, welcomeBulletin.ID)
// 4. Empty file library — like the original's Tag_Libraries config.
// Creates the library record and its on-disk directory.
libPath := filepath.Join(dataDir, "files")
if err := os.MkdirAll(libPath, 0755); err != nil {
fatal("Creating library directory: %v", err)
}
library := &models.Library{
Name: "Files",
FilePath: libPath,
UploadLow: 1, UploadHigh: 255,
DownloadLow: 0, DownloadHigh: 255,
MaxFiles: 200,
}
if err := db.CreateLibrary(library); err != nil {
fatal("Creating library: %v", err)
}
fmt.Printf(" Library: %s (ID #%d, path: %s)\n", library.Name, library.ID, libPath)
// 5. Welcome mail to sysop — a small touch. The original didn't do
// this, but it gives the sysop something to see in the mail system
// immediately and confirms that mail works.
welcomeMail := &models.Mail{
Title: "Welcome to URIT BBS",
Author: "URIT System",
FromID: sysop.ID,
ToID: sysop.ID,
Recipient: sysop.Name,
Body: "Congratulations on setting up your URIT BBS!\n\nYour system is ready to accept callers. You can customize\nthe screen files in the screens/ directory and adjust\nsettings in config.toml.\n\nHappy sysoping!",
}
if err := db.CreateMail(welcomeMail); err != nil {
// Non-fatal — mail is nice to have but not critical.
fmt.Printf(" Warning: could not create welcome mail: %v\n", err)
} else {
fmt.Printf(" Welcome mail: sent to %s\n", sysop.Name)
}
// 6. Screen files — create sample ANSI files for every screen the
// BBS code references via SendFile(). These are plain-text placeholders
// that the sysop can replace with proper ANSI art later.
fmt.Println()
fmt.Println("Creating screen files...")
fmt.Println()
createScreenFiles(cfg.System.Screens, cfg.System.Name, sysop.Name)
// Done!
fmt.Println()
fmt.Println(strings.Repeat("─", 44))
fmt.Println("Initialization complete!")
fmt.Println()
fmt.Printf(" Config: %s\n", configPath)
fmt.Printf(" Database: %s\n", cfg.Storage.SQLitePath)
fmt.Printf(" Screens: %s\n", cfg.System.Screens)
fmt.Println()
fmt.Println("Start the BBS with:")
fmt.Printf(" urit -config %s\n", configPath)
fmt.Println()
}
// loadOrCreateConfig loads config from path, or creates a default config
// file and returns defaults if it doesn't exist.
func loadOrCreateConfig(path string) *config.Config {
cfg, err := config.Load(path)
if err != nil {
fatal("Loading config: %v", err)
}
// If the config file doesn't exist, write the default one.
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
if writeErr := writeDefaultConfig(path); writeErr != nil {
fmt.Printf(" Warning: could not write default config: %v\n", writeErr)
} else {
fmt.Printf(" Created config: %s\n", path)
}
} else {
fmt.Printf(" Using config: %s\n", path)
}
return cfg
}
// writeDefaultConfig writes a default config.toml file.
func writeDefaultConfig(path string) error {
content := `# URIT BBS Configuration
[system]
name = "URIT BBS"
sysop = "Sysop"
location = "./data/" # Base directory for BBS data files
screens = "./screens/" # ANSI art and display files
[telnet]
enabled = true
address = ":2323" # Listen address and port
[ssh]
enabled = false
address = ":2222"
host_key = "./data/ssh_host_key"
[http]
enabled = true
address = ":8080" # HTTP file server
[storage]
driver = "sqlite"
sqlite_path = "./data/urit.db"
[users]
max_accounts = 500
guest_time_limit = 1800 # Seconds (30 minutes)
new_time_limit = 3600 # Seconds (1 hour)
valid_time_limit = 7200 # Seconds (2 hours)
[users.guest_security]
status = 0
board = 0
library = 0
bulletin = 0
[users.new_security]
status = 1
board = 1
library = 1
bulletin = 1
[users.valid_security]
status = 2
board = 2
library = 2
bulletin = 2
[logging]
level = "info" # debug, info, warn, error
file = "" # Empty means stdout only
`
return os.WriteFile(path, []byte(content), 0644)
}
// createScreenFiles writes sample ANSI screen files for every screen
// the BBS references. These use basic ANSI color codes so they look
// reasonable in any terminal. The sysop can replace them with proper
// ANSI art later.
//
// In the original TAG-BBS, screen files were created manually by the
// sysop and referenced by the init config files. Here we generate
// sensible defaults automatically.
func createScreenFiles(screensDir, systemName, sysopName string) {
screens := map[string]string{
"welcome.ans": ansiScreen(
"\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n"+
"\033[1;37m Welcome to "+systemName+"!\033[0m\r\n"+
"\033[36m Powered by URIT BBS\033[0m\r\n"+
"\033[36m Operated by "+sysopName+"\033[0m\r\n"+
"\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n",
),
"login.ans": ansiScreen(
"\033[1;33m Log in with your username and password.\033[0m\r\n"+
"\033[33m Enter \"guest\" to browse as a guest.\033[0m\r\n",
),
"guest.ans": ansiScreen(
"\033[1;33m Welcome, guest!\033[0m\r\n"+
"\033[33m Some features require a registered account.\033[0m\r\n"+
"\033[33m Press [J] at the main menu to join.\033[0m\r\n",
),
"newuser.ans": ansiScreen(
"\033[1;32m Welcome aboard!\033[0m\r\n"+
"\033[32m Your account is new and may need sysop validation.\033[0m\r\n"+
"\033[32m Enjoy your time on the board!\033[0m\r\n",
),
"logon.ans": ansiScreen(
"\033[1;36m Welcome back!\033[0m\r\n",
),
"notime.ans": ansiScreen(
"\033[1;31m You have no remaining time for today.\033[0m\r\n"+
"\033[31m Please try again later.\033[0m\r\n",
),
"mainmenu.ans": "", // Intentionally blank — the generated help list is shown instead.
"help.ans": "", // Intentionally blank — falls through to generated command list.
"join.ans": ansiScreen(
"\033[1;36m Create Your Account\033[0m\r\n"+
"\033[1;36m"+strings.Repeat("─", 30)+"\033[0m\r\n",
),
"joined.ans": ansiScreen(
"\033[1;32m You're in! Welcome to the community.\033[0m\r\n"+
"\033[32m Check out the message boards and say hello.\033[0m\r\n",
),
"goodbye.ans": ansiScreen(
"\033[1;33m Thanks for calling "+systemName+"!\033[0m\r\n"+
"\033[33m See you next time.\033[0m\r\n",
),
"bulletin-welcome.ans": ansiScreen(
"\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n"+
"\033[1;37m Welcome Bulletin\033[0m\r\n"+
"\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n"+
"\r\n"+
"\033[37m Welcome to "+systemName+"!\033[0m\r\n"+
"\r\n"+
"\033[37m This bulletin board system is running URIT BBS,\033[0m\r\n"+
"\033[37m a modern reimplementation of T.A.G.-BBS (1986).\033[0m\r\n"+
"\r\n"+
"\033[37m Check out the message boards, file libraries,\033[0m\r\n"+
"\033[37m and private mail system. If you're new here,\033[0m\r\n"+
"\033[37m post an introduction on the General board!\033[0m\r\n"+
"\r\n"+
"\033[36m — "+sysopName+", Sysop\033[0m\r\n"+
"\r\n"+
"\033[1;36m"+strings.Repeat("━", 50)+"\033[0m\r\n",
),
}
for name, content := range screens {
path := filepath.Join(screensDir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
fmt.Printf(" Warning: could not create %s: %v\n", name, err)
} else {
fmt.Printf(" Screen file: %s\n", name)
}
}
}
// ansiScreen is a trivial helper that returns its input unchanged.
// It exists purely for readability in the screen definitions above.
func ansiScreen(content string) string {
return content
}
// promptLine reads a line of input from the user with a default value.
func promptLine(reader *bufio.Reader, prompt, defaultVal string) string {
if defaultVal != "" {
fmt.Printf("%s [%s]: ", prompt, defaultVal)
} else {
fmt.Printf("%s: ", prompt)
}
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
return defaultVal
}
return line
}
// promptPassword reads a password with confirmation. The password is
// visible on screen — this is acceptable for a local setup command.
func promptPassword(reader *bufio.Reader) string {
for {
fmt.Print("Sysop password: ")
pass1, _ := reader.ReadString('\n')
pass1 = strings.TrimSpace(pass1)
if len(pass1) < 4 {
fmt.Println(" Password must be at least 4 characters.")
continue
}
fmt.Print("Confirm password: ")
pass2, _ := reader.ReadString('\n')
pass2 = strings.TrimSpace(pass2)
if pass1 != pass2 {
fmt.Println(" Passwords do not match. Try again.")
continue
}
return pass1
}
}
// fatal prints an error message and exits.
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}

127
cmd/urit/main.go Normal file
View file

@ -0,0 +1,127 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/urit/urit/internal/config"
"github.com/urit/urit/internal/server"
"github.com/urit/urit/internal/store"
)
const version = "0.2.0"
func main() {
// Subcommand routing. If the first argument is a known subcommand,
// dispatch to it. Otherwise, fall through to the default server mode.
//
// This replaces flag.Parse() at the top level — each subcommand
// (and the default server path) parses its own flags.
if len(os.Args) > 1 {
switch os.Args[1] {
case "init":
runInit(os.Args[2:])
return
case "version", "--version", "-version":
fmt.Printf("URIT BBS v%s\n", version)
return
case "help", "--help", "-help", "-h":
printUsage()
return
}
}
runServer(os.Args[1:])
}
// printUsage displays top-level help.
func printUsage() {
fmt.Printf("URIT BBS v%s\n\n", version)
fmt.Println("Usage:")
fmt.Println(" urit Start the BBS server")
fmt.Println(" urit init Initialize a new BBS instance")
fmt.Println(" urit version Print version and exit")
fmt.Println(" urit help Show this help")
fmt.Println()
fmt.Println("Server flags:")
fmt.Println(" -config <path> Path to configuration file (default: config.toml)")
}
// runServer starts the BBS server. This is the original main() logic,
// now behind a function so the subcommand router can call it.
func runServer(args []string) {
// Parse server-specific flags from the remaining args.
configPath := "config.toml"
for i := 0; i < len(args); i++ {
switch args[i] {
case "-config":
if i+1 < len(args) {
configPath = args[i+1]
i++
} else {
fmt.Fprintf(os.Stderr, "error: -config requires a path\n")
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown flag: %s\n", args[i])
os.Exit(1)
}
}
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
log.Printf("URIT BBS v%s", version)
log.Printf("System: %s", cfg.System.Name)
log.Printf("Sysop: %s", cfg.System.Sysop)
log.Printf("Storage: %s (%s)", cfg.Storage.Driver, cfg.Storage.SQLitePath)
// Open the database — replaces the original's file-based data stores
// (User.Data, System.Data, *.Keys, *.Data).
db, err := store.OpenSQLite(cfg.Storage.SQLitePath)
if err != nil {
log.Fatalf("Database error: %v", err)
}
defer db.Close()
log.Printf("Database open: %s", cfg.Storage.SQLitePath)
srv := server.New(cfg, db)
// Start the HTTP file server if enabled.
// This runs alongside telnet and serves the system info page,
// health endpoint, and (in later steps) library browsing/downloads.
var httpSrv *server.HTTPServer
if cfg.HTTP.Enabled {
httpSrv = server.NewHTTP(cfg, db, srv)
go func() {
if err := httpSrv.ListenAndServe(); err != nil {
log.Printf("HTTP server error: %v", err)
}
}()
}
// Handle shutdown signals — close the listener cleanly so the
// process exits without leaving the port in TIME_WAIT.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("Received %v, shutting down...", sig)
if httpSrv != nil {
httpSrv.Close()
}
srv.Close()
}()
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("Server error: %v", err)
}
log.Printf("URIT BBS shut down.")
}

48
config.toml Normal file
View file

@ -0,0 +1,48 @@
# URIT BBS Configuration
[system]
name = "URIT BBS"
sysop = "Sysop"
location = "./data/" # Base directory for BBS data files
screens = "./screens/" # ANSI art and display files
[telnet]
enabled = true
address = ":2323" # Listen address and port
[ssh]
enabled = false
address = ":2222"
host_key = "./data/ssh_host_key"
[storage]
driver = "sqlite"
sqlite_path = "./data/urit.db"
[users]
max_accounts = 500
guest_time_limit = 1800 # Seconds (30 minutes)
new_time_limit = 3600 # Seconds (1 hour)
valid_time_limit = 7200 # Seconds (2 hours)
[users.guest_security]
status = 0
board = 0
library = 0
bulletin = 0
[users.new_security]
status = 1
board = 1
library = 1
bulletin = 1
[users.valid_security]
status = 2
board = 2
library = 2
bulletin = 2
[logging]
level = "info" # debug, info, warn, error
file = "" # Empty means stdout only

268
docs/ROADMAP-v0.3.0.md Normal file
View file

@ -0,0 +1,268 @@
# URIT BBS — v0.3.0 Roadmap
Status: **Planning**
Previous: v0.2.0 (steps 10a17) — telnet, auth, message boards, mail, file libraries,
bulletins, HTTP file server, inter-node chat, sysop console, sysop guide.
---
## Step 18 — SSH Support
**Priority: 1 — High value, low effort**
The config section (`[ssh]`) is already defined and parsed. The session layer
(`internal/session`) is transport-agnostic — it works on any `net.Conn`. Wiring
up SSH is primarily a listener and key-management task.
### Sub-steps
- **18a — SSH listener and host key management.**
Add an SSH listener to the server using `golang.org/x/crypto/ssh`. Generate a
host key on first run (or via `urit init`) and store it at the configured path.
Accept connections, negotiate a PTY channel, and hand the resulting `net.Conn`
equivalent to the existing `handleConnection` flow.
- **18b — Password authentication callback.**
Wire the SSH password callback to the same `auth.Check` path used by telnet
login. On successful auth, skip the telnet login prompts and drop the user
directly into the post-login flow (logon.ans → main menu). Guest access via
SSH should be configurable (allow/deny in config).
- **18c — Terminal handling.**
Read PTY dimensions from the SSH channel request (replaces telnet NAWS). Map
SSH window-change requests to the session's terminal size updates. Verify ANSI
escape passthrough works correctly over the SSH channel.
### Notes
- The SSH user's identity is known at connection time (unlike telnet, where login
happens after connecting), so the session lifecycle needs a minor branch: skip
the welcome.ans → login prompt sequence and jump to the authenticated path.
- Public key auth is a natural follow-on but not required for the initial
implementation. Password auth matches the existing BBS credential model.
- Vendor `golang.org/x/crypto/ssh` into the vendor directory.
## Step 19 — Door Games
**Priority: 2 — Highest user-facing impact**
Door games are external programs launched from within the BBS, with the user's
terminal piped through. This was the killer feature of multi-node BBSes in the
late '80s and '90s. Supporting even a basic door interface opens up a large
ecosystem of existing door game binaries.
### Sub-steps
- **19a — Door configuration and menu integration.**
Add a `[doors]` config section defining available doors: name, command path,
arguments, working directory, and required SecStatus. Add a `[D]oors` command
to the main menu (relocate the existing `[D] Download` to another key or make
it a sub-option). List available doors and let the user select one.
- **19b — Dropout file generation.**
Generate a DOOR32.SYS dropout file before launching the door. DOOR32.SYS is
the most widely supported modern format and includes: comm type (2=telnet),
socket handle, baud rate (0 for telnet), BBS name, user info, time remaining,
and node number. Write the file to a per-node temp directory.
- **19c — Process execution and I/O bridging.**
Launch the door process with `os/exec`, connecting its stdin/stdout to the
user's telnet (or SSH) connection. Set environment variables that doors
commonly expect: `DOORNODE`, `DROPFILE`, etc. The door process inherits the
terminal. On exit, return the user to the main menu. Handle process timeouts
tied to the user's remaining session time.
- **19d — Cleanup and logging.**
Remove temp dropout files after the door exits. Log door usage (door name,
user, duration). Handle abnormal door exits gracefully (process crash, timeout,
signal). Ensure the terminal state is sane after the door returns (re-send
ANSI reset, re-negotiate telnet options if needed).
### Notes
- Many classic doors expect a DOS-like environment. Running them may require
DOSBox or similar on Linux. URIT's role is just the I/O bridge and dropout
file — the door binary's compatibility is the sysop's responsibility.
- Native Linux doors (written for systems like Mystic, Synchronet) will work
more naturally. Some modern door games are written specifically for telnet BBSes.
- Consider a `door_log` table for tracking door usage statistics.
## Step 20 — Message Threading
**Priority: 3 — Model is ready, UI work needed**
The `ReplyTo` field exists on the Message model but is not surfaced in the UI.
Wiring it up gives boards a threaded conversation feel rather than a flat
chronological list.
### Sub-steps
- **20a — Reply command in message reader.**
When reading a message, add an `[R]eply` option that pre-fills the subject
with "Re: {original subject}" and sets ReplyTo to the parent message ID.
The compose flow is otherwise identical to posting a new message.
- **20b — Thread-aware message listing.**
Modify the board message list to indicate threading. Two approaches to
consider: indented tree view (classic Usenet style) or flat list with
"Re: ..." subjects and a "view thread" command that filters to a single
conversation. The flat approach is simpler and more BBS-authentic.
- **20c — Store support for thread queries.**
Add `ListMessageThread(boardID, rootMessageID)` to the store interface. This
returns all messages in a thread (the root plus all descendants) in
chronological order. Use a recursive CTE in SQLite for efficient traversal.
### Notes
- Keep the default board view as a flat chronological list — threading is
additive, not a replacement. Users who don't care about threads should see
no change in behavior.
- The HTTP side doesn't currently display messages, but if it ever does,
threading support in the store will carry over naturally.
## Step 21 — Email Notifications
**Priority: 4 — Bridges BBS and modern communication**
Lightweight email notifications that pull users back to the BBS when something
happens that's relevant to them.
### Sub-steps
- **21a — SMTP configuration.**
Add an `[email]` config section: enabled flag, SMTP host/port, from address,
auth credentials (optional), TLS mode (none/starttls/tls). Add an `Email`
field to the User model for the notification address (set during registration
or via account settings).
- **21b — Notification triggers.**
Send emails on: new private mail received, reply to a message the user posted
(requires step 20), and sysop broadcast (optional). Each trigger should be
individually toggleable per user via account preferences.
- **21c — Mail queue and sender.**
Implement a background goroutine that processes a notification queue. Outbound
emails are queued (in-memory channel or a database table for persistence) and
sent asynchronously so they never block the BBS session. Rate limit to avoid
overwhelming the SMTP relay. Include an unsubscribe link or instructions in
every email.
- **21d — User preferences.**
Add a notification preferences submenu to the `[A]ccount` command. Let users
set their email address, enable/disable each notification type, and set a
digest preference (immediate vs. daily summary). Store preferences in a new
`user_preferences` table or as additional fields on the user record.
### Notes
- Keep emails plain text with minimal formatting — matches the BBS aesthetic
and avoids HTML email complexity.
- Consider a daily digest mode that batches notifications into a single email
to avoid spamming active boards.
- The email address doubles as an account recovery mechanism in the future.
## Step 22 — Automated Backup
**Priority: 5 — Operational reliability**
A `urit backup` subcommand that snapshots all BBS state into a single archive.
Designed to be run from cron on unattended systems.
### Sub-steps
- **22a — Database snapshot.**
Use SQLite's online backup API (`sqlite3_backup_init`) via the CGo binding to
create a consistent snapshot of the database while the server is running. This
is safe for concurrent reads/writes — no need to stop the server. Write the
snapshot to a temp file, then move it into the backup directory.
- **22b — File collection.**
Copy the screens directory and all library file directories (paths read from
the library records in the database) into the backup staging area. Skip files
that haven't changed since the last backup if a `--incremental` flag is set
(compare mtimes).
- **22c — Archive and rotation.**
Tar/gzip the staged files into a timestamped archive:
`urit-backup-YYYYMMDD-HHMMSS.tar.gz`. Support a `--keep N` flag that
automatically deletes backups older than the N most recent. Default output
directory configurable via `--output` flag or a `[backup]` config section.
- **22d — Cron integration.**
Document cron usage in the sysop guide. Example:
`0 3 * * * /opt/urit/urit backup -config /opt/urit/config.toml --keep 7`
The command should exit cleanly with appropriate exit codes (0=success,
1=partial failure, 2=fatal error) and log to stdout for cron mail capture.
### Notes
- The backup command connects to the database read-only — it does not need the
server to be running, but it works safely if it is.
- Consider adding a `--verify` flag that restores the backup to a temp directory
and runs a schema integrity check.
- A `urit restore` subcommand is a natural follow-on but lower priority.
## Steps 23+ — Future Considerations
The following features are candidates for future versions. Order and scope are
flexible.
### Full-text search (Step 23)
Add keyword search across message boards and mail using SQLite's FTS5 extension.
A `[/]` command at the board level searches subject and body text. Results
displayed as a filtered message list. FTS5 is already compiled into the CGo
binding — the work is creating the virtual table, keeping it in sync with
inserts, and building the search UI.
### File descriptions on telnet (Step 24)
The telnet library browser shows filenames but lacks the rich file listing that
classic BBSes were known for. Add a proper file list display with descriptions,
sizes, dates, download counts, and uploader names — the traditional columnar
BBS file list format. The data is all in the store already; this is purely a
display task.
### ANSI auto-detection (Step 25)
Detect terminal capabilities during telnet negotiation or ask during login.
Provide plain-text fallback screens for minimal clients. Add a user preference
to override detection. Low priority since virtually all modern telnet clients
support ANSI, but it's a polish item for accessibility.
### Rate limiting and connection throttling (Step 26)
Add per-IP connection limiting (max N concurrent connections per IP) and a brief
delay after failed login attempts. The max node count provides a global cap, but
per-IP limits would prevent a single source from consuming all nodes. Important
for public-facing systems.
### Web admin console expansion (Step 27)
Extend the existing `/admin` dashboard with full CRUD for users, boards,
libraries, and bulletins — a complete browser-based replacement for the telnet
sysop menu. The middleware, template system, and store methods all exist; this
is primarily HTML form work. See the step 16 discussion for scope analysis.
---
## Version Summary
| Step | Feature | Priority | Effort |
|------|------------------------|----------|----------|
| 18 | SSH support | 1 | Medium |
| 19 | Door games | 2 | Medium |
| 20 | Message threading | 3 | Low |
| 21 | Email notifications | 4 | Medium |
| 22 | Automated backup | 5 | LowMed |
| 23 | Full-text search | — | Low |
| 24 | Telnet file listings | — | Low |
| 25 | ANSI auto-detection | — | Low |
| 26 | Rate limiting | — | Low |
| 27 | Web admin expansion | — | High |

Binary file not shown.

894
docs/sysop-guide.js Normal file
View file

@ -0,0 +1,894 @@
const fs = require("fs");
const {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
Header, Footer, AlignmentType, LevelFormat, HeadingLevel,
BorderStyle, WidthType, ShadingType, PageNumber, PageBreak,
TabStopType, TabStopPosition
} = require("docx");
// --- Color palette ---
const CLR = {
title: "1A5276",
heading: "1A5276",
accent: "2E75B6",
dimText: "555555",
tblHead: "D6EAF8",
tblAlt: "F2F8FC",
border: "B0C4DE",
black: "000000",
white: "FFFFFF",
};
const border = { style: BorderStyle.SINGLE, size: 1, color: CLR.border };
const borders = { top: border, bottom: border, left: border, right: border };
const cellMargins = { top: 60, bottom: 60, left: 100, right: 100 };
const noBorders = {
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
};
// --- Helpers ---
function h1(text) {
return new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [new TextRun(text)],
spacing: { before: 360, after: 200 },
});
}
function h2(text) {
return new Paragraph({
heading: HeadingLevel.HEADING_2,
children: [new TextRun(text)],
spacing: { before: 280, after: 160 },
});
}
function h3(text) {
return new Paragraph({
heading: HeadingLevel.HEADING_3,
children: [new TextRun(text)],
spacing: { before: 200, after: 120 },
});
}
function para(text, opts = {}) {
const runs = [];
if (typeof text === "string") {
runs.push(new TextRun({ text, ...opts }));
} else {
// Array of TextRun configs
for (const t of text) {
runs.push(new TextRun(t));
}
}
return new Paragraph({
children: runs,
spacing: { after: 140 },
});
}
function bold(text) {
return { text, bold: true };
}
function code(text) {
return { text, font: "Courier New", size: 20, color: "333333" };
}
function codeBlock(lines) {
return lines.map(line =>
new Paragraph({
children: [new TextRun({ text: line || " ", font: "Courier New", size: 19, color: "333333" })],
spacing: { after: 20 },
indent: { left: 400 },
shading: { type: ShadingType.CLEAR, fill: "F5F5F5" },
})
);
}
function bullet(text, opts = {}) {
const runs = [];
if (typeof text === "string") {
runs.push(new TextRun(text));
} else {
for (const t of text) runs.push(new TextRun(t));
}
return new Paragraph({
numbering: { reference: "bullets", level: opts.level || 0 },
children: runs,
spacing: { after: 80 },
});
}
function makeRow(cells, opts = {}) {
return new TableRow({
children: cells.map((text, i) => {
const widths = opts.widths || [4680, 4680];
return new TableCell({
borders,
width: { size: widths[i], type: WidthType.DXA },
margins: cellMargins,
shading: opts.shading ? { fill: opts.shading, type: ShadingType.CLEAR } : undefined,
children: [
new Paragraph({
children: [new TextRun({
text: String(text),
bold: opts.header || false,
font: "Arial",
size: opts.header ? 21 : 20,
color: opts.header ? CLR.heading : CLR.black,
})],
}),
],
});
}),
});
}
function makeTable(headers, rows, widths) {
const totalWidth = widths.reduce((a, b) => a + b, 0);
return new Table({
width: { size: totalWidth, type: WidthType.DXA },
columnWidths: widths,
rows: [
makeRow(headers, { widths, header: true, shading: CLR.tblHead }),
...rows.map((row, i) =>
makeRow(row, { widths, shading: i % 2 === 1 ? CLR.tblAlt : undefined })
),
],
});
}
function spacer() {
return new Paragraph({ spacing: { after: 80 }, children: [] });
}
// --- Document content ---
const children = [];
// ==================== TITLE PAGE ====================
children.push(new Paragraph({ spacing: { before: 3000 }, children: [] }));
children.push(new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "URIT BBS", size: 72, bold: true, color: CLR.title, font: "Arial" })],
}));
children.push(new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { after: 400 },
children: [new TextRun({ text: "Sysop Guide", size: 48, color: CLR.accent, font: "Arial" })],
}));
children.push(new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
children: [new TextRun({ text: "Version 0.2.0", size: 24, color: CLR.dimText, font: "Arial" })],
}));
children.push(new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({
text: "A modern reimplementation of T.A.G.-BBS (1986) in Go",
size: 22, color: CLR.dimText, font: "Arial", italics: true,
})],
}));
children.push(new Paragraph({ children: [new PageBreak()] }));
// ==================== 1. INTRODUCTION ====================
children.push(h1("Introduction"));
children.push(para(
"URIT is a multi-node BBS written in Go, inspired by T.A.G.-BBS (The Amiga Gazette BBS, 1986). " +
"It preserves the classic BBS experience\u2014ANSI screens, message boards, private mail, file libraries, " +
"bulletins\u2014while replacing the Amiga serial port with telnet, flat files with SQLite, and ZMODEM " +
"with HTTP file transfers. Features that the single-node original never had, such as multi-node " +
"support and inter-node chat, are included."
));
children.push(para(
"This guide covers everything a sysop needs to install, configure, and manage a URIT BBS. It assumes " +
"familiarity with BBS concepts (nodes, security levels, message boards, file areas) but not with " +
"URIT or TAG specifically."
));
// ==================== 2. BUILDING AND INSTALLING ====================
children.push(h1("Building and Installing"));
children.push(para("URIT is a single Go binary with no runtime dependencies beyond SQLite (bundled via CGo). You need:"));
children.push(bullet([bold("Go 1.22+"), { text: " \u2014 " }, code("go version"), { text: " to verify" }]));
children.push(bullet([bold("GCC"), { text: " \u2014 required by the SQLite CGo binding (gcc or musl-gcc)" }]));
children.push(spacer());
children.push(para("Clone and build:"));
children.push(...codeBlock([
"git clone https://github.com/urit/urit.git",
"cd urit",
"go build -o urit ./cmd/urit/",
]));
children.push(para(
"This produces a single binary. Dependencies are vendored, so no network access is needed during the build. " +
"Copy the binary wherever you like; all runtime paths are configurable."
));
// ==================== 3. QUICK START ====================
children.push(h1("Quick Start"));
children.push(para("Initialize a new BBS instance and start it:"));
children.push(...codeBlock([
"./urit init",
"./urit",
]));
children.push(para([
{ text: "The " }, code("init"), { text: " command walks you through naming the BBS and creating the sysop account. " +
"It creates a config.toml, a data directory with the SQLite database, seed content (boards, a bulletin, " +
"a file library), and a screens directory with starter ANSI files." }
]));
children.push(para([
{ text: "Once running, connect via telnet on port 2323 (" }, code("telnet localhost 2323"),
{ text: ") and visit " }, code("http://localhost:8080"),
{ text: " in a browser to see the landing page." }
]));
// ==================== 4. COMMAND-LINE USAGE ====================
children.push(h1("Command-Line Usage"));
children.push(para("URIT has two modes: server and initialization."));
children.push(spacer());
children.push(makeTable(
["Command", "Description"],
[
["urit", "Start the BBS server"],
["urit init", "Initialize a new BBS instance"],
["urit version", "Print version and exit"],
["urit help", "Show help"],
],
[3000, 6360],
));
children.push(spacer());
children.push(para([
{ text: "Both " }, code("urit"), { text: " and " }, code("urit init"),
{ text: " accept " }, code("-config <path>"),
{ text: " to specify a configuration file (default: config.toml in the current directory). " +
"The init command also accepts " }, code("-force"), { text: " to overwrite an existing database." },
]));
// ==================== 5. CONFIGURATION ====================
children.push(h1("Configuration"));
children.push(para(
"URIT uses a single TOML file for all configuration. A default config.toml is created during " +
"initialization. Below is a reference of every section."
));
// --- [system] ---
children.push(h2("[system]"));
children.push(makeTable(
["Key", "Default", "Description"],
[
["name", '"URIT BBS"', "BBS name shown on banners and the HTTP landing page"],
["sysop", '"Sysop"', "Sysop display name"],
["location", '"./data/"', "Base data directory"],
["screens", '"./screens/"', "Path to ANSI screen files"],
],
[2000, 2200, 5160],
));
// --- [telnet] ---
children.push(h2("[telnet]"));
children.push(makeTable(
["Key", "Default", "Description"],
[
["enabled", "true", "Enable or disable the telnet listener"],
["address", '":2323"', "Listen address and port"],
],
[2000, 2200, 5160],
));
// --- [http] ---
children.push(h2("[http]"));
children.push(makeTable(
["Key", "Default", "Description"],
[
["enabled", "true", "Enable or disable the HTTP file server"],
["address", '":8080"', "Listen address and port"],
],
[2000, 2200, 5160],
));
children.push(para(
"The HTTP server provides the system landing page, file library browsing, file uploads and downloads, " +
"and a health-check endpoint at /health."
));
// --- [storage] ---
children.push(h2("[storage]"));
children.push(makeTable(
["Key", "Default", "Description"],
[
["driver", '"sqlite"', "Storage backend (only sqlite is supported)"],
["sqlite_path", '"./data/urit.db"', "Path to the SQLite database file"],
],
[2000, 2800, 4560],
));
// --- [users] ---
children.push(h2("[users]"));
children.push(makeTable(
["Key", "Default", "Description"],
[
["max_accounts", "500", "Maximum registered accounts"],
["guest_time_limit", "1800", "Guest session time limit in seconds (30 min)"],
["new_time_limit", "3600", "New user session time limit (60 min)"],
["valid_time_limit", "7200", "Validated user time limit (120 min)"],
],
[2600, 1600, 5160],
));
children.push(spacer());
children.push(para(
"Time limits are enforced per calendar day. If a user reconnects within 12 hours, their remaining " +
"time carries over from the previous session. After 12 hours, the timer resets. The sysop account " +
"(SecStatus 255) always gets a full reset on every login."
));
// --- [users.*_security] ---
children.push(h2("[users.guest_security] / [users.new_security] / [users.valid_security]"));
children.push(makeTable(
["Key", "Description"],
[
["status", "SecStatus tier assigned to users in this class"],
["board", "SecBoard level assigned to users in this class"],
["library", "SecLibrary level assigned to users in this class"],
["bulletin", "SecBulletin level assigned to users in this class"],
],
[2000, 7360],
));
children.push(spacer());
children.push(para(
"These define the default security levels for each user class. When someone registers, they receive " +
"the new_security values. When the sysop validates them, they receive the valid_security values. " +
"See the Security Model section for details on what the levels mean."
));
// --- [logging] ---
children.push(h2("[logging]"));
children.push(makeTable(
["Key", "Default", "Description"],
[
["level", '"info"', "Log verbosity"],
["file", '""', "Log file path (empty means stdout)"],
],
[2000, 2200, 5160],
));
// ==================== 6. SECURITY MODEL ====================
children.push(h1("Security Model"));
children.push(para(
"URIT uses a four-axis security system inherited from the original TAG-BBS. Every user has four " +
"independent security levels that control what they can access."
));
children.push(h2("SecStatus \u2014 Account Tier"));
children.push(para(
"This is the overall account classification. It determines which main menu commands are available " +
"and gates access to the sysop menu."
));
children.push(spacer());
children.push(makeTable(
["Range", "Label", "Description"],
[
["0", "Guest", "Unauthenticated browser; limited to public commands"],
["1", "New", "Registered but awaiting sysop validation"],
["2\u201399", "Valid", "Normal validated user"],
["100\u2013149", "BoardOp", "Can moderate message boards"],
["150\u2013254", "LibOp", "Can manage file libraries"],
["255", "Sysop", "Full administrative access"],
],
[1600, 1400, 6360],
));
children.push(h2("SecBoard, SecLibrary, SecBulletin"));
children.push(para(
"These are numeric levels (typically 0\u201310) checked against the Low/High access range on each board, " +
"library, or bulletin. For example, a board with ReadLow=2 and ReadHigh=10 is visible to any user " +
"whose SecBoard is between 2 and 10 inclusive."
));
children.push(para(
"This lets you create tiered content: public boards at level 0, member boards at level 2, staff " +
"boards at level 5, and so on. The same pattern applies to libraries (with separate ranges for " +
"upload and download access) and bulletins."
));
children.push(para(
"The sysop can edit all four security levels per user from the sysop menu."
));
// ==================== 7. SCREEN FILES ====================
children.push(h1("Screen Files"));
children.push(para(
"Screen files are ANSI art text files displayed at various points during a session. They live in " +
"the directory specified by system.screens (default: ./screens/). Replace them with your own " +
"ANSI art to customize the look and feel of your BBS."
));
children.push(spacer());
children.push(makeTable(
["File", "When Displayed"],
[
["welcome.ans", "Before the login prompt (pre-auth banner)"],
["login.ans", "Alongside the login prompt"],
["guest.ans", "After guest login"],
["newuser.ans", "After first-time registration"],
["logon.ans", "After successful login for returning users"],
["notime.ans", "When a user has no remaining time (then disconnected)"],
["mainmenu.ans", "Above the main menu (blank = show generated command list)"],
["help.ans", "For the [?] help command (blank = generated list)"],
["join.ans", "Before the account creation prompts"],
["joined.ans", "After successful account creation"],
["goodbye.ans", "At logoff"],
],
[2400, 6960],
));
children.push(spacer());
children.push(para(
"Bulletin display files are stored wherever the bulletin\u2019s FilePath points. These are managed " +
"through the sysop bulletin menu."
));
children.push(para(
"Screen files use standard ANSI escape codes. Classic BBS ANSI editors such as PabloDraw, Moebius, " +
"and SyncDraw produce compatible output. Keep line widths to 80 columns for the best terminal experience."
));
// ==================== 8. THE SYSOP MENU ====================
children.push(h1("The Sysop Menu"));
children.push(para([
{ text: "Log in as the sysop account (created during " }, code("urit init"),
{ text: ") and press " }, bold("E"), { text: " at the main menu to enter the sysop management area." },
]));
children.push(spacer());
children.push(makeTable(
["Key", "Command", "Description"],
[
["L", "List all users", "Paginated user listing with status and last login"],
["N", "New/unvalidated", "Shows only users with SecStatus=1 awaiting validation"],
["V", "View/edit user", "Full account editor (security, stats, password, active flag)"],
["B", "Board management", "Create, edit, delete message boards"],
["U", "Bulletin management", "Create, edit, delete bulletins and their display files"],
["F", "File library mgmt", "Create, edit, delete file libraries with access ranges"],
["C", "Call log", "Last 30 connection events with timestamps, IPs, and details"],
["X", "Force disconnect", "Kick a node by number (sends courtesy message first)"],
["Q", "Return", "Back to the main menu"],
],
[800, 2800, 5760],
));
children.push(h2("Validating New Users"));
children.push(para(
"When someone registers, they receive SecStatus=1 (New). They can use the BBS but with limited " +
"access defined by the new_security configuration. To validate a user, navigate to the sysop menu " +
"and press V to view/edit their account, then press 2 to validate. This promotes the user to " +
"the valid_security levels defined in your config. You can also manually adjust their security " +
"levels with keys F through I."
));
children.push(h2("User Account Editor"));
children.push(para(
"From the sysop menu, press V and enter a user ID. The editor displays all account fields and " +
"accepts single-key commands."
));
children.push(spacer());
children.push(makeTable(
["Key", "Action"],
[
["1", "Save changes"],
["ESC", "Cancel (discard changes)"],
["2", "Validate user (set to valid security levels)"],
["3", "Toggle active/deactivated"],
["D", "Permanently delete user"],
["A", "Change username"],
["B", "Reset password"],
["C", "Edit comments"],
["F", "Set SecStatus"],
["G", "Set SecBoard"],
["H", "Set SecLibrary"],
["I", "Set SecBulletin"],
["J\u2013N", "Edit stats (messages posted, mail sent/received, uploads, downloads)"],
["Q", "Set time limit"],
["R", "Set time used"],
],
[1200, 8160],
));
children.push(h2("Board Management"));
children.push(para(
"The board management submenu (sysop menu \u2192 B) lets you list, create, edit, and delete " +
"message boards. Each board has a name and four access boundaries: ReadLow, ReadHigh, WriteLow, " +
"and WriteHigh, all checked against the user\u2019s SecBoard level."
));
children.push(para(
"A board with ReadLow=0 and ReadHigh=255 is visible to everyone. Setting WriteLow=2 means guests " +
"and new users can read but not post. MaxPosts sets the board\u2019s capacity; when reached, the " +
"oldest messages are purged as new ones arrive."
));
children.push(h2("Library Management"));
children.push(para(
"The library management submenu (sysop menu \u2192 F) manages file libraries. Libraries have " +
"separate upload and download access ranges, both checked against the user\u2019s SecLibrary level."
));
children.push(para([
{ text: "A library with DownloadLow=0 is publicly browsable via the HTTP server without login. " +
"Set DownloadLow higher to restrict access to authenticated users. UploadLow and UploadHigh " +
"control who can upload files. MaxFiles sets the capacity limit. " },
bold("FilePath"),
{ text: " is the directory on disk where uploaded files are stored; the server creates it " +
"automatically on the first upload." },
]));
children.push(h2("Bulletin Management"));
children.push(para(
"Bulletins (sysop menu \u2192 U) are ANSI text files displayed to users from the bulletin menu. " +
"Each bulletin has a name, a file path pointing to the display file on disk, and a " +
"ReadLow/ReadHigh range checked against SecBulletin."
));
// ==================== 9. HTTP FILE SERVER ====================
children.push(h1("HTTP File Server"));
children.push(para(
"The HTTP server (port 8080 by default) is the modern replacement for ZMODEM and other file " +
"transfer protocols. It provides library browsing, file downloads, and file uploads through " +
"a browser interface styled to match the BBS\u2019s terminal aesthetic."
));
children.push(h2("Routes"));
children.push(makeTable(
["Path", "Purpose"],
[
["/", "Landing page with system name, stats, and telnet address"],
["/health", "JSON health check for monitoring"],
["/login", "Browser-based login (BBS username/password)"],
["/logout", "Clear session"],
["/libraries", "List of accessible file libraries"],
["/libraries/{id}", "File listing for a specific library"],
["/libraries/{id}/files/{n}", "File download"],
["/libraries/{id}/upload", "File upload form"],
],
[3800, 5560],
));
children.push(h2("Authentication"));
children.push(para("The HTTP server supports two authentication methods."));
children.push(para([
bold("Browser login"),
{ text: " \u2014 Visit /login and enter your BBS username and password. This creates a cookie-based " +
"session lasting 24 hours. The same bcrypt password check as the telnet side is used." },
]));
children.push(para([
bold("Telnet tokens"),
{ text: " \u2014 From the BBS main menu, press D to generate a one-time URL. Open it in your browser " +
"to get authenticated with your BBS security levels. Tokens expire after one hour. This is " +
"convenient for telnet users who want to download files without re-entering their password." },
]));
children.push(para(
"Anonymous visitors see only public libraries (DownloadLow=0). Authenticated users see all " +
"libraries matching their SecLibrary level."
));
children.push(h2("File Uploads"));
children.push(para(
"Authenticated users can upload to libraries where their SecLibrary falls within the library\u2019s " +
"UploadLow\u2013UploadHigh range. The upload form is accessible from the file listing page. " +
"Files are limited to 50 MB. Duplicate filenames within the same library are rejected. The " +
"library\u2019s directory on disk is created automatically if it does not exist."
));
// ==================== 10. INTER-NODE CHAT ====================
children.push(h1("Inter-Node Chat"));
children.push(para([
{ text: "URIT supports real-time communication between connected users. Press " },
bold("C"),
{ text: " at the main menu to enter the chat system. Three modes are available." },
]));
children.push(para([
bold("Page"),
{ text: " \u2014 Send a one-line message to another node. The message appears on their terminal " +
"immediately, regardless of what they are doing. No chat mode is required on either end." },
]));
children.push(para([
bold("Chat"),
{ text: " \u2014 Enter real-time line-by-line messaging with another node. When you start a chat, " +
"the target user receives a notification. If they also press C and select your node, the " +
"channels link and messages flow bidirectionally. Type /quit to exit. If either user " +
"disconnects, the partner is notified." },
]));
children.push(para([
bold("Broadcast"),
{ text: " (sysop only) \u2014 Send a message to every connected node at once. Useful for " +
"maintenance announcements or server shutdown warnings." },
]));
// ==================== 11. MAIN MENU COMMANDS ====================
children.push(h1("Main Menu Commands"));
children.push(para(
"The following commands are available from the main menu. Guest users see only commands marked " +
"as guest-accessible. Registered users see all non-sysop commands."
));
children.push(spacer());
children.push(makeTable(
["Key", "Name", "Description"],
[
["I", "Info", "System information"],
["S", "Stats", "System statistics (calls, messages, time)"],
["T", "Time", "Session time remaining"],
["W", "Who", "Who\u2019s online (node list)"],
["A", "Account", "View your account details"],
["P", "Mail", "Private mail system"],
["M", "Messages", "Message boards"],
["B", "Bulletins", "Bulletin listings"],
["L", "Library", "File library browser (telnet side)"],
["D", "Download", "Generate a web download token URL"],
["C", "Chat", "Page/chat with other users"],
["U", "Users", "User listings"],
["F", "Feedback", "Send a note to the sysop (delivered as mail)"],
["J", "Join", "Create a permanent account (guest only)"],
["?", "Help", "Command list"],
["G", "Goodbye", "Log off"],
["E", "Sysop", "Sysop management menu (sysop only)"],
],
[800, 1600, 6960],
));
// ==================== 12. STATISTICS AND CALL LOG ====================
children.push(h1("Statistics and Call Log"));
children.push(para("URIT tracks system activity in two ways."));
children.push(para([
bold("Stats counters"),
{ text: " are atomic key-value counters stored in the database. The system tracks: total_calls, " +
"guest_calls, new_calls, valid_calls, new_accounts, messages_posted, mail_sent, total_time_secs, " +
"files_downloaded, and files_uploaded. Users can view a summary from the main menu with S." },
]));
children.push(para([
bold("The call log"),
{ text: " records timestamped events (login, logoff, kicked) with the user name, node number, " +
"remote IP, and detail text. The sysop call log (sysop menu \u2192 C) shows the last 30 events " +
"with full detail. The user-facing stats screen shows the last 10 login/logoff events without " +
"IP addresses." },
]));
// ==================== 13. SESSION LIFECYCLE ====================
children.push(h1("Session Lifecycle"));
children.push(para(
"Understanding the session flow helps when troubleshooting or customizing screens. The following " +
"is the sequence from connection to logoff."
));
children.push(spacer());
children.push(makeTable(
["Step", "Action", "Screen File"],
[
["1", "Telnet connection; node allocated", "welcome.ans"],
["2", "Login prompt; username/password checked (3 attempts, then guest)", "login.ans"],
["3a", "New user: registration flow", "newuser.ans"],
["3b", "Guest login", "guest.ans"],
["3c", "Returning user login", "logon.ans"],
["4", "Time check: if >12 hrs since last login, reset; otherwise carry over", "(none)"],
["4x", "No time remaining", "notime.ans"],
["5", "Last caller display, unread mail check, validation notice", "(none)"],
["6", "Main menu loop", "mainmenu.ans"],
["7", "Logoff; time/stats saved; node freed", "goodbye.ans"],
],
[900, 5660, 2800],
));
children.push(spacer());
children.push(para(
"The logoff handler runs in a deferred function, so stats and time are saved even if the " +
"connection drops unexpectedly."
));
// ==================== 14. DATABASE ====================
children.push(h1("Database"));
children.push(para(
"All persistent state lives in a single SQLite database (default: ./data/urit.db). The schema " +
"is created automatically on first run."
));
children.push(spacer());
children.push(makeTable(
["Table", "Contents"],
[
["users", "User accounts (name, password hash, security levels, stats)"],
["boards", "Message boards (name, topic, access ranges)"],
["messages", "Board messages (author, subject, body, timestamps)"],
["mail", "Private mail between users"],
["libraries", "File libraries (name, path, access ranges, capacity)"],
["library_files", "File metadata (name, size, uploader, download count)"],
["bulletins", "Bulletin definitions (name, display file path)"],
["call_log", "Timestamped connection events"],
["stats", "Key-value counters"],
["web_sessions", "HTTP authentication sessions"],
],
[2200, 7160],
));
children.push(spacer());
children.push(para([
{ text: "The database can be backed up by copying the file while the server is stopped. For a " +
"running system, use " },
code('sqlite3 urit.db ".backup backup.db"'),
{ text: " which is safe for concurrent reads." },
]));
// ==================== 15. DEPLOYMENT TIPS ====================
children.push(h1("Deployment Tips"));
children.push(h2("Reverse Proxy"));
children.push(para(
"Put nginx or Caddy in front of the HTTP server for TLS. The telnet server does not support TLS " +
"natively; consider a stunnel or haproxy wrapper if you need encrypted telnet connections."
));
children.push(h2("Systemd"));
children.push(para(
"Create a service unit that runs the binary with your config file. Set Restart=on-failure for " +
"automatic recovery. Use a dedicated system user for isolation. A minimal unit file:"
));
children.push(...codeBlock([
"[Unit]",
"Description=URIT BBS",
"After=network.target",
"",
"[Service]",
"Type=simple",
"User=urit",
"WorkingDirectory=/opt/urit",
"ExecStart=/opt/urit/urit -config /opt/urit/config.toml",
"Restart=on-failure",
"",
"[Install]",
"WantedBy=multi-user.target",
]));
children.push(h2("Backups"));
children.push(para(
"Back up two things: the SQLite database and the screens directory. File libraries are stored on " +
"disk at the paths configured per library, so include those directories as well. " +
"The config.toml should be kept in version control or backed up separately."
));
children.push(h2("Monitoring"));
children.push(para([
{ text: "The " }, code("/health"),
{ text: " endpoint returns JSON with the system name and current timestamp. " +
"Point your monitoring tool at it for basic availability checks." },
]));
// ==================== 16. APPENDIX: CONFIG DEFAULTS ====================
children.push(h1("Appendix: Default Configuration"));
children.push(para("For reference, here is the complete default config.toml produced by initialization:"));
children.push(...codeBlock([
'[system]',
'name = "URIT BBS"',
'sysop = "Sysop"',
'location = "./data/"',
'screens = "./screens/"',
'',
'[telnet]',
'enabled = true',
'address = ":2323"',
'',
'[http]',
'enabled = true',
'address = ":8080"',
'',
'[storage]',
'driver = "sqlite"',
'sqlite_path = "./data/urit.db"',
'',
'[users]',
'max_accounts = 500',
'guest_time_limit = 1800',
'new_time_limit = 3600',
'valid_time_limit = 7200',
'',
'[users.guest_security]',
'status = 0',
'board = 0',
'library = 0',
'bulletin = 0',
'',
'[users.new_security]',
'status = 1',
'board = 1',
'library = 1',
'bulletin = 1',
'',
'[users.valid_security]',
'status = 2',
'board = 2',
'library = 2',
'bulletin = 2',
'',
'[logging]',
'level = "info"',
'file = ""',
]));
// ==================== BUILD DOCUMENT ====================
const doc = new Document({
styles: {
default: {
document: {
run: { font: "Arial", size: 22, color: CLR.black },
},
},
paragraphStyles: [
{
id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 36, bold: true, font: "Arial", color: CLR.title },
paragraph: {
spacing: { before: 360, after: 200 },
outlineLevel: 0,
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: CLR.accent, space: 4 } },
},
},
{
id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 28, bold: true, font: "Arial", color: CLR.heading },
paragraph: { spacing: { before: 240, after: 160 }, outlineLevel: 1 },
},
{
id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true,
run: { size: 24, bold: true, font: "Arial", color: CLR.heading },
paragraph: { spacing: { before: 200, after: 120 }, outlineLevel: 2 },
},
],
},
numbering: {
config: [
{
reference: "bullets",
levels: [
{
level: 0, format: LevelFormat.BULLET, text: "\u2022",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720, hanging: 360 } } },
},
{
level: 1, format: LevelFormat.BULLET, text: "\u2013",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 1440, hanging: 360 } } },
},
],
},
],
},
sections: [
{
properties: {
page: {
size: { width: 12240, height: 15840 },
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: CLR.border, space: 4 } },
children: [
new TextRun({ text: "URIT BBS Sysop Guide", font: "Arial", size: 18, color: CLR.dimText }),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
border: { top: { style: BorderStyle.SINGLE, size: 1, color: CLR.border, space: 4 } },
children: [
new TextRun({ text: "Page ", font: "Arial", size: 18, color: CLR.dimText }),
new TextRun({ children: [PageNumber.CURRENT], font: "Arial", size: 18, color: CLR.dimText }),
],
}),
],
}),
},
children,
},
],
});
Packer.toBuffer(doc).then(buffer => {
fs.writeFileSync("/mnt/user-data/outputs/URIT-BBS-Sysop-Guide.docx", buffer);
console.log("Done: URIT-BBS-Sysop-Guide.docx");
});

10
go.mod Normal file
View file

@ -0,0 +1,10 @@
module github.com/urit/urit
go 1.22.2
require github.com/BurntSushi/toml v1.6.0
require (
github.com/mattn/go-sqlite3 v1.14.34
golang.org/x/crypto v0.31.0
)

6
go.sum Normal file
View file

@ -0,0 +1,6 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=

396
internal/auth/auth.go Normal file
View file

@ -0,0 +1,396 @@
// Package auth implements authentication for URIT BBS.
//
// This replaces LOGON.C from the original TAG-BBS. The original used
// numeric account numbers (slot positions in User.Data), plaintext
// passwords compared with StringCompare(), and a simple "3 tries then
// guest" flow. URIT uses usernames, bcrypt password hashing, and
// the same 3-tries-then-guest pattern.
//
// The original's Sysop_Account_Sequence() (auto-login for local console)
// is not needed since we don't have a local serial port. Sysop logs in
// like any other user.
package auth
import (
"fmt"
"strings"
"time"
"unicode"
"golang.org/x/crypto/bcrypt"
"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 (
bcryptCost = 12
maxLoginTries = 3
inputTimeout = 60 * time.Second
maxNameLen = 30
maxPassLen = 72 // bcrypt's max input length
minPassLen = 4
)
// Result describes the outcome of the authentication flow.
type Result struct {
User *models.User
IsGuest bool
IsNew bool
}
// Login runs the full login sequence on the given session.
// This is the replacement for Logon_Sequence() in LOGON.C.
//
// The flow:
// 1. Display login screen file if it exists
// 2. Prompt for name or "NEW" or "GUEST"
// 3. If existing user: verify password (3 tries, then forced guest)
// 4. If "NEW": run account creation flow
// 5. If "GUEST": create an ephemeral guest session
// 6. If first-ever user: auto-create as sysop
func Login(sess *session.Session, db store.Store, cfg *config.Config) (*Result, error) {
// Display the login screen file if the sysop has installed one
sess.SendFile(cfg.System.Screens + "login.ans")
// Check if this is a fresh install (no users yet).
// If so, the first person to create an account becomes the sysop.
userCount, err := db.CountUsers()
if err != nil {
return nil, fmt.Errorf("counting users: %w", err)
}
firstRun := userCount == 0
if firstRun {
sess.Color(session.AnsiFgBrightYellow)
sess.WriteString("*** First run detected — first account will be Sysop ***\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
}
tries := 0
for {
if tries >= maxLoginTries {
sess.WriteString("Three tries and you're out.\r\n")
sess.WriteString("Now you get a Guest account.\r\n\r\n")
return guestLogin(sess, cfg), nil
}
sess.Color(session.AnsiFgCyan)
sess.WriteString("Enter your username (NEW for new account, GUEST for guest): ")
sess.Color(session.AnsiReset)
name, err := sess.ReadLine("", maxNameLen, inputTimeout)
if err != nil {
return nil, err
}
name = strings.TrimSpace(name)
if name == "" {
tries++
continue
}
upper := strings.ToUpper(name)
// Guest access
if upper == "GUEST" {
return guestLogin(sess, cfg), nil
}
// New account
if upper == "NEW" {
if firstRun {
return newAccount(sess, db, cfg, true)
}
return newAccount(sess, db, cfg, false)
}
// Existing user login
user, err := db.GetUserByName(name)
if err != nil {
return nil, fmt.Errorf("looking up user: %w", err)
}
if user == nil {
sess.WriteString("No account with that name.\r\n")
// Offer to create if name looks intentional
yes, err := sess.Confirm("Create a new account? (Y/N) ", inputTimeout)
if err != nil {
return nil, err
}
if yes {
if firstRun {
return newAccountWithName(sess, db, cfg, name, true)
}
return newAccountWithName(sess, db, cfg, name, false)
}
tries++
continue
}
// User found — verify password
result, err := passwordLogin(sess, db, cfg, user)
if err != nil {
return nil, err
}
if result != nil {
return result, nil
}
// Password failed
tries++
}
}
// passwordLogin prompts for a password and verifies it against the
// stored bcrypt hash. Returns nil result if password is wrong.
func passwordLogin(sess *session.Session, db store.Store, cfg *config.Config, user *models.User) (*Result, error) {
sess.Color(session.AnsiFgCyan)
sess.WriteString("Password: ")
sess.Color(session.AnsiReset)
pass, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout)
if err != nil {
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil {
sess.WriteString("Invalid password.\r\n\r\n")
return nil, nil // Signal: wrong password, but not a fatal error
}
// Successful login — update last-on time
now := time.Now()
user.LastOn = &now
user.TimeUsed = 0 // Reset per-session time
if err := db.UpdateUser(user); err != nil {
return nil, fmt.Errorf("updating user: %w", err)
}
sess.NewLine()
sess.Color(session.AnsiFgGreen)
sess.Printf("Welcome back, %s!\r\n", user.Name)
sess.Color(session.AnsiReset)
// Set session time limit from user record
sess.TimeLimit = time.Duration(user.TimeLimit) * time.Second
return &Result{User: user}, nil
}
// guestLogin creates a transient guest session with no database record.
// This replaces New_Account_Sequence() from the original, which despite
// its name actually created a guest session (not a saved account).
func guestLogin(sess *session.Session, cfg *config.Config) *Result {
guest := &models.User{
Name: "Guest",
SecStatus: cfg.Users.GuestSecurity.Status,
SecBoard: cfg.Users.GuestSecurity.Board,
SecLibrary: cfg.Users.GuestSecurity.Library,
SecBulletin: cfg.Users.GuestSecurity.Bulletin,
TimeLimit: int64(cfg.Users.GuestTimeLimit),
Active: true,
}
sess.TimeLimit = time.Duration(cfg.Users.GuestTimeLimit) * time.Second
// Display guest login screen if available
sess.SendFile(cfg.System.Screens + "guest.ans")
sess.Color(session.AnsiFgYellow)
sess.WriteString("Logged in as Guest.\r\n")
sess.Color(session.AnsiReset)
return &Result{User: guest, IsGuest: true}
}
// newAccount runs the new account creation flow.
func newAccount(sess *session.Session, db store.Store, cfg *config.Config, isSysop bool) (*Result, error) {
sess.NewLine()
// Display new user screen if available
sess.SendFile(cfg.System.Screens + "newuser.ans")
sess.Color(session.AnsiFgCyan)
sess.WriteString("Choose a username: ")
sess.Color(session.AnsiReset)
name, err := sess.ReadLine("", maxNameLen, inputTimeout)
if err != nil {
return nil, err
}
return createAccount(sess, db, cfg, strings.TrimSpace(name), isSysop)
}
// newAccountWithName runs account creation with a pre-chosen name
// (when the user typed a name that didn't exist and said "yes" to
// creating it).
func newAccountWithName(sess *session.Session, db store.Store, cfg *config.Config, name string, isSysop bool) (*Result, error) {
sess.NewLine()
sess.SendFile(cfg.System.Screens + "newuser.ans")
return createAccount(sess, db, cfg, name, isSysop)
}
// createAccount handles the shared account creation logic.
func createAccount(sess *session.Session, db store.Store, cfg *config.Config, name string, isSysop bool) (*Result, error) {
// Validate name
if err := validateName(name); err != nil {
sess.Printf("%s\r\n", err)
return guestLogin(sess, cfg), nil
}
// Check for duplicate
existing, err := db.GetUserByName(name)
if err != nil {
return nil, fmt.Errorf("checking name: %w", err)
}
if existing != nil {
sess.WriteString("That name is already taken.\r\n")
return guestLogin(sess, cfg), nil
}
// Check account limit
count, err := db.CountUsers()
if err != nil {
return nil, fmt.Errorf("counting users: %w", err)
}
if count >= cfg.Users.MaxAccounts {
sess.WriteString("Sorry, maximum accounts reached.\r\n")
return guestLogin(sess, cfg), nil
}
// Get password
sess.Color(session.AnsiFgCyan)
sess.WriteString("Choose a password: ")
sess.Color(session.AnsiReset)
pass, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout)
if err != nil {
return nil, err
}
if len(pass) < minPassLen {
sess.Printf("Password must be at least %d characters.\r\n", minPassLen)
return guestLogin(sess, cfg), nil
}
// Confirm password
sess.Color(session.AnsiFgCyan)
sess.WriteString("Confirm password: ")
sess.Color(session.AnsiReset)
confirm, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout)
if err != nil {
return nil, err
}
if pass != confirm {
sess.WriteString("Passwords do not match.\r\n")
return guestLogin(sess, cfg), nil
}
// Hash the password
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcryptCost)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
// Build the user record
now := time.Now()
user := &models.User{
Name: name,
PasswordHash: string(hash),
Active: true,
LastOn: &now,
}
if isSysop {
// First user ever — full sysop privileges
user.SecStatus = 255
user.SecBoard = 255
user.SecLibrary = 255
user.SecBulletin = 255
user.TimeLimit = 86400 // 24 hours — effectively unlimited
} else {
// Normal new user — gets "new" security tier
user.SecStatus = cfg.Users.NewSecurity.Status
user.SecBoard = cfg.Users.NewSecurity.Board
user.SecLibrary = cfg.Users.NewSecurity.Library
user.SecBulletin = cfg.Users.NewSecurity.Bulletin
user.TimeLimit = int64(cfg.Users.NewTimeLimit)
}
if err := db.CreateUser(user); err != nil {
// Could be a race condition on duplicate name
sess.WriteString("Error creating account. Please try again.\r\n")
return guestLogin(sess, cfg), nil
}
sess.TimeLimit = time.Duration(user.TimeLimit) * time.Second
sess.NewLine()
sess.Color(session.AnsiFgGreen, session.AnsiBold)
if isSysop {
sess.Printf("Sysop account created: %s (ID #%d)\r\n", user.Name, user.ID)
} else {
sess.Printf("Account created: %s (ID #%d)\r\n", user.Name, user.ID)
sess.Color(session.AnsiFgYellow)
sess.WriteString("Your account is NEW and must be validated by the Sysop\r\n")
sess.WriteString("for full access.\r\n")
}
sess.Color(session.AnsiReset)
sess.NewLine()
return &Result{User: user, IsNew: !isSysop}, nil
}
// validateName checks that a username meets requirements.
func validateName(name string) error {
if len(name) < 2 {
return fmt.Errorf("name must be at least 2 characters")
}
if len(name) > maxNameLen {
return fmt.Errorf("name must be %d characters or fewer", maxNameLen)
}
upper := strings.ToUpper(name)
if upper == "GUEST" || upper == "NEW" || upper == "SYSOP" {
return fmt.Errorf("%q is a reserved name", name)
}
// Must contain at least one letter
hasLetter := false
for _, r := range name {
if unicode.IsLetter(r) {
hasLetter = true
}
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != ' ' && r != '-' && r != '_' && r != '.' {
return fmt.Errorf("name can only contain letters, numbers, spaces, hyphens, underscores, and periods")
}
}
if !hasLetter {
return fmt.Errorf("name must contain at least one letter")
}
return nil
}
// HashPassword generates a bcrypt hash for the given password.
// Exported for use by admin tools and tests.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword verifies a password against a bcrypt hash.
// Exported for use by admin tools and tests.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View file

@ -0,0 +1,79 @@
package auth
import (
"testing"
)
func TestHashAndCheckPassword(t *testing.T) {
hash, err := HashPassword("testpass123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if hash == "" {
t.Fatal("HashPassword returned empty string")
}
if hash == "testpass123" {
t.Fatal("Hash should not equal plaintext")
}
// Correct password
if !CheckPassword(hash, "testpass123") {
t.Error("CheckPassword should return true for correct password")
}
// Wrong password
if CheckPassword(hash, "wrongpassword") {
t.Error("CheckPassword should return false for wrong password")
}
// Empty password
if CheckPassword(hash, "") {
t.Error("CheckPassword should return false for empty password")
}
}
func TestValidateName(t *testing.T) {
tests := []struct {
name string
wantErr bool
}{
{"Alice", false},
{"Bob Smith", false},
{"user-1", false},
{"user_2", false},
{"test.user", false},
{"CoolDude99", false},
// Too short
{"A", true},
{"", true},
// Reserved
{"GUEST", true},
{"guest", true},
{"NEW", true},
{"new", true},
{"SYSOP", true},
{"sysop", true},
// No letters
{"123", true},
{"---", true},
// Invalid characters
{"user@name", true},
{"user!name", true},
{"user/name", true},
{"user<script>", true},
}
for _, tt := range tests {
err := validateName(tt.name)
if tt.wantErr && err == nil {
t.Errorf("validateName(%q) = nil, want error", tt.name)
}
if !tt.wantErr && err != nil {
t.Errorf("validateName(%q) = %v, want nil", tt.name, err)
}
}
}

148
internal/config/config.go Normal file
View file

@ -0,0 +1,148 @@
package config
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
// Config is the top-level configuration for URIT BBS.
type Config struct {
System SystemConfig `toml:"system"`
Telnet TelnetConfig `toml:"telnet"`
SSH SSHConfig `toml:"ssh"`
HTTP HTTPConfig `toml:"http"`
Storage StorageConfig `toml:"storage"`
Users UsersConfig `toml:"users"`
Logging LoggingConfig `toml:"logging"`
}
// SystemConfig holds general BBS identity and paths.
type SystemConfig struct {
Name string `toml:"name"`
Sysop string `toml:"sysop"`
Location string `toml:"location"`
Screens string `toml:"screens"`
}
// TelnetConfig controls the telnet listener.
type TelnetConfig struct {
Enabled bool `toml:"enabled"`
Address string `toml:"address"`
}
// SSHConfig controls the SSH listener.
type SSHConfig struct {
Enabled bool `toml:"enabled"`
Address string `toml:"address"`
HostKey string `toml:"host_key"`
}
// HTTPConfig controls the HTTP file server.
// This serves file libraries for download via browser — the modern
// replacement for ZMODEM and other transfer protocols.
type HTTPConfig struct {
Enabled bool `toml:"enabled"`
Address string `toml:"address"`
}
// StorageConfig selects and configures the storage backend.
type StorageConfig struct {
Driver string `toml:"driver"`
SQLitePath string `toml:"sqlite_path"`
}
// SecurityLevel defines access levels for a user tier.
type SecurityLevel struct {
Status int `toml:"status"`
Board int `toml:"board"`
Library int `toml:"library"`
Bulletin int `toml:"bulletin"`
}
// UsersConfig holds user account defaults.
type UsersConfig struct {
MaxAccounts int `toml:"max_accounts"`
GuestTimeLimit int `toml:"guest_time_limit"`
NewTimeLimit int `toml:"new_time_limit"`
ValidTimeLimit int `toml:"valid_time_limit"`
GuestSecurity SecurityLevel `toml:"guest_security"`
NewSecurity SecurityLevel `toml:"new_security"`
ValidSecurity SecurityLevel `toml:"valid_security"`
}
// LoggingConfig controls log output.
type LoggingConfig struct {
Level string `toml:"level"`
File string `toml:"file"`
}
// Load reads a TOML config file from the given path and returns a Config.
// If the file does not exist, it returns a Config populated with defaults.
func Load(path string) (*Config, error) {
cfg := defaults()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %s: %w", path, err)
}
return cfg, nil
}
// defaults returns a Config with sensible default values.
// These match the values in the shipped config.toml.
func defaults() *Config {
return &Config{
System: SystemConfig{
Name: "URIT BBS",
Sysop: "Sysop",
Location: "./data/",
Screens: "./screens/",
},
Telnet: TelnetConfig{
Enabled: true,
Address: ":2323",
},
SSH: SSHConfig{
Enabled: false,
Address: ":2222",
HostKey: "./data/ssh_host_key",
},
HTTP: HTTPConfig{
Enabled: true,
Address: ":8080",
},
Storage: StorageConfig{
Driver: "sqlite",
SQLitePath: "./data/urit.db",
},
Users: UsersConfig{
MaxAccounts: 500,
GuestTimeLimit: 1800,
NewTimeLimit: 3600,
ValidTimeLimit: 7200,
GuestSecurity: SecurityLevel{
Status: 0, Board: 0, Library: 0, Bulletin: 0,
},
NewSecurity: SecurityLevel{
Status: 1, Board: 1, Library: 1, Bulletin: 1,
},
ValidSecurity: SecurityLevel{
Status: 2, Board: 2, Library: 2, Bulletin: 2,
},
},
Logging: LoggingConfig{
Level: "info",
File: "",
},
}
}

143
internal/menu/bulletins.go Normal file
View file

@ -0,0 +1,143 @@
// bulletins.go implements the bulletin subsystem.
//
// This replaces BCOM.C from the original TAG-BBS. Bulletins are
// read-only text (or ANSI art) files displayed to users. The sysop
// creates them outside the BBS and registers them in the database.
//
// The original stored bulletins as a linked list of Bulletin_Header
// structs loaded from System.Data at startup. Each had a Filename
// and Location (directory path) plus ReadLow/ReadHigh access levels.
// Reading a bulletin called MenuSend() which opened the file and
// sent it line-by-line with flow control.
//
// Our version stores bulletin metadata in SQLite and reads the actual
// files from the FilePath column. The session's SendFile method
// handles ANSI display and flow control.
package menu
import (
"fmt"
"os"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdBulletins is the entry point for the bulletin subsystem.
// Replaces BCom() from BCOM.C.
func cmdBulletins(ctx *Context) error {
bulletins, err := ctx.Store.ListBulletins()
if err != nil {
ctx.Sess.WriteString(" Error loading bulletins.\r\n")
return nil
}
// Filter to those the user can read
var visible []*models.Bulletin
for _, b := range bulletins {
if ctx.User.SecBulletin >= b.ReadLow && ctx.User.SecBulletin <= b.ReadHigh {
visible = append(visible, b)
}
}
if len(visible) == 0 {
ctx.Sess.WriteString(" No bulletins available.\r\n\r\n")
return nil
}
ctx.Sess.NewLine()
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("L>ist Q>uit\r\n")
ctx.Sess.Printf("Bulletin to read [1-%d]: ", len(visible))
ctx.Sess.Color(session.AnsiReset)
// Read a line (not a single key) so users can type a number
// — matches original: LineInput("",string,5,120L)
input, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return nil
}
input = strings.TrimSpace(input)
if input == "" {
continue
}
switch {
case strings.EqualFold(input, "L"):
bulletinList(ctx, visible)
case strings.EqualFold(input, "Q") || input == "@":
return nil
case input == "?" || input == "/":
bulletinList(ctx, visible)
default:
num := parseIntDefault(input, 0)
if num < 1 || num > len(visible) {
ctx.Sess.Printf(" Enter 1-%d, L to list, Q to quit.\r\n", len(visible))
continue
}
bulletinRead(ctx, visible[num-1], num)
}
}
}
// bulletinList shows all accessible bulletins.
// Replaces Bulletin_Listings() from BCOM.C.
func bulletinList(ctx *Context, bulletins []*models.Bulletin) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Bulletin Listings\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
for i, b := range bulletins {
ctx.Sess.Printf(" [%d] %s\r\n", i+1, b.Name)
}
ctx.Sess.NewLine()
}
// bulletinRead displays a single bulletin file.
// Replaces Read_Bulletin() from BCOM.C — the original just called
// MenuSend(location + filename) which sent the file with flow control.
func bulletinRead(ctx *Context, b *models.Bulletin, num int) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("Bulletin #%d: %s\r\n", num, b.Name)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
// Check if file exists
if _, err := os.Stat(b.FilePath); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" File not found: %s\r\n", b.FilePath)
ctx.Sess.Color(session.AnsiReset)
return
}
// Read and display the file
data, err := os.ReadFile(b.FilePath)
if err != nil {
ctx.Sess.WriteString(" Error reading bulletin file.\r\n")
return
}
// Display with pagination
content := string(data)
ctx.Sess.Paginate(content, idleTimeout)
ctx.Sess.NewLine()
// Pause after display — classic BBS "press any key"
ctx.Sess.WaitForKey(
fmt.Sprintf("%s--- End of Bulletin #%d ---%s Press any key... ",
session.AnsiFgBrightBlack, num, session.AnsiReset),
idleTimeout,
)
ctx.Sess.NewLine()
}

289
internal/menu/chat.go Normal file
View file

@ -0,0 +1,289 @@
package menu
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/urit/urit/internal/session"
)
// cmdChat is the main entry point for inter-node communication.
//
// The original TAG-BBS was single-node (Amiga serial), so there was
// no inter-node chat. Multi-node BBSes of the era (TBBS, Major BBS,
// Wildcat) typically offered "page" (one-shot notification) and
// "chat" (real-time line-by-line messaging between two nodes).
//
// Our implementation provides both:
// [P] Page — send a one-line message to another node's screen
// [C] Chat — enter real-time chat mode with another node
// [B] Broadcast — sysop sends a message to all nodes (sysop only)
func cmdChat(ctx *Context) error {
if ctx.Chat == nil || ctx.Nodes == nil {
ctx.Sess.WriteString(" Chat is not available.\r\n")
return nil
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Inter-Node Chat\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
// Show who's online
nodes := ctx.Nodes.ActiveNodes()
otherCount := 0
for _, n := range nodes {
if n.Node == ctx.Sess.Node {
continue
}
name := n.UserName
if name == "" {
name = "(connecting)"
}
ctx.Sess.Printf(" Node %-3d %s\r\n", n.Node, name)
otherCount++
}
if otherCount == 0 {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" No other users online.\r\n")
ctx.Sess.Color(session.AnsiReset)
return nil
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" [P]age [C]hat [B]roadcast [Q]uit\r\n")
ctx.Sess.Color(session.AnsiReset)
key, err := ctx.Sess.ReadKey(30 * time.Second)
if err != nil {
return nil
}
switch key | 0x20 { // lowercase
case 'p':
return chatPage(ctx, nodes)
case 'c':
return chatEnter(ctx, nodes)
case 'b':
if ctx.User.IsSysop() {
return chatBroadcast(ctx)
}
ctx.Sess.WriteString(" Broadcast is sysop-only.\r\n")
}
return nil
}
// chatPage sends a one-shot message to another node's screen.
func chatPage(ctx *Context, nodes []NodeInfo) error {
ctx.Sess.NewLine()
ctx.Sess.WriteString(" Page which node? ")
input, err := ctx.Sess.ReadLine("", 5, 15*time.Second)
if err != nil || input == "" {
return nil
}
targetNode, err := strconv.Atoi(strings.TrimSpace(input))
if err != nil || targetNode == ctx.Sess.Node {
ctx.Sess.WriteString(" Invalid node.\r\n")
return nil
}
// Verify node exists and has a user
var targetName string
for _, n := range nodes {
if n.Node == targetNode && n.UserName != "" {
targetName = n.UserName
break
}
}
if targetName == "" {
ctx.Sess.WriteString(" That node is not available.\r\n")
return nil
}
ctx.Sess.WriteString(" Message: ")
msg, err := ctx.Sess.ReadLine("", 70, 30*time.Second)
if err != nil || msg == "" {
return nil
}
// Format and send the page
page := fmt.Sprintf(
"\r\n\x1b[1;33m>> Page from %s (Node %d): %s\x1b[0m\r\n",
ctx.User.Name, ctx.Sess.Node, msg)
if err := ctx.Nodes.SendToNode(targetNode, page); err != nil {
ctx.Sess.WriteString(" Could not reach that node.\r\n")
return nil
}
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Page sent to %s.\r\n", targetName)
ctx.Sess.Color(session.AnsiReset)
return nil
}
// chatEnter puts the user into real-time chat mode with another node.
//
// The chat is line-by-line: the user types a line, it appears on the
// partner's screen, and vice versa. A background goroutine receives
// incoming messages and injects them into the terminal between the
// user's own input prompts. This is the classic BBS chat style —
// messages interleave naturally rather than using a split screen.
func chatEnter(ctx *Context, nodes []NodeInfo) error {
ctx.Sess.NewLine()
ctx.Sess.WriteString(" Chat with which node? ")
input, err := ctx.Sess.ReadLine("", 5, 15*time.Second)
if err != nil || input == "" {
return nil
}
targetNode, err := strconv.Atoi(strings.TrimSpace(input))
if err != nil || targetNode == ctx.Sess.Node {
ctx.Sess.WriteString(" Invalid node.\r\n")
return nil
}
// Verify node exists
var targetName string
for _, n := range nodes {
if n.Node == targetNode && n.UserName != "" {
targetName = n.UserName
break
}
}
if targetName == "" {
ctx.Sess.WriteString(" That node is not available.\r\n")
return nil
}
// Enter chat mode — get our incoming message channel
incoming := ctx.Chat.EnterChat(ctx.Sess.Node)
// Try to link with the target if they're also in chat mode
linked := ctx.Chat.LinkChat(ctx.Sess.Node, targetNode)
// Send page notification to the target
notification := fmt.Sprintf(
"\r\n\x1b[1;33m>> %s (Node %d) wants to chat! Press [C] to respond.\x1b[0m\r\n",
ctx.User.Name, ctx.Sess.Node)
ctx.Nodes.SendToNode(targetNode, notification)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("── Chat Mode ──\r\n")
ctx.Sess.Color(session.AnsiReset)
if linked {
ctx.Sess.Printf(" Connected with %s. Type /quit to exit.\r\n", targetName)
} else {
ctx.Sess.Printf(" Waiting for %s to join... Type /quit to exit.\r\n", targetName)
}
ctx.Sess.NewLine()
// Background goroutine: receive messages and display them.
// Stops when the channel is closed (EndChat) or context cancelled.
done := make(chan struct{})
go func() {
defer close(done)
for msg := range incoming {
if msg == "" {
// Empty string = partner disconnected
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString("\r\n ** Partner has left chat **\r\n")
ctx.Sess.Color(session.AnsiReset)
return
}
// Display the incoming message
ctx.Sess.WriteString("\r" + msg)
}
}()
// Main loop: read user input and send to partner
for {
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("%s> ", ctx.User.Name)
ctx.Sess.Color(session.AnsiReset)
line, err := ctx.Sess.ReadLine("", 200, 120*time.Second)
if err != nil {
break
}
if strings.EqualFold(strings.TrimSpace(line), "/quit") {
break
}
if line == "" {
continue
}
// Check if we're linked now (partner may have joined since we started)
if ctx.Chat.ChatPartner(ctx.Sess.Node) == 0 {
// Not linked yet — try again
if ctx.Chat.IsInChat(targetNode) {
ctx.Chat.LinkChat(ctx.Sess.Node, targetNode)
}
}
// Format and send
formatted := fmt.Sprintf("\x1b[1;36m%s>\x1b[0m %s\r\n",
ctx.User.Name, line)
if !ctx.Chat.SendChat(ctx.Sess.Node, formatted) {
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" (not connected — waiting for partner)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
// Clean up
ctx.Chat.EndChat(ctx.Sess.Node)
// Wait for receiver goroutine to finish
select {
case <-done:
case <-time.After(time.Second):
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" Chat ended.\r\n")
ctx.Sess.Color(session.AnsiReset)
return nil
}
// chatBroadcast sends a message to all connected nodes (sysop only).
func chatBroadcast(ctx *Context) error {
ctx.Sess.NewLine()
ctx.Sess.WriteString(" Broadcast message: ")
msg, err := ctx.Sess.ReadLine("", 70, 30*time.Second)
if err != nil || msg == "" {
return nil
}
broadcast := fmt.Sprintf(
"\r\n\x1b[1;31m>> SYSOP BROADCAST: %s\x1b[0m\r\n", msg)
nodes := ctx.Nodes.ActiveNodes()
sent := 0
for _, n := range nodes {
if n.Node == ctx.Sess.Node {
continue
}
if err := ctx.Nodes.SendToNode(n.Node, broadcast); err == nil {
sent++
}
}
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Broadcast sent to %d node(s).\r\n", sent)
ctx.Sess.Color(session.AnsiReset)
return nil
}

777
internal/menu/library.go Normal file
View file

@ -0,0 +1,777 @@
// library.go implements the file library subsystem.
//
// This replaces LCOM.C from the original TAG-BBS. The original stored
// library metadata in Library.Keys files (arrays of Library_Data structs)
// and the actual files on disk in per-library directories. File transfer
// used XMODEM over serial (TRANSFER.C).
//
// Our version stores metadata in SQLite and keeps files on disk in the
// library's FilePath directory. The catalog browsing experience is fully
// preserved: three-level navigation matching the message board pattern
// (library selection → per-library commands → between-file prompts).
//
// File transfer: the original's XMODEM is specific to serial. Over
// telnet, the standard is ZMODEM (via external sz/rz tools). This step
// implements full catalog browsing and file metadata management. Actual
// binary transfer (ZMODEM integration) is noted as a future enhancement.
// Text files can be viewed inline.
package menu
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdLibrary is the entry point for the file library subsystem.
// Replaces LCom() from LCOM.C — the top-level library selection menu.
func cmdLibrary(ctx *Context) error {
libs, err := ctx.Store.ListLibraries()
if err != nil {
ctx.Sess.WriteString(" Error loading libraries.\r\n")
return nil
}
// Filter to those the user can access
var visible []*models.Library
for _, l := range libs {
if l.CanDownload(ctx.User.SecLibrary) || l.CanUpload(ctx.User.SecLibrary) {
visible = append(visible, l)
}
}
if len(visible) == 0 {
ctx.Sess.WriteString(" No file libraries available.\r\n\r\n")
return nil
}
ctx.Sess.NewLine()
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("N>ew S>ome A>ll L>ist Q>uit\r\n")
ctx.Sess.Printf("%s Library> ", ctx.Cfg.System.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
libList(ctx, visible)
case 'A':
ctx.Sess.WriteString("All Libraries\r\n\r\n")
for i, l := range visible {
ctx.Sess.Printf("[%d] %s\r\n", i+1, l.Name)
quit := libPrompt(ctx, l, i+1)
if quit {
goto done
}
}
ctx.Sess.WriteString("Completed visiting ALL\r\n")
case 'N':
ctx.Sess.WriteString("New Files\r\n\r\n")
found := false
for i, l := range visible {
if !l.CanDownload(ctx.User.SecLibrary) {
continue
}
if ctx.User.LastOn != nil && l.LatestFile != nil &&
!l.LatestFile.After(*ctx.User.LastOn) {
continue
}
found = true
ctx.Sess.Printf("\r\n[%d] %s\r\n", i+1, l.Name)
libReadNew(ctx, l)
quit := libPrompt(ctx, l, i+1)
if quit {
goto done
}
}
if !found {
ctx.Sess.WriteString("Nothing new.\r\n")
}
ctx.Sess.WriteString("Completed visiting NEW\r\n")
case 'S':
ctx.Sess.WriteString("Some Libraries\r\n\r\n")
for i, l := range visible {
ctx.Sess.Printf("Visit [%d] %s? ", i+1, l.Name)
ych, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ych) {
case 'Y':
ctx.Sess.WriteString("Yes\r\n")
quit := libPrompt(ctx, l, i+1)
if quit {
goto done
}
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
goto done
default:
ctx.Sess.WriteString("No\r\n")
}
}
ctx.Sess.WriteString("Completed visiting SOME\r\n")
case 'Q', '\x1b':
ctx.Sess.WriteString("Quit\r\n")
goto done
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
libList(ctx, visible)
}
}
done:
return nil
}
// libList displays the list of visible libraries with file counts.
func libList(ctx *Context, libs []*models.Library) {
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("File Libraries\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 55) + "\r\n")
for i, l := range libs {
access := ""
if l.CanDownload(ctx.User.SecLibrary) && l.CanUpload(ctx.User.SecLibrary) {
access = "DL/UL"
} else if l.CanDownload(ctx.User.SecLibrary) {
access = "DL "
} else if l.CanUpload(ctx.User.SecLibrary) {
access = " UL"
}
latest := "no files"
if l.LatestFile != nil {
latest = l.LatestFile.Format("Jan 02")
}
ctx.Sess.Printf(" [%d] %-20s %3d files %s %s\r\n",
i+1, l.Name, l.FileCount, access, latest)
}
ctx.Sess.NewLine()
}
// libPrompt is the per-library command loop.
// Replaces Lib_Prompt() from LCOM.C.
// Returns true if the user wants to return to the main menu (@).
func libPrompt(ctx *Context, lib *models.Library, num int) bool {
canDL := lib.CanDownload(ctx.User.SecLibrary)
canUL := lib.CanUpload(ctx.User.SecLibrary)
for {
ctx.Sess.CheckTime()
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
opts := ""
if canDL {
opts += "N>ew L>ist C>atalog D>ownload "
}
if canUL {
opts += "U>pload "
}
if ctx.User.SecStatus >= 150 {
opts += "R>emove "
}
opts += "Q>uit @>Main"
ctx.Sess.WriteString(opts + "\r\n")
ctx.Sess.Printf("[%d] %s> ", num, lib.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return true
}
switch toUpper(ch) {
case 'N':
if !canDL {
continue
}
ctx.Sess.WriteString("New\r\n")
libReadNew(ctx, lib)
case 'L':
if !canDL {
continue
}
ctx.Sess.WriteString("List\r\n")
libListFrontend(ctx, lib)
case 'C':
if !canDL {
continue
}
ctx.Sess.WriteString("Catalog\r\n")
libCatalog(ctx, lib)
case 'D', 'I':
if !canDL {
continue
}
ctx.Sess.WriteString("Download\r\n")
libDownload(ctx, lib)
case 'U':
if !canUL {
continue
}
ctx.Sess.WriteString("Upload\r\n")
libUpload(ctx, lib)
case 'R':
if ctx.User.SecStatus < 150 {
continue
}
ctx.Sess.WriteString("Remove\r\n")
libRemove(ctx, lib)
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return false
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return true
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N - List new files since last login\r\n")
ctx.Sess.WriteString(" L - List files by number range\r\n")
ctx.Sess.WriteString(" C - Quick catalog (names only)\r\n")
ctx.Sess.WriteString(" D - Download a file\r\n")
ctx.Sess.WriteString(" U - Upload a file\r\n")
if ctx.User.SecStatus >= 150 {
ctx.Sess.WriteString(" R - Remove a file entry\r\n")
}
ctx.Sess.WriteString(" Q - Return to library selection\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// libReadNew lists files added since the user's last login.
// Replaces Lib_ReadNew() from LCOM.C.
func libReadNew(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles)
if err != nil {
ctx.Sess.WriteString(" Error loading files.\r\n")
return
}
// Filter to new files
var newFiles []*models.LibraryFile
for _, f := range files {
if ctx.User.LastOn == nil || f.CreatedAt.After(*ctx.User.LastOn) {
newFiles = append(newFiles, f)
}
}
if len(newFiles) == 0 {
ctx.Sess.WriteString(" Nothing new.\r\n")
return
}
ctx.Sess.Printf(" %d new file(s)\r\n\r\n", len(newFiles))
libReadSequence(ctx, lib, newFiles)
}
// libListFrontend prompts for a range and lists files with full details.
// Replaces Lib_Read_Frontend() from LCOM.C.
func libListFrontend(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
ctx.Sess.Printf(" List FROM [1-%d]: ", lib.FileCount)
fromStr, err := ctx.Sess.ReadLine("1", 5, inputTimeout)
if err != nil {
return
}
from := parseIntDefault(fromStr, 1)
if from < 1 || from > lib.FileCount {
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", lib.FileCount)
return
}
ctx.Sess.Printf(" List TO [%d-%d]: ", from, lib.FileCount)
toStr, err := ctx.Sess.ReadLine(fmt.Sprintf("%d", lib.FileCount), 5, inputTimeout)
if err != nil {
return
}
to := parseIntDefault(toStr, lib.FileCount)
if to < from || to > lib.FileCount {
ctx.Sess.Printf(" Invalid. Range is %d-%d\r\n", from, lib.FileCount)
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, from-1, to-from+1)
if err != nil {
ctx.Sess.WriteString(" Error loading files.\r\n")
return
}
libReadSequence(ctx, lib, files)
}
// libCatalog shows a quick filename-only listing.
// Replaces Lib_Catalog() from LCOM.C.
func libCatalog(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles)
if err != nil {
ctx.Sess.WriteString(" Error loading files.\r\n")
return
}
ctx.Sess.NewLine()
var lines []string
for i, f := range files {
lines = append(lines, fmt.Sprintf(" [%d] %-30s %s %s",
i+1, f.Filename, formatSize(f.FileSize),
f.CreatedAt.Format("Jan 02")))
}
ctx.Sess.Paginate(strings.Join(lines, "\n"), idleTimeout)
ctx.Sess.NewLine()
}
// libReadSequence displays files in order with between-file prompts.
// Replaces Lib_Read() from LCOM.C.
func libReadSequence(ctx *Context, lib *models.Library, files []*models.LibraryFile) {
if len(files) == 0 {
return
}
idx := 0
for idx >= 0 && idx < len(files) {
f := files[idx]
libDisplayFile(ctx, lib, f, idx+1, len(files))
action := libBetweenPrompt(ctx, lib, f, idx+1)
switch action {
case libActNext:
idx++
case libActAgain:
// stay
case libActLast:
if idx > 0 {
idx--
}
case libActQuit:
return
case libActMainMenu:
return
}
}
}
// libDisplayFile renders a single file entry.
// Replaces Send_Message() (the library version) from LCOM.C.
func libDisplayFile(ctx *Context, lib *models.Library, f *models.LibraryFile, num, total int) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("File Number: [%d] of [%d]\r\n", num, total)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" Filename: %s\r\n", f.Filename)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.Printf(" Origin: %s [%d]\r\n", f.Uploader, f.UploaderID)
ctx.Sess.Printf(" Size: %s\r\n", formatSize(f.FileSize))
ctx.Sess.Printf(" Validated: %s\r\n", f.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
ctx.Sess.Printf(" Downloads: %d\r\n", f.Downloads)
if f.Description != "" {
ctx.Sess.NewLine()
ctx.Sess.WriteString(f.Description + "\r\n")
} else {
ctx.Sess.WriteString(" No description available.\r\n")
}
}
type libAction int
const (
libActNext libAction = iota
libActAgain
libActLast
libActQuit
libActMainMenu
)
// libBetweenPrompt shows navigation options between file entries.
// Replaces Between_Lib_Prompt() from LCOM.C.
func libBetweenPrompt(ctx *Context, lib *models.Library, f *models.LibraryFile, num int) libAction {
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
opts := "N>ext A>gain L>ast S>tats D>ownload Q>uit @>Main"
if ctx.User.SecStatus >= 150 {
opts += " R>emove"
}
ctx.Sess.WriteString("\r\n" + opts + "\r\n")
ctx.Sess.WriteString("File> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return libActQuit
}
switch toUpper(ch) {
case 'N', 'C', '\r':
ctx.Sess.WriteString("Next\r\n")
return libActNext
case 'A':
ctx.Sess.WriteString("Again\r\n")
return libActAgain
case 'L':
ctx.Sess.WriteString("Last\r\n")
return libActLast
case 'S':
ctx.Sess.WriteString("Statistics\r\n")
libShowStats(ctx, lib, f)
case 'D':
ctx.Sess.WriteString("Download\r\n")
libDoDownload(ctx, lib, f)
return libActNext
case 'R':
if ctx.User.SecStatus < 150 {
continue
}
ctx.Sess.WriteString("Remove\r\n")
yes, err := ctx.Sess.Confirm(" Remove this file entry? ", inputTimeout)
if err != nil {
return libActQuit
}
if yes {
ctx.Store.DeleteLibraryFile(f.ID)
if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil {
*lib = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" File entry removed.\r\n")
ctx.Sess.WriteString(" (The file itself remains on disk.)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
return libActNext
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return libActQuit
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return libActMainMenu
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N/Enter - Next file\r\n")
ctx.Sess.WriteString(" A - View again\r\n")
ctx.Sess.WriteString(" L - Previous file\r\n")
ctx.Sess.WriteString(" S - File statistics\r\n")
ctx.Sess.WriteString(" D - Download file\r\n")
ctx.Sess.WriteString(" Q - Return to library menu\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// libShowStats displays detailed OS-level stats for a file.
// Replaces the 'S' (statistics / report()) command from LCOM.C.
func libShowStats(ctx *Context, lib *models.Library, f *models.LibraryFile) {
fullPath := filepath.Join(lib.FilePath, f.Filename)
info, err := os.Stat(fullPath)
if err != nil {
ctx.Sess.Printf(" File not found on disk: %s\r\n", fullPath)
return
}
ctx.Sess.NewLine()
ctx.Sess.Printf(" Filename: %s\r\n", f.Filename)
ctx.Sess.Printf(" On disk: %s\r\n", fullPath)
ctx.Sess.Printf(" Size: %s (%d bytes)\r\n", formatSize(info.Size()), info.Size())
ctx.Sess.Printf(" Modified: %s\r\n", info.ModTime().Format("Mon Jan 02 15:04:05 2006"))
ctx.Sess.Printf(" DL count: %d\r\n", f.Downloads)
ctx.Sess.NewLine()
}
// libDoDownload handles a file download request.
//
// The original used XMODEM over serial (Xmodem_Send from TRANSFER.C).
// Over telnet, binary transfer requires ZMODEM or similar. For now:
// - Text files (.txt, .nfo, .diz, .ans): displayed inline
// - Binary files: show path and size, note ZMODEM needed
//
// Future enhancement: integrate external sz (ZMODEM send) tool.
func libDoDownload(ctx *Context, lib *models.Library, f *models.LibraryFile) {
fullPath := filepath.Join(lib.FilePath, f.Filename)
info, err := os.Stat(fullPath)
if err != nil {
ctx.Sess.WriteString(" File not found on disk.\r\n")
return
}
ext := strings.ToLower(filepath.Ext(f.Filename))
isText := ext == ".txt" || ext == ".nfo" || ext == ".diz" ||
ext == ".ans" || ext == ".asc" || ext == ".doc" ||
ext == ".1st" || ext == ".me" || ext == ""
if isText && info.Size() < 64*1024 {
// Small text files — display inline (like a bulletin)
data, err := os.ReadFile(fullPath)
if err != nil {
ctx.Sess.WriteString(" Error reading file.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Paginate(string(data), idleTimeout)
ctx.Sess.NewLine()
// Increment download counter
f.Downloads++
ctx.User.Downloads++
ctx.Store.UpdateUser(ctx.User)
} else {
// Binary or large file — inform the user
ctx.Sess.NewLine()
ctx.Sess.Printf(" File: %s (%s)\r\n", f.Filename, formatSize(info.Size()))
ctx.Sess.Printf(" Path: %s\r\n", fullPath)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightYellow)
ctx.Sess.WriteString(" Binary file transfer requires a ZMODEM-capable terminal.\r\n")
ctx.Sess.WriteString(" (ZMODEM integration coming in a future update.)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
// libDownload prompts for a file number and initiates download.
// Replaces Lib_Immediate() from LCOM.C.
func libDownload(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files in this library.\r\n")
return
}
ctx.Sess.Printf(" Download which [1-%d]: ", lib.FileCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > lib.FileCount {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, num-1, 1)
if err != nil || len(files) == 0 {
ctx.Sess.WriteString(" File not found.\r\n")
return
}
libDoDownload(ctx, lib, files[0])
}
// libUpload registers a new file in the library.
// Replaces Lib_Upload() from LCOM.C.
//
// The original received the file via XMODEM, then stored the metadata.
// Our version records the metadata and expects the sysop to place the
// actual file in the library directory (or, for future ZMODEM, receive
// the file into that directory).
func libUpload(ctx *Context, lib *models.Library) {
if lib.FileCount >= lib.MaxFiles {
ctx.Sess.WriteString(" Library is full.\r\n")
return
}
ctx.Sess.NewLine()
// Filename
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Filename (Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
filename, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
filename = strings.TrimSpace(filename)
if filename == "" || strings.EqualFold(filename, "Q") {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Security check: no path traversal
// Matches original's check for : / * characters
if strings.ContainsAny(filename, ":/\\*?\"<>|") {
ctx.Sess.WriteString(" Filename cannot contain special characters (: / \\ * ? \" < > |).\r\n")
return
}
// Check for duplicate — replaces Lib_Crossref()
existing, _ := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles)
for _, f := range existing {
if strings.EqualFold(f.Filename, filename) {
ctx.Sess.WriteString(" A file with that name already exists.\r\n")
return
}
}
ctx.Sess.WriteString(" Filename accepted.\r\n\r\n")
// Description
ctx.Sess.WriteString("Description (79 chars): ")
desc, err := ctx.Sess.ReadLine("", 79, inputTimeout)
if err != nil {
return
}
// Uploader name — sysops can override
uploader := ctx.User.Name
if ctx.User.SecStatus >= 150 {
ctx.Sess.WriteString("Name to use: ")
nameInput, err := ctx.Sess.ReadLine(uploader, 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput != "" {
uploader = nameInput
}
}
// Check if file actually exists on disk
fullPath := filepath.Join(lib.FilePath, filename)
var fileSize int64
if info, err := os.Stat(fullPath); err == nil {
fileSize = info.Size()
ctx.Sess.Printf(" File found on disk: %s\r\n", formatSize(fileSize))
} else {
ctx.Sess.Color(session.AnsiFgBrightYellow)
ctx.Sess.WriteString(" File not yet on disk.\r\n")
ctx.Sess.Printf(" Place it at: %s\r\n", fullPath)
ctx.Sess.Color(session.AnsiReset)
}
// Save the entry
entry := &models.LibraryFile{
LibraryID: lib.ID,
Filename: filename,
Description: strings.TrimSpace(desc),
UploaderID: ctx.User.ID,
Uploader: uploader,
FileSize: fileSize,
}
if err := ctx.Store.CreateLibraryFile(entry); err != nil {
ctx.Sess.WriteString(" Error saving file entry.\r\n")
return
}
ctx.User.Uploads++
ctx.Store.UpdateUser(ctx.User)
// Refresh library
if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil {
*lib = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" File entry #%d created.\r\n", entry.ID)
ctx.Sess.Color(session.AnsiReset)
}
// libRemove deletes a file entry from the library.
// Replaces Lib_Delete_Frontend() from LCOM.C.
func libRemove(ctx *Context, lib *models.Library) {
if lib.FileCount == 0 {
ctx.Sess.WriteString(" No files to remove.\r\n")
return
}
ctx.Sess.Printf(" Remove which [1-%d] (0 to cancel): ", lib.FileCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > lib.FileCount {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
files, err := ctx.Store.ListLibraryFiles(lib.ID, num-1, 1)
if err != nil || len(files) == 0 {
ctx.Sess.WriteString(" File not found.\r\n")
return
}
f := files[0]
ctx.Sess.Printf(" Remove \"%s\" by %s? ", f.Filename, f.Uploader)
yes, err := ctx.Sess.Confirm("", inputTimeout)
if err != nil || !yes {
ctx.Sess.WriteString(" Not removed.\r\n")
return
}
ctx.Store.DeleteLibraryFile(f.ID)
if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil {
*lib = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" File entry removed.\r\n")
ctx.Sess.WriteString(" (The file itself remains on disk.)\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// formatSize formats a byte count as a human-readable string.
func formatSize(bytes int64) string {
switch {
case bytes >= 1024*1024:
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
case bytes >= 1024:
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
default:
return fmt.Sprintf("%d B", bytes)
}
}

564
internal/menu/mail.go Normal file
View file

@ -0,0 +1,564 @@
// mail.go implements the private mail subsystem.
//
// This replaces PCOM.C from the original TAG-BBS. The original stored
// mail in a Mail.Keys file (array of Mail_Data structs with slot numbers,
// author/recipient codes) and a Mail.Data file (fixed 2500-byte bodies
// at lseek offsets). Our version stores everything in the SQLite mail table.
//
// The user-facing flow is preserved:
// PCom() → Mail_Prompt: L>ist, R>ead, S>end, Q>uit (+ sysop: $>Read, D>elete)
//
// Key improvements over the original:
// - Read tracking (unread flag) — the original had no concept of read/unread
// - Search by name uses the store's case-insensitive lookup instead of
// scanning all slots with the jive() pattern matcher
// - No slot-count limit — mail limited by disk space, not a fixed array
package menu
import (
"fmt"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdMail is the entry point for private mail.
// Replaces PCom() → Mail_Prompt() from PCOM.C.
func cmdMail(ctx *Context) error {
ctx.Sess.NewLine()
// Show unread count
unread, _ := ctx.Store.CountUnreadMail(ctx.User.ID)
if unread > 0 {
ctx.Sess.Color(session.AnsiFgBrightYellow)
if unread == 1 {
ctx.Sess.WriteString(" You have 1 letter waiting!\r\n")
} else {
ctx.Sess.Printf(" You have %d letters waiting!\r\n", unread)
}
ctx.Sess.Color(session.AnsiReset)
}
for {
ctx.Sess.CheckTime()
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("L>ist R>ead S>end Q>uit\r\n")
if ctx.User.IsSysop() {
ctx.Sess.WriteString("Sysop: $>Read-all D>elete\r\n")
}
ctx.Sess.WriteString("Mail> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n")
mailList(ctx)
case 'R':
ctx.Sess.WriteString("Read\r\n")
mailRead(ctx)
case 'S':
ctx.Sess.WriteString("Send\r\n")
mailWrite(ctx, 0, "")
case '$', '4':
// Sysop: read all mail (any user's)
if !ctx.User.IsSysop() {
continue
}
ctx.Sess.WriteString("Read All\r\n")
mailSysopRead(ctx)
case 'D':
if !ctx.User.IsSysop() {
continue
}
ctx.Sess.WriteString("Delete\r\n")
mailSysopDelete(ctx)
case 'Q', '\x1b':
ctx.Sess.WriteString("Quit\r\n")
return nil
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" L - List your mail\r\n")
ctx.Sess.WriteString(" R - Read your mail\r\n")
ctx.Sess.WriteString(" S - Send a letter\r\n")
ctx.Sess.WriteString(" Q - Return to main menu\r\n")
if ctx.User.IsSysop() {
ctx.Sess.WriteString(" $ - Read all mail (sysop)\r\n")
ctx.Sess.WriteString(" D - Delete mail (sysop)\r\n")
}
}
}
}
// mailList shows all mail addressed to the current user.
// Replaces Mail_List(mh, User.Slot_Number) from PCOM.C.
func mailList(ctx *Context) {
letters, err := ctx.Store.ListMailFor(ctx.User.ID)
if err != nil {
ctx.Sess.WriteString(" Error loading mail.\r\n")
return
}
if len(letters) == 0 {
ctx.Sess.WriteString(" No mail.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Your Mail\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 55) + "\r\n")
for _, m := range letters {
unreadMark := " "
if !m.Read {
unreadMark = "*"
}
ctx.Sess.Printf(" %s[%3d] %-25s from %s %s\r\n",
unreadMark, m.ID, truncate(m.Title, 25), m.Author,
m.CreatedAt.Format("Jan 02"))
}
ctx.Sess.NewLine()
}
// mailRead reads the user's mail sequentially with between-letter prompts.
// Replaces Mail_Immediate() and Mail_Read() flow from PCOM.C.
func mailRead(ctx *Context) {
letters, err := ctx.Store.ListMailFor(ctx.User.ID)
if err != nil {
ctx.Sess.WriteString(" Error loading mail.\r\n")
return
}
if len(letters) == 0 {
ctx.Sess.WriteString(" No mail.\r\n")
return
}
ctx.Sess.Printf(" %d letter(s). Read which #? (Enter for all, 0 to cancel): ", len(letters))
numStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(strings.TrimSpace(numStr), -1)
if num == 0 {
return
}
if num > 0 {
// Read a specific letter by ID
m, err := ctx.Store.GetMail(int64(num))
if err != nil || m == nil || m.ToID != ctx.User.ID {
ctx.Sess.WriteString(" Letter not found.\r\n")
return
}
mailDisplay(ctx, m)
mailMarkRead(ctx, m)
mailBetweenPrompt(ctx, m)
return
}
// Read all — sequential
idx := 0
for idx < len(letters) {
m := letters[idx]
mailDisplay(ctx, m)
mailMarkRead(ctx, m)
action := mailBetweenPrompt(ctx, m)
switch action {
case mailNext:
idx++
case mailAgain:
// stay
case mailQuit:
return
case mailDeleted:
// Refresh list
letters, err = ctx.Store.ListMailFor(ctx.User.ID)
if err != nil || idx >= len(letters) {
return
}
}
}
ctx.Sess.WriteString(" End of mail.\r\n")
}
// mailDisplay renders a single letter.
// Replaces Send_Mail() from PCOM.C.
func mailDisplay(ctx *Context, m *models.Mail) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("Letter #%d\r\n", m.ID)
ctx.Sess.Color(session.AnsiReset)
if m.Title != "" {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf("Subject: %s\r\n", m.Title)
ctx.Sess.Color(session.AnsiReset)
}
ctx.Sess.Printf(" From: %s [%d]\r\n", m.Author, m.FromID)
ctx.Sess.Printf(" To: %s [%d]\r\n", m.Recipient, m.ToID)
ctx.Sess.Printf(" Time: %s\r\n", m.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
ctx.Sess.NewLine()
ctx.Sess.Paginate(m.Body, idleTimeout)
}
// mailMarkRead marks a letter as read if it hasn't been.
func mailMarkRead(ctx *Context, m *models.Mail) {
if !m.Read {
ctx.Store.MarkMailRead(m.ID)
m.Read = true
}
}
type mailAction int
const (
mailNext mailAction = iota
mailAgain
mailQuit
mailDeleted
)
// mailBetweenPrompt shows navigation options between letters.
// Replaces Between_Mail_Prompt() from PCOM.C.
func mailBetweenPrompt(ctx *Context, m *models.Mail) mailAction {
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("\r\nN>ext A>gain R>eply D>elete Q>uit\r\n")
ctx.Sess.WriteString("Mail> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return mailQuit
}
switch toUpper(ch) {
case 'N', 'C', '\r':
ctx.Sess.WriteString("Next\r\n")
return mailNext
case 'A':
ctx.Sess.WriteString("Again\r\n")
return mailAgain
case 'R':
ctx.Sess.WriteString("Reply\r\n")
// Reply goes to the sender
mailWrite(ctx, m.FromID, "Re: "+m.Title)
return mailNext
case 'D':
ctx.Sess.WriteString("Delete\r\n")
yes, err := ctx.Sess.Confirm(" Delete this letter? ", inputTimeout)
if err != nil {
return mailQuit
}
if yes {
if err := ctx.Store.DeleteMail(m.ID); err != nil {
ctx.Sess.WriteString(" Error deleting.\r\n")
} else {
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
return mailDeleted
}
}
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return mailQuit
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N/Enter - Next letter\r\n")
ctx.Sess.WriteString(" A - Read again\r\n")
ctx.Sess.WriteString(" R - Reply to sender\r\n")
ctx.Sess.WriteString(" D - Delete this letter\r\n")
ctx.Sess.WriteString(" Q - Return to mail menu\r\n")
}
}
}
// mailWrite composes and sends a new letter.
// Replaces Mail_Write() and Mail_Reply_To() from PCOM.C.
//
// If toUserID > 0, it's a direct reply (skip recipient selection).
// If defaultTitle is set, it's pre-filled as the subject.
func mailWrite(ctx *Context, toUserID int64, defaultTitle string) {
ctx.Sess.NewLine()
// Determine recipient
var recipient *models.User
if toUserID > 0 {
// Direct reply — recipient already known
var err error
recipient, err = ctx.Store.GetUser(toUserID)
if err != nil || recipient == nil {
ctx.Sess.WriteString(" Recipient account not found.\r\n")
return
}
ctx.Sess.Printf(" Writing to %s [%d]\r\n\r\n", recipient.Name, recipient.ID)
} else {
// Ask for recipient by name
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Send to (name or Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
nameInput, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput == "" || strings.EqualFold(nameInput, "Q") {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
recipient, err = ctx.Store.GetUserByName(nameInput)
if err != nil || recipient == nil {
ctx.Sess.WriteString(" No user with that name.\r\n")
return
}
ctx.Sess.Printf(" Writing to %s [%d]\r\n\r\n", recipient.Name, recipient.ID)
}
// Subject
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Subject (Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
title, err := ctx.Sess.ReadLine(defaultTitle, 60, inputTimeout)
if err != nil {
return
}
title = strings.TrimSpace(title)
if strings.EqualFold(title, "Q") || title == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Body
ctx.Sess.WriteString("\r\nEnter your letter (blank line when done):\r\n")
lines := mailEditLoop(ctx)
if lines == nil {
return
}
// Edit menu — simplified from message boards (no continue/edit)
for {
ctx.Sess.WriteString("\r\nA>bort S>end L>ist C>ontinue\r\n")
ctx.Sess.WriteString("Letter> ")
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'S':
ctx.Sess.WriteString("Send\r\n")
goto send
case 'A', 'Q':
ctx.Sess.WriteString("Abort\r\n")
yes, err := ctx.Sess.Confirm(" Discard this letter? ", inputTimeout)
if err != nil || yes {
ctx.Sess.WriteString(" Discarded.\r\n")
return
}
case 'C':
ctx.Sess.WriteString("Continue\r\n")
more := mailEditLoop(ctx)
if more == nil {
return
}
lines = append(lines, more...)
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
for i, l := range lines {
ctx.Sess.Printf("%3d> %s\r\n", i+1, l)
}
}
}
send:
body := strings.Join(lines, "\n")
if strings.TrimSpace(body) == "" {
ctx.Sess.WriteString(" Empty letter — not sent.\r\n")
return
}
mail := &models.Mail{
Title: title,
Author: ctx.User.Name,
FromID: ctx.User.ID,
ToID: recipient.ID,
Recipient: recipient.Name,
Body: body,
}
if err := ctx.Store.CreateMail(mail); err != nil {
ctx.Sess.WriteString(" Error sending letter.\r\n")
return
}
// Update stats
ctx.User.MailSent++
ctx.Store.UpdateUser(ctx.User)
ctx.Store.IncrementStat("mail_sent", 1)
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Letter sent to %s.\r\n", recipient.Name)
ctx.Sess.Color(session.AnsiReset)
}
// mailEditLoop reads lines until a blank line, same as message editor.
func mailEditLoop(ctx *Context) []string {
var lines []string
lineNum := 1
for {
ctx.Sess.Printf("%3d> ", lineNum)
line, err := ctx.Sess.ReadLine("", 75, inputTimeout)
if err != nil {
return nil
}
if strings.TrimSpace(line) == "" {
break
}
lines = append(lines, line)
lineNum++
if lineNum > 100 {
ctx.Sess.WriteString(" Maximum lines reached.\r\n")
break
}
}
return lines
}
// mailSysopRead lets the sysop read any user's mail.
// Replaces the '$' command (Mail_Read_Frontend) from PCOM.C.
func mailSysopRead(ctx *Context) {
ctx.Sess.WriteString(" Enter user name to read mail for: ")
nameInput, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput == "" {
return
}
target, err := ctx.Store.GetUserByName(nameInput)
if err != nil || target == nil {
ctx.Sess.WriteString(" No user with that name.\r\n")
return
}
letters, err := ctx.Store.ListMailFor(target.ID)
if err != nil {
ctx.Sess.WriteString(" Error loading mail.\r\n")
return
}
if len(letters) == 0 {
ctx.Sess.Printf(" No mail for %s.\r\n", target.Name)
return
}
ctx.Sess.Printf(" %d letter(s) for %s:\r\n\r\n", len(letters), target.Name)
for _, m := range letters {
unreadMark := " "
if !m.Read {
unreadMark = "*"
}
ctx.Sess.Printf(" %s[%3d] %-25s from %-12s to %-12s %s\r\n",
unreadMark, m.ID, truncate(m.Title, 25),
m.Author, m.Recipient, m.CreatedAt.Format("Jan 02"))
}
ctx.Sess.WriteString("\r\n Read which # (0 to cancel): ")
numStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num == 0 {
return
}
m, err := ctx.Store.GetMail(int64(num))
if err != nil || m == nil {
ctx.Sess.WriteString(" Letter not found.\r\n")
return
}
mailDisplay(ctx, m)
}
// mailSysopDelete lets the sysop delete any mail.
// Replaces Mail_Delete_Frontend() from PCOM.C.
func mailSysopDelete(ctx *Context) {
ctx.Sess.WriteString(" Delete letter #: ")
numStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num == 0 {
return
}
m, err := ctx.Store.GetMail(int64(num))
if err != nil || m == nil {
ctx.Sess.WriteString(" Letter not found.\r\n")
return
}
ctx.Sess.Printf(" Letter #%d: \"%s\" from %s to %s\r\n",
m.ID, m.Title, m.Author, m.Recipient)
yes, err := ctx.Sess.Confirm(" Delete? ", inputTimeout)
if err != nil || !yes {
ctx.Sess.WriteString(" Not deleted.\r\n")
return
}
if err := ctx.Store.DeleteMail(m.ID); err != nil {
ctx.Sess.WriteString(" Error deleting.\r\n")
return
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// truncate shortens a string to maxLen, adding "..." if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return fmt.Sprintf("%-*s", maxLen, s)
}
return s[:maxLen-3] + "..."
}

863
internal/menu/menu.go Normal file
View file

@ -0,0 +1,863 @@
// 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
}

902
internal/menu/messages.go Normal file
View file

@ -0,0 +1,902 @@
// messages.go implements the message board subsystem.
//
// This replaces MCOM.C from the original TAG-BBS. The original had
// three nested menu levels:
//
// MCom() → Board selection: N>ew, S>ome, A>ll, L>ist, Q>uit
// Msg_Prompt(board) → Per-board: N>ew, R>ead, I>mmediate, W>rite, Q>uit
// Between_Msg_Prompt → Between messages: A>gain, N>ext, L>ast, R>eply, D>elete
//
// The message storage is completely different from the original:
//
// Original: Board_Header (linked list in memory) + Board.Keys (flat file
// of Board_Data structs with slot numbers) + Board.Data (flat file with
// fixed 2500-byte message bodies at lseek offsets).
//
// Modern: SQLite tables (boards + messages) with foreign keys, variable-
// length text bodies, and automatic numbering via the store layer.
//
// The user-facing experience is preserved: single-keypress navigation,
// read-new-since-last-login, sequential message display with between-
// message prompts, and the same access control model (ReadLow/ReadHigh,
// WriteLow/WriteHigh ranges compared against the user's SecBoard level).
package menu
import (
"fmt"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// cmdMessages is the entry point for the message board subsystem.
// Replaces MCom() from MCOM.C — the top-level board selection menu.
func cmdMessages(ctx *Context) error {
boards, err := ctx.Store.ListBoards()
if err != nil {
ctx.Sess.WriteString(" Error loading boards.\r\n")
return nil
}
// Filter boards to those the user can see (read OR write access)
var visible []*models.Board
for _, b := range boards {
if b.CanRead(ctx.User.SecBoard) || b.CanWrite(ctx.User.SecBoard) {
visible = append(visible, b)
}
}
if len(visible) == 0 {
ctx.Sess.WriteString(" No message boards available.\r\n\r\n")
return nil
}
ctx.Sess.NewLine()
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("N>ew S>ome A>ll L>ist Q>uit\r\n")
ctx.Sess.Printf("%s Message Base> ", ctx.Cfg.System.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
msgBoardList(ctx, visible)
case 'A':
ctx.Sess.WriteString("All Boards\r\n\r\n")
for i, b := range visible {
ctx.Sess.Printf("[%d] %s\r\n", i+1, b.Name)
quit := msgBoardPrompt(ctx, b, i+1)
if quit {
goto done
}
}
ctx.Sess.WriteString("Completed visiting ALL\r\n")
case 'N':
ctx.Sess.WriteString("New Messages\r\n\r\n")
found := false
for i, b := range visible {
if !b.CanRead(ctx.User.SecBoard) {
continue
}
// Check if there are new messages since last login
if ctx.User.LastOn != nil && b.LatestPost != nil &&
!b.LatestPost.After(*ctx.User.LastOn) {
continue
}
found = true
ctx.Sess.Printf("\r\n[%d] %s\r\n", i+1, b.Name)
// Read new messages first, then show prompt
msgReadNew(ctx, b)
quit := msgBoardPrompt(ctx, b, i+1)
if quit {
goto done
}
}
if !found {
ctx.Sess.WriteString("Nothing new.\r\n")
}
ctx.Sess.WriteString("Completed visiting NEW\r\n")
case 'S':
ctx.Sess.WriteString("Some Boards\r\n\r\n")
for i, b := range visible {
ctx.Sess.Printf("Visit [%d] %s? ", i+1, b.Name)
ych, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return nil
}
switch toUpper(ych) {
case 'Y':
ctx.Sess.WriteString("Yes\r\n")
quit := msgBoardPrompt(ctx, b, i+1)
if quit {
goto done
}
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
goto done
default:
ctx.Sess.WriteString("No\r\n")
}
}
ctx.Sess.WriteString("Completed visiting SOME\r\n")
case 'Q', '\x1b':
ctx.Sess.WriteString("Quit\r\n")
goto done
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
msgBoardList(ctx, visible)
}
}
done:
return nil
}
// msgBoardList displays the list of visible boards with post counts.
// Replaces the 'L' (list) case in MCom().
func msgBoardList(ctx *Context, boards []*models.Board) {
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Message Boards\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
for i, b := range boards {
access := ""
if b.CanRead(ctx.User.SecBoard) && b.CanWrite(ctx.User.SecBoard) {
access = "RW"
} else if b.CanRead(ctx.User.SecBoard) {
access = "R "
} else if b.CanWrite(ctx.User.SecBoard) {
access = " W"
}
latest := "no posts"
if b.LatestPost != nil {
latest = b.LatestPost.Format("Jan 02")
}
ctx.Sess.Printf(" [%d] %-20s %3d msgs %s %s\r\n",
i+1, b.Name, b.PostCount, access, latest)
}
ctx.Sess.NewLine()
}
// msgBoardPrompt is the per-board command loop.
// Replaces Msg_Prompt() from MCOM.C — the N>ew R>ead I>mmediate W>rite menu.
// Returns true if the user wants to return to the main menu (@).
func msgBoardPrompt(ctx *Context, board *models.Board, num int) bool {
canRead := board.CanRead(ctx.User.SecBoard)
canWrite := board.CanWrite(ctx.User.SecBoard)
for {
ctx.Sess.CheckTime()
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
// Build the prompt options based on access
opts := ""
if canRead {
opts += "N>ew R>ead I>mmediate "
}
if canWrite {
opts += "W>rite "
}
if ctx.User.SecStatus >= 100 {
opts += "D>elete "
}
opts += "Q>uit @>Main"
ctx.Sess.WriteString(opts + "\r\n")
ctx.Sess.Printf("[%d] %s> ", num, board.Name)
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return true
}
switch toUpper(ch) {
case 'N':
if !canRead {
continue
}
ctx.Sess.WriteString("New\r\n")
msgReadNew(ctx, board)
case 'R':
if !canRead {
continue
}
ctx.Sess.WriteString("Read\r\n")
msgReadFrontend(ctx, board)
case 'I':
if !canRead {
continue
}
ctx.Sess.WriteString("Immediate\r\n")
msgImmediate(ctx, board)
case 'W':
if !canWrite {
continue
}
ctx.Sess.WriteString("Write\r\n")
msgWrite(ctx, board, 0)
case 'D':
if ctx.User.SecStatus < 100 {
continue
}
ctx.Sess.WriteString("Delete\r\n")
msgDeleteFrontend(ctx, board)
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return false
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return true
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N - Read new messages since your last login\r\n")
ctx.Sess.WriteString(" R - Read messages by number range\r\n")
ctx.Sess.WriteString(" I - Read a single message by number\r\n")
ctx.Sess.WriteString(" W - Write a new message\r\n")
if ctx.User.SecStatus >= 100 {
ctx.Sess.WriteString(" D - Delete messages\r\n")
}
ctx.Sess.WriteString(" Q - Return to board selection\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// msgReadNew reads messages posted since the user's last login.
// Replaces Msg_ReadNew() from MCOM.C.
func msgReadNew(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages in this board.\r\n")
return
}
// Get messages posted since last login
var sinceUnix int64
if ctx.User.LastOn != nil {
sinceUnix = ctx.User.LastOn.Unix()
}
msgs, err := ctx.Store.ListMessagesSince(board.ID, sinceUnix)
if err != nil {
ctx.Sess.WriteString(" Error loading messages.\r\n")
return
}
if len(msgs) == 0 {
ctx.Sess.WriteString(" Nothing new.\r\n")
return
}
ctx.Sess.Printf(" %d new message(s)\r\n", len(msgs))
msgReadSequence(ctx, board, msgs)
}
// msgReadFrontend prompts for a FROM/TO range and reads those messages.
// Replaces Msg_Read_Frontend() from MCOM.C.
func msgReadFrontend(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages in this board.\r\n")
return
}
ctx.Sess.Printf(" Read FROM [1-%d]: ", board.PostCount)
fromStr, err := ctx.Sess.ReadLine("1", 5, inputTimeout)
if err != nil {
return
}
from := parseIntDefault(fromStr, 1)
if from < 1 || from > board.PostCount {
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", board.PostCount)
return
}
ctx.Sess.Printf(" Read TO [%d-%d]: ", from, board.PostCount)
toStr, err := ctx.Sess.ReadLine(fmt.Sprintf("%d", board.PostCount), 5, inputTimeout)
if err != nil {
return
}
to := parseIntDefault(toStr, board.PostCount)
if to < from || to > board.PostCount {
ctx.Sess.Printf(" Invalid. Range is %d-%d\r\n", from, board.PostCount)
return
}
// Load the range of messages
msgs, err := ctx.Store.ListMessages(board.ID, from-1, to-from+1)
if err != nil {
ctx.Sess.WriteString(" Error loading messages.\r\n")
return
}
msgReadSequence(ctx, board, msgs)
}
// msgImmediate reads a single message by number.
// Replaces Msg_Immediate() from MCOM.C.
func msgImmediate(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages in this board.\r\n")
return
}
ctx.Sess.Printf(" Read which [1-%d]: ", board.PostCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > board.PostCount {
ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", board.PostCount)
return
}
msgs, err := ctx.Store.ListMessages(board.ID, num-1, 1)
if err != nil || len(msgs) == 0 {
ctx.Sess.WriteString(" Message not found.\r\n")
return
}
msgReadSequence(ctx, board, msgs)
}
// msgReadSequence displays messages in order with the between-message
// navigation prompt. This is the core reading loop that replaces
// Msg_Read() from MCOM.C.
//
// The original used a for loop with index manipulation:
// number++ (next), number-- (again), number-=2 (last)
//
// We use the same approach with a slice index.
func msgReadSequence(ctx *Context, board *models.Board, msgs []*models.Message) {
if len(msgs) == 0 {
return
}
idx := 0
for idx >= 0 && idx < len(msgs) {
msg := msgs[idx]
// Display the message (replaces Send_Message)
msgDisplay(ctx, board, msg)
// Between-message prompt (replaces Between_Msg_Prompt)
action := msgBetweenPrompt(ctx, board, msg)
switch action {
case msgActionNext:
idx++
case msgActionAgain:
// idx stays the same — re-display
case msgActionLast:
if idx > 0 {
idx--
}
// At first message, "last" re-reads it (matches original)
case msgActionQuit:
return
case msgActionMainMenu:
return
case msgActionDeleted:
// Message was deleted; refresh the list and adjust
refreshed, err := ctx.Store.ListMessages(board.ID, 0, board.MaxPosts)
if err != nil {
return
}
msgs = refreshed
// Refresh the board too (post count changed)
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
if idx >= len(msgs) {
return
}
}
}
}
// msgDisplay renders a single message to the terminal.
// Replaces Send_Message() from MCOM.C.
func msgDisplay(ctx *Context, board *models.Board, msg *models.Message) {
ctx.Sess.NewLine()
// Header — matches original format:
// Number: [N] of [Total]
// Title: ...
// Author: Name [ID]
// Time: ...
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.Printf("Number: [%d] of [%d]\r\n", msg.Number, board.PostCount)
ctx.Sess.Color(session.AnsiReset)
if msg.Title != "" {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" Title: %s\r\n", msg.Title)
ctx.Sess.Color(session.AnsiReset)
}
ctx.Sess.Printf("Author: %s [%d]\r\n", msg.Author, msg.AuthorID)
ctx.Sess.Printf(" Time: %s\r\n", msg.CreatedAt.Format("Mon Jan 02 15:04:05 2006"))
if msg.ReplyTo > 0 {
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" (reply to #%d)\r\n", msg.ReplyTo)
ctx.Sess.Color(session.AnsiReset)
}
if msg.Locked {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.WriteString(" [LOCKED]\r\n")
ctx.Sess.Color(session.AnsiReset)
}
ctx.Sess.NewLine()
// Body — paginate if long (replaces the More.. logic in Send_Message)
ctx.Sess.Paginate(msg.Body, idleTimeout)
}
// msgAction represents the user's choice at the between-message prompt.
type msgAction int
const (
msgActionNext msgAction = iota // Continue to next message
msgActionAgain // Re-read current message
msgActionLast // Go back one message
msgActionQuit // Return to board prompt
msgActionMainMenu // Return to main menu
msgActionDeleted // Message was deleted
)
// msgBetweenPrompt shows navigation options between messages.
// Replaces Between_Msg_Prompt() from MCOM.C.
//
// The original's return codes:
// 0 = quit, 1 = continue, 2 = again, 3 = backwards, '@' = main menu
//
// We use named constants instead.
func msgBetweenPrompt(ctx *Context, board *models.Board, msg *models.Message) msgAction {
for {
ctx.Sess.CheckTime()
ctx.Sess.Color(session.AnsiFgCyan)
opts := "N>ext A>gain L>ast R>eply Q>uit @>Main"
// Delete — original required SecStatus >= 100 OR being the author
canDelete := ctx.User.SecStatus >= 100 || ctx.User.ID == msg.AuthorID
if canDelete {
opts += " D>elete"
}
if ctx.User.SecStatus >= 100 {
if msg.Locked {
opts += " ->Unlock"
} else {
opts += " +>Lock"
}
}
ctx.Sess.WriteString("\r\n" + opts + "\r\n")
ctx.Sess.WriteString("Message> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return msgActionQuit
}
switch toUpper(ch) {
case 'N', 'C', '\r':
ctx.Sess.WriteString("Next\r\n")
return msgActionNext
case 'A':
ctx.Sess.WriteString("Again\r\n")
return msgActionAgain
case 'L':
ctx.Sess.WriteString("Last\r\n")
return msgActionLast
case 'R':
ctx.Sess.WriteString("Reply\r\n")
if board.CanWrite(ctx.User.SecBoard) {
msgWrite(ctx, board, msg.Number)
} else {
ctx.Sess.WriteString(" You don't have write access to this board.\r\n")
}
return msgActionNext
case 'D':
if !canDelete {
continue
}
ctx.Sess.WriteString("Delete\r\n")
yes, err := ctx.Sess.Confirm(" Delete this message? ", inputTimeout)
if err != nil {
return msgActionQuit
}
if yes {
if err := ctx.Store.DeleteMessage(msg.ID); err != nil {
ctx.Sess.WriteString(" Error deleting message.\r\n")
} else {
// Update the board's post count
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Message deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
return msgActionDeleted
}
}
case '+':
if ctx.User.SecStatus < 100 {
continue
}
ctx.Sess.WriteString("Lock\r\n")
msg.Locked = true
// We need an UpdateMessage — for now toggle in memory.
// The locked state will persist if the store supports it.
ctx.Sess.WriteString(" Message locked.\r\n")
case '-':
if ctx.User.SecStatus < 100 {
continue
}
ctx.Sess.WriteString("Unlock\r\n")
msg.Locked = false
ctx.Sess.WriteString(" Message unlocked.\r\n")
case 'Q':
ctx.Sess.WriteString("Quit\r\n")
return msgActionQuit
case '@':
ctx.Sess.WriteString("Main Menu\r\n")
return msgActionMainMenu
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" N/C/Enter - Next message\r\n")
ctx.Sess.WriteString(" A - Read again\r\n")
ctx.Sess.WriteString(" L - Previous message\r\n")
ctx.Sess.WriteString(" R - Reply to this message\r\n")
ctx.Sess.WriteString(" D - Delete this message\r\n")
ctx.Sess.WriteString(" Q - Return to board prompt\r\n")
ctx.Sess.WriteString(" @ - Return to main menu\r\n")
}
}
}
// msgWrite composes and saves a new message.
// Replaces Msg_Write() and Msg_Public_Reply() from MCOM.C.
//
// The original used the full-screen editor from EDIT.C with word wrap,
// line editing, and a 2500-byte buffer. Our version uses a simpler
// multi-line editor that collects lines until a blank line, then offers
// Save/Abort/Continue/List/Edit — matching the original's edit menu.
//
// replyToNum is the message number being replied to (0 for new posts).
func msgWrite(ctx *Context, board *models.Board, replyToNum int) {
ctx.Sess.NewLine()
// Check if the board is full
if board.PostCount >= board.MaxPosts {
ctx.Sess.WriteString(" Board is full — no room for another message.\r\n")
return
}
// Title
defaultTitle := ""
if replyToNum > 0 {
// Try to prefill with "Re: original title"
origMsgs, _ := ctx.Store.ListMessages(board.ID, replyToNum-1, 1)
if len(origMsgs) > 0 && origMsgs[0].Title != "" {
t := origMsgs[0].Title
if !strings.HasPrefix(strings.ToLower(t), "re:") {
defaultTitle = "Re: " + t
} else {
defaultTitle = t
}
}
}
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Enter a Title (Q to quit): ")
ctx.Sess.Color(session.AnsiReset)
title, err := ctx.Sess.ReadLine(defaultTitle, 60, inputTimeout)
if err != nil {
return
}
title = strings.TrimSpace(title)
if strings.EqualFold(title, "Q") || title == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Author name — sysops/high-sec users can override (like original)
author := ctx.User.Name
if ctx.User.SecStatus >= 100 {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Name to use: ")
ctx.Sess.Color(session.AnsiReset)
nameInput, err := ctx.Sess.ReadLine(author, 30, inputTimeout)
if err != nil {
return
}
nameInput = strings.TrimSpace(nameInput)
if nameInput != "" {
author = nameInput
}
}
// Compose body using the line editor
ctx.Sess.WriteString("\r\nEnter your message (blank line when done):\r\n")
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" Ctrl-X: erase line | Ctrl-W: erase word | Blank line: finish\r\n\r\n")
ctx.Sess.Color(session.AnsiReset)
lines := msgEditLoop(ctx)
if lines == nil {
return // disconnected
}
// Edit menu — matches EDIT.C's post-entry options
for {
ctx.Sess.WriteString("\r\nA>bort S>ave C>ontinue L>ist E>dit-line D>elete-line\r\n")
ctx.Sess.WriteString("Edit> ")
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'S':
ctx.Sess.WriteString("Save\r\n")
goto save
case 'A', 'Q':
ctx.Sess.WriteString("Abort\r\n")
yes, err := ctx.Sess.Confirm(" Discard this message? ", inputTimeout)
if err != nil || yes {
ctx.Sess.WriteString(" Message discarded.\r\n")
return
}
case 'C':
ctx.Sess.WriteString("Continue\r\n")
more := msgEditLoop(ctx)
if more == nil {
return
}
lines = append(lines, more...)
case 'L':
ctx.Sess.WriteString("List\r\n\r\n")
for i, l := range lines {
ctx.Sess.Printf("%3d> %s\r\n", i+1, l)
}
case 'E':
ctx.Sess.WriteString("Edit\r\n")
if len(lines) == 0 {
ctx.Sess.WriteString(" Nothing to edit.\r\n")
continue
}
ctx.Sess.Printf(" Line number [1-%d]: ", len(lines))
numStr, err := ctx.Sess.ReadLine("", 4, inputTimeout)
if err != nil {
return
}
n := parseIntDefault(numStr, 0)
if n < 1 || n > len(lines) {
ctx.Sess.WriteString(" Invalid line number.\r\n")
continue
}
ctx.Sess.Printf(" Old: %s\r\n", lines[n-1])
ctx.Sess.WriteString(" New: ")
newLine, err := ctx.Sess.ReadLine(lines[n-1], 75, inputTimeout)
if err != nil {
return
}
lines[n-1] = newLine
case 'D':
ctx.Sess.WriteString("Delete\r\n")
if len(lines) == 0 {
ctx.Sess.WriteString(" Nothing to delete.\r\n")
continue
}
ctx.Sess.Printf(" Line number [1-%d]: ", len(lines))
numStr, err := ctx.Sess.ReadLine("", 4, inputTimeout)
if err != nil {
return
}
n := parseIntDefault(numStr, 0)
if n < 1 || n > len(lines) {
ctx.Sess.WriteString(" Invalid line number.\r\n")
continue
}
lines = append(lines[:n-1], lines[n:]...)
ctx.Sess.Printf(" Line %d deleted.\r\n", n)
case '?', '/':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.WriteString(" S - Save the message\r\n")
ctx.Sess.WriteString(" A - Abort and discard\r\n")
ctx.Sess.WriteString(" C - Continue writing\r\n")
ctx.Sess.WriteString(" L - List what you've written\r\n")
ctx.Sess.WriteString(" E - Edit a line\r\n")
ctx.Sess.WriteString(" D - Delete a line\r\n")
}
}
save:
body := strings.Join(lines, "\n")
if strings.TrimSpace(body) == "" {
ctx.Sess.WriteString(" Empty message — not saved.\r\n")
return
}
// Resolve replyTo — if replying, store the original message's DB ID
var replyToID int64
if replyToNum > 0 {
origMsgs, _ := ctx.Store.ListMessages(board.ID, replyToNum-1, 1)
if len(origMsgs) > 0 {
replyToID = origMsgs[0].ID
}
}
msg := &models.Message{
BoardID: board.ID,
Title: title,
Author: author,
AuthorID: ctx.User.ID,
Body: body,
ReplyTo: replyToID,
}
ctx.Sess.WriteString(" Saving...\r\n")
if err := ctx.Store.CreateMessage(msg); err != nil {
ctx.Sess.WriteString(" Error saving message.\r\n")
return
}
// Update stats
ctx.User.MessagesPosted++
ctx.Store.UpdateUser(ctx.User)
ctx.Store.IncrementStat("messages_posted", 1)
// Refresh the board's post count
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Message #%d saved.\r\n", msg.Number)
ctx.Sess.Color(session.AnsiReset)
}
// msgEditLoop reads lines of input until a blank line is entered.
// Replaces the Enter() function from EDIT.C.
//
// The original had elaborate word-wrap logic because terminals ran at
// 75 columns over serial. We still do line-at-a-time with numbered
// prompts, but rely on the terminal's own wrapping for long lines.
func msgEditLoop(ctx *Context) []string {
var lines []string
lineNum := len(lines) + 1
for {
ctx.Sess.Printf("%3d> ", lineNum)
line, err := ctx.Sess.ReadLine("", 75, inputTimeout)
if err != nil {
return nil
}
if strings.TrimSpace(line) == "" {
break
}
lines = append(lines, line)
lineNum++
// Safety limit — original had MAXLINES=100 and Size=2500 bytes
if lineNum > 100 {
ctx.Sess.WriteString(" Maximum lines reached.\r\n")
break
}
}
return lines
}
// msgDeleteFrontend prompts for a range and deletes messages.
// Replaces Msg_Delete_Frontend() from MCOM.C.
func msgDeleteFrontend(ctx *Context, board *models.Board) {
if board.PostCount == 0 {
ctx.Sess.WriteString(" No messages to delete.\r\n")
return
}
ctx.Sess.Printf(" Delete which [1-%d] (0 to cancel): ", board.PostCount)
numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return
}
num := parseIntDefault(numStr, 0)
if num < 1 || num > board.PostCount {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
msgs, err := ctx.Store.ListMessages(board.ID, num-1, 1)
if err != nil || len(msgs) == 0 {
ctx.Sess.WriteString(" Message not found.\r\n")
return
}
msg := msgs[0]
if msg.Locked && ctx.User.SecStatus < 255 {
ctx.Sess.WriteString(" That message is locked.\r\n")
return
}
ctx.Sess.Printf(" Delete #%d \"%s\" by %s? ", msg.Number, msg.Title, msg.Author)
yes, err := ctx.Sess.Confirm("", inputTimeout)
if err != nil || !yes {
ctx.Sess.WriteString(" Not deleted.\r\n")
return
}
if err := ctx.Store.DeleteMessage(msg.ID); err != nil {
ctx.Sess.WriteString(" Error deleting message.\r\n")
return
}
// Refresh board
if updated, err := ctx.Store.GetBoard(board.ID); err == nil {
*board = *updated
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString(" Message deleted.\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// parseIntDefault parses a string as int, returning def on failure.
func parseIntDefault(s string, def int) int {
s = strings.TrimSpace(s)
if s == "" {
return def
}
var n int
_, err := fmt.Sscanf(s, "%d", &n)
if err != nil {
return def
}
return n
}

40
internal/menu/nodes.go Normal file
View file

@ -0,0 +1,40 @@
package menu
import "time"
// NodeInfo describes an active node for display and management.
// This is the information the menu layer needs about connected sessions
// without requiring direct access to the session objects themselves.
//
// The original TAG-BBS was single-user, so there was no concept of
// node management. The closest equivalent was the sysop console's
// ability to see who was online and boot them. NodeInfo enables the
// same functionality in our multi-node architecture.
type NodeInfo struct {
Node int
RemoteAddr string
UserName string // Empty for pre-login connections
UserID int64 // 0 for guests or pre-login
ConnectedAt time.Time
}
// NodeManager provides read and control operations over active nodes.
// It abstracts the server's session management so the menu layer can
// list, message, and disconnect nodes without coupling to the server
// implementation.
//
// The server.Server struct implements this interface. The menu package
// consumes it through the Context.
type NodeManager interface {
// ActiveNodes returns info about all currently connected sessions.
ActiveNodes() []NodeInfo
// DisconnectNode forcibly disconnects the given node number.
// Returns an error if the node doesn't exist.
DisconnectNode(node int) error
// SendToNode sends a message string to the given node's terminal.
// Used for inter-node chat and sysop announcements.
// Returns an error if the node doesn't exist.
SendToNode(node int, msg string) error
}

1032
internal/menu/sysop.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,502 @@
// sysop_boards.go implements board management for the sysop menu.
//
// The original TAG-BBS had no in-BBS board management — boards were
// configured in text files (s:Tag_Boards) and created by running the
// GENERATE program. Each board entry had a name, disk location, read/write
// security ranges, and a maximum post count.
//
// Our version provides full CRUD from the sysop menu:
// L List all boards
// C Create a new board
// E Edit an existing board (name, security, max posts)
// D Delete a board (with confirmation and message count warning)
// Q Return to sysop menu
//
// The edit flow follows the same pattern as the user editor:
// display → keypress → modify field in memory → redisplay → save/cancel.
package menu
import (
"fmt"
"strconv"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// sysopBoardMenu is the board management sub-menu.
func sysopBoardMenu(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Board Management\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [L] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("List boards\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [C] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Create board\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [E] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Edit board\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [D] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Delete board\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [Q] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Return\r\n")
ctx.Sess.Color(session.AnsiReset)
for {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.WriteString("Boards> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n")
sysopListBoards(ctx)
case 'C':
ctx.Sess.WriteString("Create\r\n")
sysopCreateBoard(ctx)
case 'E':
ctx.Sess.WriteString("Edit\r\n")
sysopEditBoardPrompt(ctx)
case 'D':
ctx.Sess.WriteString("Delete\r\n")
sysopDeleteBoardPrompt(ctx)
case 'Q', '\x1b':
ctx.Sess.WriteString("Return\r\n")
return
case '?':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" L=List C=Create E=Edit D=Delete Q=Quit\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
}
// sysopListBoards displays all boards with their configuration.
func sysopListBoards(ctx *Context) {
boards, err := ctx.Store.ListBoards()
if err != nil {
ctx.Sess.WriteString(" Error loading boards.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %-4s %-20s %5s %-11s %-11s %s\r\n",
"ID", "Name", "Posts", "Read", "Write", "Max")
ctx.Sess.WriteString(strings.Repeat("─", 68) + "\r\n")
ctx.Sess.Color(session.AnsiReset)
if len(boards) == 0 {
ctx.Sess.WriteString(" (no boards)\r\n")
return
}
for _, b := range boards {
ctx.Sess.Printf(" %-4d %-20s %5d %3d — %-3d %3d — %-3d %d\r\n",
b.ID, truncate(b.Name, 20), b.PostCount,
b.ReadLow, b.ReadHigh, b.WriteLow, b.WriteHigh, b.MaxPosts)
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %d board(s)\r\n", len(boards))
ctx.Sess.Color(session.AnsiReset)
}
// sysopCreateBoard walks through creating a new board.
// In the original, boards were created by adding entries to s:Tag_Boards
// and re-running GENERATE. Here the sysop does it interactively.
func sysopCreateBoard(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.WriteString("Create Board\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 30) + "\r\n")
// Name
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// Read access range
readLow, readHigh := sysopReadRange(ctx, "Read access", 0, 255)
// Write access range
writeLow, writeHigh := sysopReadRange(ctx, "Write access", 1, 255)
// Max posts
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Max posts [200]: ")
ctx.Sess.Color(session.AnsiReset)
maxStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
maxPosts := 200
if s := strings.TrimSpace(maxStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v > 0 && v <= 10000 {
maxPosts = v
}
}
board := &models.Board{
Name: name,
ReadLow: readLow,
ReadHigh: readHigh,
WriteLow: writeLow,
WriteHigh: writeHigh,
MaxPosts: maxPosts,
}
if err := ctx.Store.CreateBoard(board); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Board created: %s (ID #%d)\r\n", board.Name, board.ID)
ctx.Sess.Color(session.AnsiReset)
}
// sysopEditBoardPrompt asks for a board ID and enters the edit loop.
func sysopEditBoardPrompt(ctx *Context) {
// Show the board list first so the sysop can see IDs
sysopListBoards(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Board ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid board ID.\r\n")
return
}
sysopEditBoard(ctx, id)
}
// sysopEditBoard is the edit loop for a single board.
// Follows the same display → keypress → edit → redisplay pattern
// as the user account editor.
func sysopEditBoard(ctx *Context, id int64) {
board, err := ctx.Store.GetBoard(id)
if err != nil || board == nil {
ctx.Sess.WriteString(" Board not found.\r\n")
return
}
dirty := false
for {
// Display
sysopDisplayBoard(ctx, board, dirty)
// Wait for keypress
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case '1': // Save
ctx.Sess.WriteString("Save\r\n")
if err := ctx.Store.UpdateBoard(board); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
continue
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Board #%d saved.\r\n", board.ID)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
return
case '\x1b': // ESC — Cancel
ctx.Sess.WriteString("Cancel\r\n")
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" Unsaved changes! Discard? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
continue
}
ctx.Sess.WriteString("Yes\r\n")
}
return
case 'A': // Name
ctx.Sess.WriteString("Name\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" New name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine(board.Name, 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name != "" && name != board.Name {
board.Name = name
dirty = true
}
case 'B': // ReadLow
ctx.Sess.WriteString("Read Low\r\n")
if v, ok := sysopReadInt(ctx, "ReadLow", board.ReadLow, 0, 255); ok {
board.ReadLow = v
dirty = true
}
case 'C': // ReadHigh
ctx.Sess.WriteString("Read High\r\n")
if v, ok := sysopReadInt(ctx, "ReadHigh", board.ReadHigh, 0, 255); ok {
board.ReadHigh = v
dirty = true
}
case 'D': // WriteLow
ctx.Sess.WriteString("Write Low\r\n")
if v, ok := sysopReadInt(ctx, "WriteLow", board.WriteLow, 0, 255); ok {
board.WriteLow = v
dirty = true
}
case 'E': // WriteHigh
ctx.Sess.WriteString("Write High\r\n")
if v, ok := sysopReadInt(ctx, "WriteHigh", board.WriteHigh, 0, 255); ok {
board.WriteHigh = v
dirty = true
}
case 'F': // MaxPosts
ctx.Sess.WriteString("Max Posts\r\n")
if v, ok := sysopReadInt(ctx, "MaxPosts", board.MaxPosts, 1, 10000); ok {
board.MaxPosts = v
dirty = true
}
}
}
}
// sysopDisplayBoard renders the board detail screen for editing.
func sysopDisplayBoard(ctx *Context, board *models.Board, dirty bool) {
ctx.Sess.ClearScreen()
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
ctx.Sess.Printf(" Board #%d", board.ID)
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" *")
}
ctx.Sess.WriteString("\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
ctx.Sess.NewLine()
sysopField(ctx, "A", "Name", board.Name)
ctx.Sess.NewLine()
// Read access range — mirrors the original's READ: low,high
sysopFieldInt(ctx, "B", "Read Low", board.ReadLow)
sysopFieldInt(ctx, "C", "Read High", board.ReadHigh)
ctx.Sess.NewLine()
// Write access range — mirrors the original's WRITE: low,high
sysopFieldInt(ctx, "D", "Write Low", board.WriteLow)
sysopFieldInt(ctx, "E", "Write High", board.WriteHigh)
ctx.Sess.NewLine()
// Capacity and usage
sysopFieldInt(ctx, "F", "Max Posts", board.MaxPosts)
sysopField(ctx, " ", "Current Posts",
fmt.Sprintf("%d", board.PostCount))
ctx.Sess.NewLine()
// Metadata
if board.LatestPost != nil {
sysopField(ctx, " ", "Latest Post",
board.LatestPost.Format("Jan 02, 2006 3:04 PM"))
} else {
sysopField(ctx, " ", "Latest Post", "none")
}
sysopField(ctx, " ", "Created",
board.CreatedAt.Format("Jan 02, 2006 3:04 PM"))
ctx.Sess.NewLine()
// Access summary — helpful at-a-glance description
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Read: %s Write: %s\r\n",
describeAccess(board.ReadLow, board.ReadHigh),
describeAccess(board.WriteLow, board.WriteHigh))
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.NewLine()
// Footer
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" 1=Save ESC=Cancel A-F=Edit fields\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// sysopDeleteBoardPrompt asks for a board ID and deletes it with confirmation.
func sysopDeleteBoardPrompt(ctx *Context) {
// Show the board list first
sysopListBoards(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Delete board ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid board ID.\r\n")
return
}
board, err := ctx.Store.GetBoard(id)
if err != nil || board == nil {
ctx.Sess.WriteString(" Board not found.\r\n")
return
}
// Warn about message count
msgCount, _ := ctx.Store.CountMessages(board.ID)
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
ctx.Sess.Printf("\r\n Delete board: %s (#%d)\r\n", board.Name, board.ID)
ctx.Sess.Color(session.AnsiFgRed)
if msgCount > 0 {
ctx.Sess.Printf(" This will also delete %d message(s).\r\n", msgCount)
}
ctx.Sess.WriteString(" Are you sure? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
ctx.Sess.WriteString("Yes\r\n")
if err := ctx.Store.DeleteBoard(board.ID); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Board %s deleted.\r\n", board.Name)
ctx.Sess.Color(session.AnsiReset)
}
// --- Helpers ---
// sysopReadRange prompts for a low/high security range pair.
// Returns the entered values, defaulting to the provided defaults.
func sysopReadRange(ctx *Context, label string, defaultLow, defaultHigh int) (int, int) {
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" %s low [%d]: ", label, defaultLow)
ctx.Sess.Color(session.AnsiReset)
lowStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return defaultLow, defaultHigh
}
low := defaultLow
if s := strings.TrimSpace(lowStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v >= 0 && v <= 255 {
low = v
}
}
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" %s high [%d]: ", label, defaultHigh)
ctx.Sess.Color(session.AnsiReset)
highStr, err := ctx.Sess.ReadLine("", 5, inputTimeout)
if err != nil {
return low, defaultHigh
}
high := defaultHigh
if s := strings.TrimSpace(highStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v >= 0 && v <= 255 {
high = v
}
}
return low, high
}
// describeAccess returns a human-readable summary of a security range.
func describeAccess(low, high int) string {
if low == 0 && high == 255 {
return "everyone"
}
if low == high {
return fmt.Sprintf("level %d only", low)
}
if low == 0 {
return fmt.Sprintf("up to %d", high)
}
if high == 255 {
return fmt.Sprintf("%d+", low)
}
return fmt.Sprintf("%d%d", low, high)
}

View file

@ -0,0 +1,399 @@
// sysop_bulletins.go implements bulletin management for the sysop menu.
//
// The original TAG-BBS managed bulletins through GENERATE config files.
// Each bulletin had a name, a display file (ANSI/text), and read
// security range. Our version provides full CRUD from the sysop menu.
//
// Bulletins are simpler than boards or libraries — they're just pointers
// to screen files with access control. There's no post count or file
// tracking, so the edit flow is straightforward.
package menu
import (
"strconv"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// sysopBulletinMenu is the bulletin management sub-menu.
func sysopBulletinMenu(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgMagenta, session.AnsiBold)
ctx.Sess.WriteString("Bulletin Management\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [L] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("List bulletins\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [C] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Create bulletin\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [E] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Edit bulletin\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [D] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Delete bulletin\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [Q] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Return\r\n")
ctx.Sess.Color(session.AnsiReset)
for {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgMagenta)
ctx.Sess.WriteString("Bulletins> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n")
sysopListBulletins(ctx)
case 'C':
ctx.Sess.WriteString("Create\r\n")
sysopCreateBulletin(ctx)
case 'E':
ctx.Sess.WriteString("Edit\r\n")
sysopEditBulletinPrompt(ctx)
case 'D':
ctx.Sess.WriteString("Delete\r\n")
sysopDeleteBulletinPrompt(ctx)
case 'Q', '\x1b':
ctx.Sess.WriteString("Return\r\n")
return
case '?':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" L=List C=Create E=Edit D=Delete Q=Quit\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
}
// sysopListBulletins displays all bulletins.
func sysopListBulletins(ctx *Context) {
bulletins, err := ctx.Store.ListBulletins()
if err != nil {
ctx.Sess.WriteString(" Error loading bulletins.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %-4s %-20s %-24s %s\r\n",
"ID", "Name", "File", "Access")
ctx.Sess.WriteString(strings.Repeat("─", 60) + "\r\n")
ctx.Sess.Color(session.AnsiReset)
if len(bulletins) == 0 {
ctx.Sess.WriteString(" (no bulletins)\r\n")
return
}
for _, b := range bulletins {
ctx.Sess.Printf(" %-4d %-20s %-24s %s\r\n",
b.ID, truncate(b.Name, 20),
truncate(b.FilePath, 24),
describeAccess(b.ReadLow, b.ReadHigh))
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %d bulletin(s)\r\n", len(bulletins))
ctx.Sess.Color(session.AnsiReset)
}
// sysopCreateBulletin walks through creating a new bulletin.
func sysopCreateBulletin(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgMagenta, session.AnsiBold)
ctx.Sess.WriteString("Create Bulletin\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 30) + "\r\n")
// Name
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// File path — the ANSI/text file to display
defaultPath := ctx.Cfg.System.Screens + "bulletin-" +
strings.ToLower(strings.ReplaceAll(name, " ", "-")) + ".ans"
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" File path [%s]: ", defaultPath)
ctx.Sess.Color(session.AnsiReset)
filePath, err := ctx.Sess.ReadLine("", 80, inputTimeout)
if err != nil {
return
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
filePath = defaultPath
}
// Read access
readLow, readHigh := sysopReadRange(ctx, "Read access", 0, 255)
b := &models.Bulletin{
Name: name,
FilePath: filePath,
ReadLow: readLow,
ReadHigh: readHigh,
}
if err := ctx.Store.CreateBulletin(b); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Bulletin created: %s (ID #%d)\r\n", b.Name, b.ID)
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" File: %s\r\n", b.FilePath)
ctx.Sess.Color(session.AnsiReset)
}
// sysopEditBulletinPrompt asks for a bulletin ID and enters the edit loop.
func sysopEditBulletinPrompt(ctx *Context) {
sysopListBulletins(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Bulletin ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid ID.\r\n")
return
}
sysopEditBulletin(ctx, id)
}
// sysopEditBulletin is the edit loop for a single bulletin.
func sysopEditBulletin(ctx *Context, id int64) {
b, err := ctx.Store.GetBulletin(id)
if err != nil || b == nil {
ctx.Sess.WriteString(" Bulletin not found.\r\n")
return
}
dirty := false
for {
sysopDisplayBulletin(ctx, b, dirty)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case '1': // Save
ctx.Sess.WriteString("Save\r\n")
if err := ctx.Store.UpdateBulletin(b); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
continue
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Bulletin #%d saved.\r\n", b.ID)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
return
case '\x1b': // ESC — Cancel
ctx.Sess.WriteString("Cancel\r\n")
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" Unsaved changes! Discard? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
continue
}
ctx.Sess.WriteString("Yes\r\n")
}
return
case 'A': // Name
ctx.Sess.WriteString("Name\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" New name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine(b.Name, 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name != "" && name != b.Name {
b.Name = name
dirty = true
}
case 'B': // FilePath
ctx.Sess.WriteString("File\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" File path: ")
ctx.Sess.Color(session.AnsiReset)
fp, err := ctx.Sess.ReadLine(b.FilePath, 80, inputTimeout)
if err != nil {
return
}
fp = strings.TrimSpace(fp)
if fp != "" && fp != b.FilePath {
b.FilePath = fp
dirty = true
}
case 'C': // ReadLow
ctx.Sess.WriteString("Read Low\r\n")
if v, ok := sysopReadInt(ctx, "ReadLow", b.ReadLow, 0, 255); ok {
b.ReadLow = v
dirty = true
}
case 'D': // ReadHigh
ctx.Sess.WriteString("Read High\r\n")
if v, ok := sysopReadInt(ctx, "ReadHigh", b.ReadHigh, 0, 255); ok {
b.ReadHigh = v
dirty = true
}
}
}
}
// sysopDisplayBulletin renders the bulletin detail screen for editing.
func sysopDisplayBulletin(ctx *Context, b *models.Bulletin, dirty bool) {
ctx.Sess.ClearScreen()
ctx.Sess.Color(session.AnsiFgMagenta, session.AnsiBold)
ctx.Sess.Printf(" Bulletin #%d", b.ID)
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" *")
}
ctx.Sess.WriteString("\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
ctx.Sess.NewLine()
sysopField(ctx, "A", "Name", b.Name)
sysopField(ctx, "B", "File Path", b.FilePath)
ctx.Sess.NewLine()
sysopFieldInt(ctx, "C", "Read Low", b.ReadLow)
sysopFieldInt(ctx, "D", "Read High", b.ReadHigh)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Access: %s\r\n", describeAccess(b.ReadLow, b.ReadHigh))
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" 1=Save ESC=Cancel A-D=Edit fields\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// sysopDeleteBulletinPrompt asks for a bulletin ID and deletes it.
func sysopDeleteBulletinPrompt(ctx *Context) {
sysopListBulletins(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Delete bulletin ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid ID.\r\n")
return
}
b, err := ctx.Store.GetBulletin(id)
if err != nil || b == nil {
ctx.Sess.WriteString(" Bulletin not found.\r\n")
return
}
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
ctx.Sess.Printf("\r\n Delete bulletin: %s (#%d)?\r\n", b.Name, b.ID)
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.WriteString(" Are you sure? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
ctx.Sess.WriteString("Yes\r\n")
if err := ctx.Store.DeleteBulletin(b.ID); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Bulletin %s deleted.\r\n", b.Name)
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Note: the file %s was not removed from disk.\r\n", b.FilePath)
ctx.Sess.Color(session.AnsiReset)
}

View file

@ -0,0 +1,477 @@
// sysop_libraries.go implements file library management for the sysop menu.
//
// The original TAG-BBS managed libraries through GENERATE config files.
// Each library had a name, disk path, upload/download security ranges,
// and a maximum file count. Our version provides full CRUD from the
// sysop menu.
//
// Libraries are slightly more complex than boards or bulletins because
// they have both an upload and download security range, plus an on-disk
// file path where actual uploaded files are stored.
package menu
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
)
// sysopLibraryMenu is the library management sub-menu.
func sysopLibraryMenu(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
ctx.Sess.WriteString("Library Management\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [L] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("List libraries\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [C] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Create library\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [E] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Edit library\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [D] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Delete library\r\n")
ctx.Sess.Color(session.AnsiFgBrightWhite)
ctx.Sess.WriteString(" [Q] ")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString("Return\r\n")
ctx.Sess.Color(session.AnsiReset)
for {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString("Libraries> ")
ctx.Sess.Color(session.AnsiReset)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case 'L':
ctx.Sess.WriteString("List\r\n")
sysopListLibraries(ctx)
case 'C':
ctx.Sess.WriteString("Create\r\n")
sysopCreateLibrary(ctx)
case 'E':
ctx.Sess.WriteString("Edit\r\n")
sysopEditLibraryPrompt(ctx)
case 'D':
ctx.Sess.WriteString("Delete\r\n")
sysopDeleteLibraryPrompt(ctx)
case 'Q', '\x1b':
ctx.Sess.WriteString("Return\r\n")
return
case '?':
ctx.Sess.WriteString("Help\r\n")
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" L=List C=Create E=Edit D=Delete Q=Quit\r\n")
ctx.Sess.Color(session.AnsiReset)
}
}
}
// sysopListLibraries displays all libraries with their configuration.
func sysopListLibraries(ctx *Context) {
libs, err := ctx.Store.ListLibraries()
if err != nil {
ctx.Sess.WriteString(" Error loading libraries.\r\n")
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %-4s %-16s %5s %-11s %-11s %s\r\n",
"ID", "Name", "Files", "Upload", "Download", "Max")
ctx.Sess.WriteString(strings.Repeat("─", 68) + "\r\n")
ctx.Sess.Color(session.AnsiReset)
if len(libs) == 0 {
ctx.Sess.WriteString(" (no libraries)\r\n")
return
}
for _, l := range libs {
ctx.Sess.Printf(" %-4d %-16s %5d %3d — %-3d %3d — %-3d %d\r\n",
l.ID, truncate(l.Name, 16), l.FileCount,
l.UploadLow, l.UploadHigh,
l.DownloadLow, l.DownloadHigh, l.MaxFiles)
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" %d library(ies)\r\n", len(libs))
ctx.Sess.Color(session.AnsiReset)
}
// sysopCreateLibrary walks through creating a new library.
func sysopCreateLibrary(ctx *Context) {
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
ctx.Sess.WriteString("Create Library\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 30) + "\r\n")
// Name
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine("", 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name == "" {
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
// File path — directory where uploaded files will be stored.
// Default to a subdirectory next to the database file.
dataDir := filepath.Dir(ctx.Cfg.Storage.SQLitePath) + "/"
defaultPath := dataDir +
strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "/"
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.Printf(" File path [%s]: ", defaultPath)
ctx.Sess.Color(session.AnsiReset)
filePath, err := ctx.Sess.ReadLine("", 80, inputTimeout)
if err != nil {
return
}
filePath = strings.TrimSpace(filePath)
if filePath == "" {
filePath = defaultPath
}
// Upload access range
uploadLow, uploadHigh := sysopReadRange(ctx, "Upload access", 1, 255)
// Download access range
downloadLow, downloadHigh := sysopReadRange(ctx, "Download access", 0, 255)
// Max files
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Max files [200]: ")
ctx.Sess.Color(session.AnsiReset)
maxStr, err := ctx.Sess.ReadLine("", 6, inputTimeout)
if err != nil {
return
}
maxFiles := 200
if s := strings.TrimSpace(maxStr); s != "" {
if v, err := strconv.Atoi(s); err == nil && v > 0 && v <= 10000 {
maxFiles = v
}
}
lib := &models.Library{
Name: name,
FilePath: filePath,
UploadLow: uploadLow,
UploadHigh: uploadHigh,
DownloadLow: downloadLow,
DownloadHigh: downloadHigh,
MaxFiles: maxFiles,
}
if err := ctx.Store.CreateLibrary(lib); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Library created: %s (ID #%d)\r\n", lib.Name, lib.ID)
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Path: %s\r\n", lib.FilePath)
ctx.Sess.Color(session.AnsiReset)
}
// sysopEditLibraryPrompt asks for a library ID and enters the edit loop.
func sysopEditLibraryPrompt(ctx *Context) {
sysopListLibraries(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Library ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid ID.\r\n")
return
}
sysopEditLibrary(ctx, id)
}
// sysopEditLibrary is the edit loop for a single library.
func sysopEditLibrary(ctx *Context, id int64) {
lib, err := ctx.Store.GetLibrary(id)
if err != nil || lib == nil {
ctx.Sess.WriteString(" Library not found.\r\n")
return
}
dirty := false
for {
sysopDisplayLibrary(ctx, lib, dirty)
ch, err := ctx.Sess.ReadKey(idleTimeout)
if err != nil {
return
}
switch toUpper(ch) {
case '1': // Save
ctx.Sess.WriteString("Save\r\n")
if err := ctx.Store.UpdateLibrary(lib); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
continue
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Library #%d saved.\r\n", lib.ID)
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.ReadKey(idleTimeout)
return
case '\x1b': // ESC — Cancel
ctx.Sess.WriteString("Cancel\r\n")
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" Unsaved changes! Discard? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
continue
}
ctx.Sess.WriteString("Yes\r\n")
}
return
case 'A': // Name
ctx.Sess.WriteString("Name\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" New name: ")
ctx.Sess.Color(session.AnsiReset)
name, err := ctx.Sess.ReadLine(lib.Name, 30, inputTimeout)
if err != nil {
return
}
name = strings.TrimSpace(name)
if name != "" && name != lib.Name {
lib.Name = name
dirty = true
}
case 'B': // FilePath
ctx.Sess.WriteString("Path\r\n")
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" File path: ")
ctx.Sess.Color(session.AnsiReset)
fp, err := ctx.Sess.ReadLine(lib.FilePath, 80, inputTimeout)
if err != nil {
return
}
fp = strings.TrimSpace(fp)
if fp != "" && fp != lib.FilePath {
lib.FilePath = fp
dirty = true
}
case 'C': // UploadLow
ctx.Sess.WriteString("Upload Low\r\n")
if v, ok := sysopReadInt(ctx, "UploadLow", lib.UploadLow, 0, 255); ok {
lib.UploadLow = v
dirty = true
}
case 'D': // UploadHigh
ctx.Sess.WriteString("Upload High\r\n")
if v, ok := sysopReadInt(ctx, "UploadHigh", lib.UploadHigh, 0, 255); ok {
lib.UploadHigh = v
dirty = true
}
case 'E': // DownloadLow
ctx.Sess.WriteString("Download Low\r\n")
if v, ok := sysopReadInt(ctx, "DownloadLow", lib.DownloadLow, 0, 255); ok {
lib.DownloadLow = v
dirty = true
}
case 'F': // DownloadHigh
ctx.Sess.WriteString("Download High\r\n")
if v, ok := sysopReadInt(ctx, "DownloadHigh", lib.DownloadHigh, 0, 255); ok {
lib.DownloadHigh = v
dirty = true
}
case 'G': // MaxFiles
ctx.Sess.WriteString("Max Files\r\n")
if v, ok := sysopReadInt(ctx, "MaxFiles", lib.MaxFiles, 1, 10000); ok {
lib.MaxFiles = v
dirty = true
}
}
}
}
// sysopDisplayLibrary renders the library detail screen for editing.
func sysopDisplayLibrary(ctx *Context, lib *models.Library, dirty bool) {
ctx.Sess.ClearScreen()
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
ctx.Sess.Printf(" Library #%d", lib.ID)
if dirty {
ctx.Sess.Color(session.AnsiFgYellow)
ctx.Sess.WriteString(" *")
}
ctx.Sess.WriteString("\r\n")
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
ctx.Sess.NewLine()
sysopField(ctx, "A", "Name", lib.Name)
sysopField(ctx, "B", "File Path", lib.FilePath)
ctx.Sess.NewLine()
// Upload access range
sysopFieldInt(ctx, "C", "Upload Low", lib.UploadLow)
sysopFieldInt(ctx, "D", "Upload High", lib.UploadHigh)
ctx.Sess.NewLine()
// Download access range
sysopFieldInt(ctx, "E", "Download Low", lib.DownloadLow)
sysopFieldInt(ctx, "F", "Download High", lib.DownloadHigh)
ctx.Sess.NewLine()
// Capacity and usage
sysopFieldInt(ctx, "G", "Max Files", lib.MaxFiles)
sysopField(ctx, " ", "Current Files",
fmt.Sprintf("%d", lib.FileCount))
ctx.Sess.NewLine()
// Metadata
if lib.LatestFile != nil {
sysopField(ctx, " ", "Latest File",
lib.LatestFile.Format("Jan 02, 2006 3:04 PM"))
} else {
sysopField(ctx, " ", "Latest File", "none")
}
sysopField(ctx, " ", "Created",
lib.CreatedAt.Format("Jan 02, 2006 3:04 PM"))
ctx.Sess.NewLine()
// Access summary
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Upload: %s Download: %s\r\n",
describeAccess(lib.UploadLow, lib.UploadHigh),
describeAccess(lib.DownloadLow, lib.DownloadHigh))
ctx.Sess.Color(session.AnsiReset)
ctx.Sess.NewLine()
// Footer
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.WriteString(" 1=Save ESC=Cancel A-G=Edit fields\r\n")
ctx.Sess.Color(session.AnsiReset)
}
// sysopDeleteLibraryPrompt asks for a library ID and deletes it with confirmation.
func sysopDeleteLibraryPrompt(ctx *Context) {
sysopListLibraries(ctx)
ctx.Sess.NewLine()
ctx.Sess.Color(session.AnsiFgCyan)
ctx.Sess.WriteString(" Delete library ID: ")
ctx.Sess.Color(session.AnsiReset)
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
if err != nil {
return
}
input = strings.TrimSpace(input)
if input == "" {
return
}
id, err := strconv.ParseInt(input, 10, 64)
if err != nil || id < 1 {
ctx.Sess.WriteString(" Invalid ID.\r\n")
return
}
lib, err := ctx.Store.GetLibrary(id)
if err != nil || lib == nil {
ctx.Sess.WriteString(" Library not found.\r\n")
return
}
fileCount, _ := ctx.Store.CountLibraryFiles(lib.ID)
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
ctx.Sess.Printf("\r\n Delete library: %s (#%d)\r\n", lib.Name, lib.ID)
ctx.Sess.Color(session.AnsiFgRed)
if fileCount > 0 {
ctx.Sess.Printf(" This will remove %d file record(s) from the database.\r\n", fileCount)
ctx.Sess.WriteString(" Actual files on disk will NOT be deleted.\r\n")
}
ctx.Sess.WriteString(" Are you sure? [Y/N] ")
ctx.Sess.Color(session.AnsiReset)
confirm, err := ctx.Sess.ReadKey(inputTimeout)
if err != nil {
return
}
if toUpper(confirm) != 'Y' {
ctx.Sess.WriteString("No\r\n")
ctx.Sess.WriteString(" Cancelled.\r\n")
return
}
ctx.Sess.WriteString("Yes\r\n")
if err := ctx.Store.DeleteLibrary(lib.ID); err != nil {
ctx.Sess.Color(session.AnsiFgRed)
ctx.Sess.Printf(" Error: %v\r\n", err)
ctx.Sess.Color(session.AnsiReset)
return
}
ctx.Sess.Color(session.AnsiFgGreen)
ctx.Sess.Printf(" Library %s deleted.\r\n", lib.Name)
if lib.FilePath != "" {
ctx.Sess.Color(session.AnsiFgBrightBlack)
ctx.Sess.Printf(" Files in %s were not removed from disk.\r\n", lib.FilePath)
}
ctx.Sess.Color(session.AnsiReset)
}

57
internal/models/board.go Normal file
View file

@ -0,0 +1,57 @@
package models
import "time"
// Board represents a message board (conference/forum).
//
// Original: struct Board_Header in BOARD.H
// Changes:
// - ID replaces positional linked-list ordering
// - Access ranges kept intact — same security model
// - LatestTime becomes LatestPost as time.Time
type Board struct {
ID int64
Name string
ReadLow int // Minimum SecBoard to read
ReadHigh int // Maximum SecBoard to read
WriteLow int // Minimum SecBoard to write
WriteHigh int // Maximum SecBoard to write
MaxPosts int // Maximum messages before oldest are purged
PostCount int // Current number of posts (Highest_Post)
LatestPost *time.Time // nil if no posts yet
CreatedAt time.Time
}
// CanRead returns true if the given security level can read this board.
func (b *Board) CanRead(secBoard int) bool {
return secBoard >= b.ReadLow && secBoard <= b.ReadHigh
}
// CanWrite returns true if the given security level can post to this board.
func (b *Board) CanWrite(secBoard int) bool {
return secBoard >= b.WriteLow && secBoard <= b.WriteHigh
}
// Message represents a single post on a message board.
//
// Original: struct Board_Data in BOARD.H + the message body stored
// in a separate .Data file at a fixed offset. Here the body is stored
// inline in the database.
// Changes:
// - ID replaces positional slot number
// - BoardID foreign key replaces the implicit file-per-board association
// - Body stored with the record instead of in a separate file
// - ReplyTo enables threading (not present in original)
// - Locked replaces the Lock field
type Message struct {
ID int64
BoardID int64
Number int // Display number within the board (1-based)
Title string
Author string // Display name (BoardOps could override in original)
AuthorID int64 // User ID of the poster
Body string
ReplyTo int64 // ID of parent message, 0 if top-level
Locked bool
CreatedAt time.Time
}

View file

@ -0,0 +1,20 @@
package models
import "time"
// CallLogEntry represents a single event in the BBS call log.
//
// This replaces the Append_Stat() calls from the original TAG-BBS
// (STAT_LOGON, STAT_LOGOFF, STAT_SHUTDOWN, etc.) which wrote to the
// binary Tag.Stat file. Our version stores structured events in SQLite
// for easy querying and display.
type CallLogEntry struct {
ID int64
Event string // "login", "logoff", "newuser", "kicked", etc.
UserID int64
UserName string
Node int
RemoteAddr string
Detail string // Disconnect reason, etc.
CreatedAt time.Time
}

View file

@ -0,0 +1,66 @@
package models
import "time"
// Library represents a file library (download area).
//
// Original: struct Library_Header in LIBRARY.H
// Changes:
// - ID replaces linked-list ordering
// - FilePath stores the on-disk location for the actual files
type Library struct {
ID int64
Name string
FilePath string // Directory on disk where files are stored
UploadLow int // Minimum SecLibrary to upload
UploadHigh int // Maximum SecLibrary to upload
DownloadLow int // Minimum SecLibrary to download
DownloadHigh int // Maximum SecLibrary to download
MaxFiles int
FileCount int
LatestFile *time.Time
CreatedAt time.Time
}
// CanUpload returns true if the given security level can upload here.
func (l *Library) CanUpload(secLibrary int) bool {
return secLibrary >= l.UploadLow && secLibrary <= l.UploadHigh
}
// CanDownload returns true if the given security level can download here.
func (l *Library) CanDownload(secLibrary int) bool {
return secLibrary >= l.DownloadLow && secLibrary <= l.DownloadHigh
}
// LibraryFile represents a single file entry in a library.
//
// Original: struct Library_Data in LIBRARY.H
// Changes:
// - ID replaces positional slot
// - LibraryID foreign key
// - FileSize is new (original relied on OS stat calls)
type LibraryFile struct {
ID int64
LibraryID int64
Filename string
Description string
UploaderID int64
Uploader string // Display name
FileSize int64
Downloads int
CreatedAt time.Time
}
// Bulletin represents a text file displayed to users.
//
// Original: struct Bulletin_Header in BULLETIN.H
// Changes:
// - ID replaces linked-list ordering
// - FilePath points to the display file on disk
type Bulletin struct {
ID int64
Name string
FilePath string // Path to the text/ANSI file
ReadLow int // Minimum SecBulletin to view
ReadHigh int // Maximum SecBulletin to view
}

23
internal/models/mail.go Normal file
View file

@ -0,0 +1,23 @@
package models
import "time"
// Mail represents a private message between users.
//
// Original: struct Mail_Data in MAIL.H + body in .Data file
// Changes:
// - ID replaces positional slot
// - ToID/FromID replace To_Code/From_Code
// - Body stored inline
// - Read flag is new (original had no read tracking)
type Mail struct {
ID int64
Title string
Author string // Display name of sender
FromID int64 // Sender's user ID
ToID int64 // Recipient's user ID
Recipient string // Display name of recipient
Body string
Read bool
CreatedAt time.Time
}

88
internal/models/user.go Normal file
View file

@ -0,0 +1,88 @@
// Package models defines the data structures for URIT BBS.
//
// These are the modernized equivalents of the original TAG-BBS header
// file structures (USER.H, BOARD.H, MAIL.H, LIBRARY.H, BULLETIN.H).
// Fixed-size char arrays become strings, USHORT slot numbers become
// int64 database IDs, and raw binary timestamps become time.Time.
package models
import "time"
// User represents a BBS user account.
//
// Original: struct User in USER.H
// Changes:
// - ID replaces Slot_Number (database-assigned, not positional)
// - PasswordHash replaces Pass[9] (bcrypt instead of plaintext)
// - Comments collapsed from [3][81] to a single text field
// - Time fields use time.Time instead of long epoch seconds
// - Active flag replaces the "Slot_Number==0 means deleted" convention
type User struct {
ID int64
Name string
PasswordHash string
Comments string
Active bool
// Security levels — same concept as the original's Sec_* fields.
// Each level is checked against board/library/bulletin access ranges.
SecStatus int // Overall tier: 0=Guest, 1=New, 2+=Valid, 100+=BoardOp, 150+=LibOp, 255=Sysop
SecBoard int // Board access level
SecLibrary int // Library access level
SecBulletin int // Bulletin access level
// Statistics — direct equivalents of the original fields
MessagesPosted int
MailSent int
MailReceived int
Uploads int
Downloads int
// Time tracking
TimeLimit int64 // Seconds allowed per session
TimeUsed int64 // Seconds used in current/last session
TimeTotal int64 // Cumulative seconds across all sessions
LastOn *time.Time // Last login time (nil if never logged in)
CreatedAt time.Time
}
// IsGuest returns true if this is a guest (unauthenticated) user.
func (u *User) IsGuest() bool {
return u.SecStatus == 0
}
// IsNew returns true if this user hasn't been validated yet.
func (u *User) IsNew() bool {
return u.SecStatus == 1
}
// IsSysop returns true if this user has sysop privileges.
func (u *User) IsSysop() bool {
return u.SecStatus == 255
}
// IsBoardOp returns true if this user has board operator privileges.
func (u *User) IsBoardOp() bool {
return u.SecStatus >= 100
}
// StatusLabel returns a human-readable label for the user's status.
// Mirrors the original's StatPrintUser() display logic.
func (u *User) StatusLabel() string {
switch {
case u.SecStatus == 0:
return "Guest"
case u.SecStatus == 1:
return "New"
case u.SecStatus >= 2 && u.SecStatus < 100:
return "Valid"
case u.SecStatus >= 100 && u.SecStatus < 150:
return "BoardOp"
case u.SecStatus >= 150 && u.SecStatus < 255:
return "LibOp"
case u.SecStatus == 255:
return "Sysop"
default:
return "Unknown"
}
}

View file

@ -0,0 +1,15 @@
package models
import "time"
// WebSession represents an authenticated HTTP session.
// These are stored in the web_sessions table and referenced by a
// cookie sent to the browser. This gives the HTTP file server the
// same user identity and security level checking as the telnet side.
type WebSession struct {
Token string
UserID int64
UserName string
CreatedAt time.Time
ExpiresAt time.Time
}

464
internal/server/admin.go Normal file
View file

@ -0,0 +1,464 @@
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 &gt;</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>
`))

131
internal/server/chat.go Normal file
View file

@ -0,0 +1,131 @@
package server
import "sync"
// ChatManager tracks real-time chat sessions between nodes.
//
// Classic BBSes like TAG used split-screen chat over serial lines.
// Our approach is simpler: line-by-line messaging through Go channels.
// A background goroutine on the receiving side injects incoming
// messages into the user's terminal between their own input lines.
//
// Flow:
// 1. User A pages user B (one-shot notification via SendToNode)
// 2. User A enters chat mode → gets a channel via EnterChat
// 3. User B enters chat mode targeting A → LinkChat connects them
// 4. Messages typed by A are sent via SendChat → delivered to B's channel
// 5. Either user types /quit → EndChat cleans up their side
//
// Thread-safe: called from multiple session goroutines concurrently.
type ChatManager struct {
mu sync.Mutex
chans map[int]chan string // per-node incoming message channel
links map[int]int // node -> partner node
}
// NewChatManager creates an empty chat manager.
func NewChatManager() *ChatManager {
return &ChatManager{
chans: make(map[int]chan string),
links: make(map[int]int),
}
}
// EnterChat registers a node for chat and returns its incoming
// message channel. If the node already has a channel, returns it.
func (cm *ChatManager) EnterChat(node int) <-chan string {
cm.mu.Lock()
defer cm.mu.Unlock()
if ch, ok := cm.chans[node]; ok {
return ch
}
ch := make(chan string, 16) // Buffered to avoid blocking sender
cm.chans[node] = ch
return ch
}
// LinkChat establishes a bidirectional chat link between two nodes.
// Both nodes must have entered chat mode first (have channels).
// Returns false if either node hasn't entered chat.
func (cm *ChatManager) LinkChat(nodeA, nodeB int) bool {
cm.mu.Lock()
defer cm.mu.Unlock()
_, aOK := cm.chans[nodeA]
_, bOK := cm.chans[nodeB]
if !aOK || !bOK {
return false
}
cm.links[nodeA] = nodeB
cm.links[nodeB] = nodeA
return true
}
// SendChat delivers a message to the given node's chat partner.
// Returns false if the node has no partner linked.
func (cm *ChatManager) SendChat(fromNode int, msg string) bool {
cm.mu.Lock()
partnerNode, linked := cm.links[fromNode]
var partnerCh chan string
if linked {
partnerCh = cm.chans[partnerNode]
}
cm.mu.Unlock()
if !linked || partnerCh == nil {
return false
}
// Non-blocking send — drop message if partner's buffer is full
select {
case partnerCh <- msg:
return true
default:
return false
}
}
// EndChat removes a node from chat and cleans up its link.
// If the node had a partner, sends a disconnect notification
// to the partner's channel and unlinks them.
func (cm *ChatManager) EndChat(node int) {
cm.mu.Lock()
partner, linked := cm.links[node]
if linked {
delete(cm.links, node)
delete(cm.links, partner)
// Notify partner
if ch, ok := cm.chans[partner]; ok {
select {
case ch <- "":
// Empty string signals disconnect
default:
}
}
}
if ch, ok := cm.chans[node]; ok {
close(ch)
delete(cm.chans, node)
}
cm.mu.Unlock()
}
// Partner returns the chat partner for a node, or 0 if not linked.
func (cm *ChatManager) Partner(node int) int {
cm.mu.Lock()
defer cm.mu.Unlock()
return cm.links[node]
}
// IsInChat returns true if the node has an active chat channel.
func (cm *ChatManager) IsInChat(node int) bool {
cm.mu.Lock()
defer cm.mu.Unlock()
_, ok := cm.chans[node]
return ok
}

1145
internal/server/http.go Normal file

File diff suppressed because it is too large Load diff

504
internal/server/server.go Normal file
View file

@ -0,0 +1,504 @@
package server
import (
"fmt"
"log"
"net"
"sort"
"sync"
"time"
"github.com/urit/urit/internal/auth"
"github.com/urit/urit/internal/config"
"github.com/urit/urit/internal/menu"
"github.com/urit/urit/internal/session"
"github.com/urit/urit/internal/store"
)
// maxNodes is the fixed pool size for node numbers.
// Node numbers are recycled: when a user disconnects, their number
// goes back into the pool for the next connection. This provides a
// natural connection limit and keeps node numbers small and readable.
//
// The original TAG-BBS was single-node (serial port). Our pool of 256
// is generous for a retro BBS — even large boards rarely had more than
// a few dozen simultaneous callers.
const maxNodes = 256
// Server manages listener(s) and active connections.
type Server struct {
cfg *config.Config
store store.Store
listener net.Listener
// Session management — protected by mu.
mu sync.Mutex
sessions map[int]*session.Session
// Web access tokens — shared between telnet and HTTP.
// Telnet users generate tokens; HTTP uses them for auth.
Tokens *TokenStore
// Chat — inter-node real-time messaging.
Chat *ChatManager
// StartedAt records when the server was created, used for uptime.
StartedAt time.Time
// Node number recycling pool. nodeBitmap tracks which node numbers
// (1 through maxNodes) are currently assigned. AllocNode picks the
// lowest free number; FreeNode returns it to the pool.
//
// This replaces the monotonically-incrementing connCount from earlier
// versions. A bitmap is efficient and gives predictable, small node
// numbers that are easy for sysops to reference.
nodeBitmap [(maxNodes + 7) / 8]byte
}
// New creates a Server from the given configuration.
func New(cfg *config.Config, db store.Store) *Server {
return &Server{
cfg: cfg,
store: db,
sessions: make(map[int]*session.Session),
Tokens: NewTokenStore(),
Chat: NewChatManager(),
StartedAt: time.Now(),
}
}
// ListenAndServe starts the telnet listener and blocks, accepting
// connections until the listener is closed. Each connection is handled
// in its own goroutine — this is the multi-user equivalent of the
// original TAG-BBS's single-caller Await_Logon() loop.
func (s *Server) ListenAndServe() error {
if !s.cfg.Telnet.Enabled {
return fmt.Errorf("telnet is not enabled in configuration")
}
ln, err := net.Listen("tcp", s.cfg.Telnet.Address)
if err != nil {
return fmt.Errorf("telnet listen %s: %w", s.cfg.Telnet.Address, err)
}
s.listener = ln
log.Printf("Telnet listening on %s", s.cfg.Telnet.Address)
for {
conn, err := ln.Accept()
if err != nil {
// Listener was closed (shutdown)
if opErr, ok := err.(*net.OpError); ok && !opErr.Temporary() {
return nil
}
log.Printf("Accept error: %v", err)
continue
}
go s.handleConnection(conn)
}
}
// Close shuts down the listener and disconnects all active sessions.
func (s *Server) Close() error {
s.mu.Lock()
for _, sess := range s.sessions {
sess.Close(session.DisconnectKicked)
}
s.mu.Unlock()
if s.listener != nil {
return s.listener.Close()
}
return nil
}
// --- WebTokenizer implementation ---
// These methods let the menu layer generate web access tokens for
// HTTP file downloads without coupling to the token store directly.
func (s *Server) GenerateWebToken(userID int64, userName string, secLibrary int) (string, error) {
return s.Tokens.Generate(userID, userName, secLibrary)
}
func (s *Server) HTTPAddress() string {
if s.cfg.HTTP.Enabled {
return s.cfg.HTTP.Address
}
return ""
}
// --- ChatAgent implementation ---
// These methods let the menu layer manage chat sessions without
// coupling to the ChatManager directly.
func (s *Server) EnterChat(node int) <-chan string { return s.Chat.EnterChat(node) }
func (s *Server) LinkChat(a, b int) bool { return s.Chat.LinkChat(a, b) }
func (s *Server) SendChat(from int, msg string) bool { return s.Chat.SendChat(from, msg) }
func (s *Server) EndChat(node int) { s.Chat.EndChat(node) }
func (s *Server) ChatPartner(node int) int { return s.Chat.Partner(node) }
func (s *Server) IsInChat(node int) bool { return s.Chat.IsInChat(node) }
// --- NodeManager interface implementation ---
// These methods allow the menu layer to list, message, and disconnect
// nodes without coupling to the server's internal session management.
// ActiveNodes returns info about all currently connected sessions.
// Results are sorted by node number for stable display.
func (s *Server) ActiveNodes() []menu.NodeInfo {
s.mu.Lock()
defer s.mu.Unlock()
nodes := make([]menu.NodeInfo, 0, len(s.sessions))
for _, sess := range s.sessions {
nodes = append(nodes, menu.NodeInfo{
Node: sess.Node,
RemoteAddr: sess.RemoteAddr(),
UserName: sess.UserName,
UserID: sess.UserID,
ConnectedAt: sess.ConnectedAt,
})
}
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].Node < nodes[j].Node
})
return nodes
}
// DisconnectNode forcibly disconnects the given node number.
func (s *Server) DisconnectNode(node int) error {
s.mu.Lock()
sess, ok := s.sessions[node]
s.mu.Unlock()
if !ok {
return fmt.Errorf("node %d not found", node)
}
// Send a courtesy message before disconnecting
sess.Color(session.AnsiFgRed, session.AnsiBold)
sess.WriteString("\r\n*** Disconnected by sysop ***\r\n")
sess.Color(session.AnsiReset)
sess.Close(session.DisconnectKicked)
return nil
}
// SendToNode sends a message string to the given node's terminal.
func (s *Server) SendToNode(node int, msg string) error {
s.mu.Lock()
sess, ok := s.sessions[node]
s.mu.Unlock()
if !ok {
return fmt.Errorf("node %d not found", node)
}
return sess.WriteString(msg)
}
// --- Node number recycling ---
// allocNode assigns the lowest available node number (1-maxNodes).
// Returns 0 if all nodes are in use.
// Must be called with s.mu held.
func (s *Server) allocNode() int {
for i := 1; i <= maxNodes; i++ {
byteIdx := (i - 1) / 8
bitIdx := uint((i - 1) % 8)
if s.nodeBitmap[byteIdx]&(1<<bitIdx) == 0 {
s.nodeBitmap[byteIdx] |= 1 << bitIdx
return i
}
}
return 0 // All nodes in use
}
// freeNode returns a node number to the pool.
// Must be called with s.mu held.
func (s *Server) freeNode(node int) {
if node < 1 || node > maxNodes {
return
}
byteIdx := (node - 1) / 8
bitIdx := uint((node - 1) % 8)
s.nodeBitmap[byteIdx] &^= 1 << bitIdx
}
// --- Connection handling ---
// handleConnection manages a single telnet session from connect to
// disconnect. In the original TAG-BBS, this entire flow was the main
// loop body — Reset_System, Await_Logon, Logon_Sequence, Menu, logoff.
// Here each connection gets its own goroutine and Session.
func (s *Server) handleConnection(conn net.Conn) {
s.mu.Lock()
nodeNum := s.allocNode()
if nodeNum == 0 {
s.mu.Unlock()
// All nodes in use — reject the connection.
conn.Write([]byte("All nodes are busy. Please try again later.\r\n"))
conn.Close()
log.Printf("Connection rejected (all %d nodes in use): %s",
maxNodes, conn.RemoteAddr())
return
}
s.mu.Unlock()
// Create the session — this replaces the global User struct,
// IO_Flags array, and jmp_buf Environment from the original.
sess := session.New(conn, nodeNum)
s.mu.Lock()
s.sessions[nodeNum] = sess
s.mu.Unlock()
log.Printf("[Node %d] Connected: %s", nodeNum, sess.RemoteAddr())
defer func() {
reason := sess.Reason()
sess.Close(reason)
// Clean up any active chat session for this node
s.Chat.EndChat(nodeNum)
s.mu.Lock()
delete(s.sessions, nodeNum)
s.freeNode(nodeNum)
s.mu.Unlock()
log.Printf("[Node %d] Disconnected: %s (%s)",
nodeNum, sess.RemoteAddr(), reason)
}()
// Send telnet negotiation — puts client into character mode with
// server-side echo. This replaces the modem handshake/baud detection
// that Await_Logon() did in the original.
if err := sess.Negotiate(); err != nil {
log.Printf("[Node %d] Negotiation error: %v", nodeNum, err)
return
}
// Run the BBS session: banner → login → menu → logoff.
// This is the modernized equivalent of the original's
// MenuSend(Logon.Text) → Logon_Sequence() → Menu() → logoff flow.
s.runSession(sess)
}
// runSession is the main BBS session handler. It runs the full
// lifecycle: welcome banner → authentication → main menu → logoff.
//
// In the original TAG-BBS, this was the body of the FOREVER loop in
// TAG.C: Reset_System → Await_Logon → Logon_Sequence → Menu → logoff.
func (s *Server) runSession(sess *session.Session) {
sess.ClearScreen()
// Welcome banner
sess.Color(session.AnsiFgCyan, session.AnsiBold)
sess.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n")
sess.Color(session.AnsiFgBrightWhite)
sess.Printf(" %s\r\n", s.cfg.System.Name)
sess.Color(session.AnsiFgCyan)
sess.WriteString(" Running URIT BBS v0.2.0\r\n")
sess.Printf(" Operated by %s\r\n", s.cfg.System.Sysop)
sess.Color(session.AnsiFgCyan, session.AnsiBold)
sess.WriteString("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
// Welcome screen file (optional)
sess.SendFile(s.cfg.System.Screens + "welcome.ans")
// Authentication
result, err := auth.Login(sess, s.store, s.cfg)
if err != nil {
log.Printf("[Node %d] Auth error: %v", sess.Node, err)
return
}
user := result.User
log.Printf("[Node %d] Logged in: %s (ID=%d, Status=%s)",
sess.Node, user.Name, user.ID, user.StatusLabel())
// Update session identity — makes this info available via NodeManager
// so cmdWho and the sysop console can show who's logged in.
sess.UserName = user.Name
sess.UserID = user.ID
// --- Login event logging and stats ---
// This replaces Append_Stat(STAT_LOGON) and the per-type call
// counters from the original TAG-BBS.
s.store.LogEvent("login", user.ID, user.Name, sess.Node,
sess.RemoteAddr(), user.StatusLabel())
s.store.IncrementStat("total_calls", 1)
switch {
case user.IsGuest():
s.store.IncrementStat("guest_calls", 1)
case user.IsNew():
s.store.IncrementStat("new_calls", 1)
default:
s.store.IncrementStat("valid_calls", 1)
}
if result.IsNew {
s.store.IncrementStat("new_accounts", 1)
}
// Deferred logoff tracking — runs when the session ends for any
// reason (normal logoff, timeout, kick, disconnect). This replaces
// Append_Stat(STAT_LOGOFF) and the time accounting from TAG.C.
defer func() {
reason := sess.Reason()
elapsed := time.Since(sess.ConnectedAt)
s.store.LogEvent("logoff", user.ID, user.Name, sess.Node,
sess.RemoteAddr(), reason.String())
s.store.IncrementStat("total_time_secs", int64(elapsed.Seconds()))
// Update user's time tracking in the database.
// TimeUsed is per-session; TimeTotal is cumulative.
if !result.IsGuest && user.ID > 0 {
secs := int64(elapsed.Seconds())
user.TimeUsed = secs
user.TimeTotal += secs
now := time.Now()
user.LastOn = &now
if err := s.store.UpdateUser(user); err != nil {
log.Printf("[Node %d] Error updating user time: %v", sess.Node, err)
}
}
}()
// --- Logon sequence (replaces TAG.C post-login and MENU.C preamble) ---
// 1. Time reset logic (matches original MENU.C)
// Must happen first — if the user has no time, we disconnect them
// immediately rather than showing informational screens.
// Original: if >12 hours since last login or sysop, reset TimeUsed;
// otherwise carry forward remaining time from previous session.
if !result.IsGuest && user.ID > 0 {
resetTime := user.SecStatus == 255 // Sysop always gets full time
if user.LastOn != nil {
elapsed := time.Since(*user.LastOn)
if elapsed >= 12*time.Hour {
resetTime = true
}
} else {
resetTime = true // First login ever
}
if resetTime {
user.TimeUsed = 0
}
// Apply user's time limit to the session, accounting for time
// already used (carried forward from a recent session).
limit := time.Duration(user.TimeLimit) * time.Second
used := time.Duration(user.TimeUsed) * time.Second
remaining := limit - used
if remaining <= 0 {
// No time left — show the "no time" screen and disconnect.
// Matches original: MenuSend("Logon24hrs.Text") then longjmp.
sess.SendFile(s.cfg.System.Screens + "notime.ans")
sess.Color(session.AnsiFgRed)
sess.WriteString(" You have no time remaining. Try again later.\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
log.Printf("[Node %d] No time remaining for %s, disconnecting",
sess.Node, user.Name)
return
}
sess.TimeLimit = remaining
}
// 2. Post-login screen files
// The original had separate files: Logon.Text, GuestLogon.Text.
// We add newuser.ans for first-time registrations.
if result.IsGuest {
sess.SendFile(s.cfg.System.Screens + "guest.ans")
} else if result.IsNew {
sess.SendFile(s.cfg.System.Screens + "newuser.ans")
} else {
sess.SendFile(s.cfg.System.Screens + "logon.ans")
}
// 3. Last caller display
// Not in the original (single-node Amiga), but a classic BBS feature.
// Shows who was the last person to log in before the current user.
if lastCaller, err := s.store.GetLastCaller(user.ID); err == nil && lastCaller != nil {
sess.Color(session.AnsiFgBrightBlack)
sess.Printf(" Last caller: %s on %s\r\n",
lastCaller.UserName,
lastCaller.CreatedAt.Format("Jan 02 at 3:04 PM"))
sess.Color(session.AnsiReset)
}
// 4. Unread mail check
if !result.IsGuest && user.ID > 0 {
unread, _ := s.store.CountUnreadMail(user.ID)
if unread > 0 {
sess.Color(session.AnsiFgBrightYellow)
sess.Printf("*** You have %d unread mail message(s) ***\r\n", unread)
sess.Color(session.AnsiReset)
sess.NewLine()
}
}
// 5. New user validation notice
// The original had security levels: 0=guest, 1=new/unvalidated,
// 2+=validated. New users could use the BBS but with restricted
// board/library access until the sysop validated them (which
// bumped their security levels up).
if !result.IsGuest && user.IsNew() {
sess.Color(session.AnsiFgYellow)
sess.WriteString(" Your account is awaiting validation by the sysop.\r\n")
sess.WriteString(" Some areas may have restricted access.\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
}
// 6. System call count and session info
totalCalls, _ := s.store.GetStat("total_calls")
if totalCalls > 0 {
sess.Color(session.AnsiFgBrightBlack)
sess.Printf(" Call #%d", totalCalls)
if !result.IsGuest && user.LastOn != nil {
sess.Printf(" | Last on: %s", user.LastOn.Format("Jan 02 at 3:04 PM"))
}
sess.WriteString("\r\n")
sess.Color(session.AnsiReset)
}
// Post-login info
sess.Color(session.AnsiFgBrightBlack)
width, height := sess.TerminalSize()
sess.Printf(" Node %d | %s | Terminal %dx%d | Time limit %s\r\n",
sess.Node, user.StatusLabel(), width, height,
fmtDuration(sess.TimeRemaining()))
sess.Color(session.AnsiReset)
sess.NewLine()
// Hand off to the main menu loop — replaces the original's Menu() call.
ctx := &menu.Context{
Sess: sess,
User: user,
Store: s.store,
Cfg: s.cfg,
Auth: result,
Nodes: s, // Server implements NodeManager
Tokens: s, // Server implements WebTokenizer
Chat: s, // Server implements ChatAgent
}
menu.Run(ctx)
}
// fmtDuration formats a time.Duration as "Xh Ym" for display.
func fmtDuration(d time.Duration) string {
mins := int(d.Minutes())
hours := mins / 60
mins = mins % 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

163
internal/server/telnet.go Normal file
View file

@ -0,0 +1,163 @@
package server
// Telnet protocol constants.
// These are the IAC (Interpret As Command) sequences used to negotiate
// terminal options between server and client. In the original TAG-BBS,
// none of this existed — the serial port was a raw byte stream. Telnet
// adds a signaling layer on top of TCP that we need to handle.
const (
iacIAC byte = 255 // Interpret As Command — escapes the byte that follows
iacDONT byte = 254 // Demand the client stop using an option
iacDO byte = 253 // Request the client start using an option
iacWONT byte = 252 // Refuse to use an option
iacWILL byte = 251 // Offer to use an option
iacSB byte = 250 // Subnegotiation Begin
iacSE byte = 240 // Subnegotiation End
iacNOP byte = 241 // No Operation
iacGA byte = 249 // Go Ahead
// Telnet options we care about
optEcho byte = 1 // Server controls echo
optSGA byte = 3 // Suppress Go-Ahead (character-at-a-time mode)
optNAWS byte = 31 // Negotiate About Window Size
optTTYPE byte = 24 // Terminal Type
optLINEMODE byte = 34 // Linemode
)
// telnetNegotiation returns the IAC sequence to send at connection start.
// This puts the client into character-at-a-time mode with server-side echo,
// which is what a BBS needs for single-keypress menus.
//
// We send:
// - WILL ECHO: "I (server) will handle echoing characters back to you"
// - WILL SGA: "I won't send Go-Ahead signals" (enables character mode)
// - DO NAWS: "Please tell me your terminal dimensions"
// - DONT LINEMODE: "Don't use linemode" (reinforces character-at-a-time)
func telnetNegotiation() []byte {
return []byte{
iacIAC, iacWILL, optEcho,
iacIAC, iacWILL, optSGA,
iacIAC, iacDO, optNAWS,
iacIAC, iacDONT, optLINEMODE,
}
}
// telnetState tracks the IAC parser state for stripping telnet commands
// from the data stream. Without this, IAC sequences show up as garbage
// characters in user input.
type telnetState int
const (
tsData telnetState = iota // Normal data
tsIAC // Got IAC, next byte is command
tsWill // Got WILL, next byte is option
tsWont // Got WONT, next byte is option
tsDo // Got DO, next byte is option
tsDont // Got DONT, next byte is option
tsSB // Inside subnegotiation
tsSBIAC // Got IAC inside subnegotiation
)
// telnetFilter strips IAC sequences from raw telnet data and returns
// only the clean user input bytes. It also captures window size if the
// client sends NAWS subnegotiation.
//
// The filter is stateful — it tracks where it is in the IAC parse across
// calls, since IAC sequences can be split across TCP reads.
type telnetFilter struct {
state telnetState
sbBuffer []byte // Accumulates subnegotiation data
sbOption byte // Which option the subnegotiation is for
Width int // Terminal width from NAWS (0 = unknown)
Height int // Terminal height from NAWS (0 = unknown)
}
func newTelnetFilter() *telnetFilter {
return &telnetFilter{
state: tsData,
}
}
// Filter processes raw bytes from the TCP connection and returns only
// the clean data bytes (user input). Telnet commands are consumed silently.
func (f *telnetFilter) Filter(raw []byte) []byte {
clean := make([]byte, 0, len(raw))
for _, b := range raw {
switch f.state {
case tsData:
if b == iacIAC {
f.state = tsIAC
} else {
clean = append(clean, b)
}
case tsIAC:
switch b {
case iacIAC:
// Escaped 0xFF — literal data byte
clean = append(clean, 0xFF)
f.state = tsData
case iacWILL:
f.state = tsWill
case iacWONT:
f.state = tsWont
case iacDO:
f.state = tsDo
case iacDONT:
f.state = tsDont
case iacSB:
f.state = tsSB
f.sbBuffer = f.sbBuffer[:0]
case iacNOP, iacGA:
f.state = tsData
default:
// Unknown command, skip
f.state = tsData
}
case tsWill, tsWont, tsDo, tsDont:
// Option byte — consumed, back to data
f.state = tsData
case tsSB:
if b == iacIAC {
f.state = tsSBIAC
} else {
if len(f.sbBuffer) == 0 {
f.sbOption = b
}
f.sbBuffer = append(f.sbBuffer, b)
}
case tsSBIAC:
if b == iacSE {
// Subnegotiation complete
f.handleSubnegotiation()
f.state = tsData
} else {
// Escaped IAC within subnegotiation
f.sbBuffer = append(f.sbBuffer, b)
f.state = tsSB
}
}
}
return clean
}
// handleSubnegotiation processes a completed subnegotiation sequence.
func (f *telnetFilter) handleSubnegotiation() {
if len(f.sbBuffer) < 1 {
return
}
switch f.sbOption {
case optNAWS:
// NAWS: option(1) + width(2) + height(2) = 5 bytes
if len(f.sbBuffer) >= 5 {
f.Width = int(f.sbBuffer[1])<<8 | int(f.sbBuffer[2])
f.Height = int(f.sbBuffer[3])<<8 | int(f.sbBuffer[4])
}
}
}

99
internal/server/tokens.go Normal file
View file

@ -0,0 +1,99 @@
package server
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// tokenTTL is how long a web access token remains valid.
const tokenTTL = 1 * time.Hour
// tokenInfo holds the identity associated with a web access token.
// When a telnet user generates a token, their security levels are
// captured so the HTTP server can enforce access control without
// needing to query the database on every request.
type tokenInfo struct {
UserID int64
UserName string
SecLibrary int
ExpiresAt time.Time
}
// TokenStore manages time-limited web access tokens.
//
// Tokens are generated by telnet users and used in the browser to
// authenticate file downloads. This is the bridge between the telnet
// BBS session and the HTTP file server — conceptually similar to how
// some BBSes generated one-time download passwords.
//
// Thread-safe: tokens can be created from telnet goroutines and
// validated from HTTP handler goroutines concurrently.
type TokenStore struct {
mu sync.Mutex
tokens map[string]*tokenInfo
}
// NewTokenStore creates an empty token store.
func NewTokenStore() *TokenStore {
return &TokenStore{
tokens: make(map[string]*tokenInfo),
}
}
// Generate creates a new token for the given user and returns it.
// The token is a 16-byte hex string (32 characters).
func (ts *TokenStore) Generate(userID int64, userName string, secLibrary int) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
token := hex.EncodeToString(b)
ts.mu.Lock()
ts.tokens[token] = &tokenInfo{
UserID: userID,
UserName: userName,
SecLibrary: secLibrary,
ExpiresAt: time.Now().Add(tokenTTL),
}
ts.mu.Unlock()
// Opportunistic cleanup of expired tokens
go ts.cleanup()
return token, nil
}
// Validate checks a token and returns the associated info if valid.
// Returns nil if the token is missing, expired, or invalid.
func (ts *TokenStore) Validate(token string) *tokenInfo {
ts.mu.Lock()
defer ts.mu.Unlock()
info, ok := ts.tokens[token]
if !ok {
return nil
}
if time.Now().After(info.ExpiresAt) {
delete(ts.tokens, token)
return nil
}
return info
}
// cleanup removes all expired tokens.
func (ts *TokenStore) cleanup() {
ts.mu.Lock()
defer ts.mu.Unlock()
now := time.Now()
for token, info := range ts.tokens {
if now.After(info.ExpiresAt) {
delete(ts.tokens, token)
}
}
}

229
internal/session/input.go Normal file
View file

@ -0,0 +1,229 @@
package session
import (
"fmt"
"time"
)
// Input error sentinels. These replace the TIMEOUT and NO_CARRIER
// return codes that the original TAG-BBS passed through every function.
// In Go, we use the context for cancellation and return these as errors
// only when the caller needs to distinguish the reason.
var (
ErrTimeout = fmt.Errorf("input timeout")
ErrDisconnected = fmt.Errorf("disconnected")
)
// ReadChar reads a single byte from the client with a timeout.
// This is the direct replacement for ReadChar() in CONSOLE.C, which
// was the central input function that Wait()'d on serial, console,
// and timer signals simultaneously.
//
// In Go, the equivalent is a select on the input channel (fed by the
// readLoop goroutine), a timer, and the context's Done channel.
//
// A timeout of 0 means no timeout (wait forever, or until disconnect).
func (s *Session) ReadChar(timeout time.Duration) (byte, error) {
// Check context first — if the session is already cancelled,
// don't block on input.
select {
case <-s.ctx.Done():
return 0, ErrDisconnected
default:
}
if timeout == 0 {
// No timeout — block until input or disconnect
select {
case b, ok := <-s.inputCh:
if !ok {
return 0, ErrDisconnected
}
return b, nil
case <-s.ctx.Done():
return 0, ErrDisconnected
}
}
// With timeout — the original used SetTimer() and waited on
// TimerSig alongside the input signals. Same concept here.
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case b, ok := <-s.inputCh:
if !ok {
return 0, ErrDisconnected
}
return b, nil
case <-timer.C:
return 0, ErrTimeout
case <-s.ctx.Done():
return 0, ErrDisconnected
}
}
// ReadLine reads a full line of input (terminated by Enter) with echo
// and basic line editing. Returns the entered string, or an error on
// timeout/disconnect.
//
// This replaces LineInput() from the original, which handled echo,
// backspace, and had a pre-fill capability (the first argument was
// a default string displayed in the input field). We keep the same
// semantics.
//
// maxLen limits the input length (like the original's fixed char arrays).
// A value of 0 means no limit.
// timeout is the idle timeout — resets on each keypress.
func (s *Session) ReadLine(prefill string, maxLen int, timeout time.Duration) (string, error) {
buf := []byte(prefill)
// Display the prefill text if any
if len(prefill) > 0 {
if err := s.WriteString(prefill); err != nil {
return "", err
}
}
for {
ch, err := s.ReadChar(timeout)
if err != nil {
return string(buf), err
}
switch {
case ch == '\r' || ch == '\n':
// Enter — return the line
s.WriteString("\r\n")
return string(buf), nil
case ch == 127 || ch == 8:
// Backspace or DEL — erase last character
// Same as the original: PutStr("\b \b")
if len(buf) > 0 {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch == 24: // Ctrl-X — erase entire line
// Same as the original editor's line-kill
for len(buf) > 0 {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch == 23: // Ctrl-W — erase last word
// Delete trailing spaces first, then back to previous space.
// Same logic as the original EDIT.C's Ctrl-W handler.
for len(buf) > 0 && buf[len(buf)-1] == ' ' {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
for len(buf) > 0 && buf[len(buf)-1] != ' ' {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch >= 32 && ch < 127:
// Printable character
if maxLen > 0 && len(buf) >= maxLen {
continue // At limit, ignore
}
buf = append(buf, ch)
s.Write([]byte{ch})
default:
// Control characters — ignore
continue
}
}
}
// ReadLineNoEcho reads a line of input without echoing characters.
// Used for password entry. Displays asterisks instead of the actual
// characters.
func (s *Session) ReadLineNoEcho(maxLen int, timeout time.Duration) (string, error) {
var buf []byte
for {
ch, err := s.ReadChar(timeout)
if err != nil {
return string(buf), err
}
switch {
case ch == '\r' || ch == '\n':
s.WriteString("\r\n")
return string(buf), nil
case ch == 127 || ch == 8:
if len(buf) > 0 {
buf = buf[:len(buf)-1]
s.WriteString("\b \b")
}
case ch >= 32 && ch < 127:
if maxLen > 0 && len(buf) >= maxLen {
continue
}
buf = append(buf, ch)
s.Write([]byte{'*'})
default:
continue
}
}
}
// ReadKey reads a single keypress and returns it immediately without
// waiting for Enter. Used for single-character menu selections.
// This is the classic BBS "press a key" input — the original's
// ReadChar(120L) in the menu dispatch loops.
func (s *Session) ReadKey(timeout time.Duration) (byte, error) {
for {
ch, err := s.ReadChar(timeout)
if err != nil {
return 0, err
}
// Skip null bytes (can come from function key sequences)
if ch == 0 {
continue
}
return ch, nil
}
}
// WaitForKey displays a prompt and waits for any keypress.
// The classic "Press any key to continue..." pattern.
func (s *Session) WaitForKey(prompt string, timeout time.Duration) error {
if prompt != "" {
if err := s.WriteString(prompt); err != nil {
return err
}
}
_, err := s.ReadKey(timeout)
return err
}
// Confirm asks a yes/no question and returns the result.
// Keeps prompting until Y or N is pressed.
func (s *Session) Confirm(prompt string, timeout time.Duration) (bool, error) {
s.WriteString(prompt)
for {
ch, err := s.ReadKey(timeout)
if err != nil {
return false, err
}
switch ch {
case 'y', 'Y':
s.WriteString("Yes\r\n")
return true, nil
case 'n', 'N', '\r', '\n':
s.WriteString("No\r\n")
return false, nil
}
}
}

188
internal/session/output.go Normal file
View file

@ -0,0 +1,188 @@
package session
import (
"fmt"
"os"
"strings"
"time"
)
// ANSI escape code constants for terminal formatting.
// The original TAG-BBS used raw escape codes inline (e.g., "\033c\14\2331m").
// These named constants are easier to work with and self-documenting.
const (
AnsiReset = "\033[0m"
AnsiBold = "\033[1m"
AnsiDim = "\033[2m"
AnsiUnderline = "\033[4m"
AnsiBlink = "\033[5m"
AnsiReverse = "\033[7m"
// Foreground colors
AnsiFgBlack = "\033[30m"
AnsiFgRed = "\033[31m"
AnsiFgGreen = "\033[32m"
AnsiFgYellow = "\033[33m"
AnsiFgBlue = "\033[34m"
AnsiFgMagenta = "\033[35m"
AnsiFgCyan = "\033[36m"
AnsiFgWhite = "\033[37m"
// Bright foreground colors
AnsiFgBrightBlack = "\033[90m"
AnsiFgBrightRed = "\033[91m"
AnsiFgBrightGreen = "\033[92m"
AnsiFgBrightYellow = "\033[93m"
AnsiFgBrightBlue = "\033[94m"
AnsiFgBrightMagenta = "\033[95m"
AnsiFgBrightCyan = "\033[96m"
AnsiFgBrightWhite = "\033[97m"
// Background colors
AnsiBgBlack = "\033[40m"
AnsiBgRed = "\033[41m"
AnsiBgGreen = "\033[42m"
AnsiBgYellow = "\033[43m"
AnsiBgBlue = "\033[44m"
AnsiBgMagenta = "\033[45m"
AnsiBgCyan = "\033[46m"
AnsiBgWhite = "\033[47m"
// Cursor and screen control
AnsiClearScreen = "\033[2J"
AnsiCursorHome = "\033[H"
AnsiClearLine = "\033[2K"
AnsiSaveCursor = "\033[s"
AnsiLoadCursor = "\033[u"
)
// Write sends raw bytes to the client. This is the lowest-level output
// function — everything else builds on it.
//
// In the original, this was split across SerPutStr/SerPutChar (serial)
// and ConPutStr/ConPutChar (console), with IO_Flags controlling which
// outputs were active. Here we just write to the connection; the single
// output destination (the network connection) replaces the flag-based
// multiplexing.
func (s *Session) Write(data []byte) (int, error) {
select {
case <-s.ctx.Done():
return 0, ErrDisconnected
default:
}
return s.conn.Write(data)
}
// WriteString sends a string to the client.
// This is the direct replacement for PutStr() in the original.
func (s *Session) WriteString(str string) error {
_, err := s.Write([]byte(str))
return err
}
// Printf formats and sends a string to the client.
// Convenience wrapper for the common sprintf+PutStr pattern that
// appeared throughout the original code.
func (s *Session) Printf(format string, args ...interface{}) error {
return s.WriteString(fmt.Sprintf(format, args...))
}
// NewLine sends a CR+LF. Telnet protocol requires \r\n for line endings,
// not just \n.
func (s *Session) NewLine() error {
return s.WriteString("\r\n")
}
// ClearScreen clears the terminal and moves cursor to home position.
// The original used "\014" (form feed) for this on the Amiga console;
// standard ANSI escape sequences are more portable.
func (s *Session) ClearScreen() error {
return s.WriteString(AnsiClearScreen + AnsiCursorHome)
}
// MoveCursor positions the cursor at the given column and row (1-based).
// Replaces StatCursorTo() from the original.
func (s *Session) MoveCursor(col, row int) error {
return s.Printf("\033[%d;%dH", row, col)
}
// Color sets the text color using ANSI codes. Accepts one or more
// ANSI constants which are concatenated. Reset with AnsiReset.
func (s *Session) Color(codes ...string) error {
return s.WriteString(strings.Join(codes, ""))
}
// HorizontalRule draws a line across the terminal width.
func (s *Session) HorizontalRule(ch rune) error {
width, _ := s.TerminalSize()
return s.WriteString(strings.Repeat(string(ch), width) + "\r\n")
}
// SendFile reads a file from disk and sends its contents to the client.
// This replaces MenuSend() from the original, which loaded text files
// (Logon.Text, MainMenu.Help, etc.) and sent them to the user.
//
// For ANSI art files (.ans), the raw bytes are sent directly — ANSI
// escape codes embedded in the file are interpreted by the client's
// terminal emulator.
//
// Returns nil if the file doesn't exist (missing screen files are not
// an error — the sysop simply hasn't installed one).
func (s *Session) SendFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil // Missing screen files are silently skipped
}
return fmt.Errorf("reading screen file %s: %w", path, err)
}
// Ensure proper line endings for telnet — convert bare \n to \r\n
// but don't double-convert existing \r\n sequences.
content := strings.ReplaceAll(string(data), "\r\n", "\n")
content = strings.ReplaceAll(content, "\n", "\r\n")
_, err = s.Write([]byte(content))
return err
}
// Paginate sends text through a "more" pager. After each screenful
// (based on terminal height), it pauses and waits for a keypress.
// Press Enter or space to continue, Q to stop.
//
// This improves on the original's behavior where long output scrolled
// off screen with only Ctrl-S (pause) and Ctrl-Q (resume) for flow
// control — a serial-era convention that doesn't apply to TCP.
func (s *Session) Paginate(text string, timeout time.Duration) error {
_, height := s.TerminalSize()
pageLines := height - 2 // Leave room for the prompt
lines := strings.Split(text, "\n")
lineCount := 0
for _, line := range lines {
// Ensure telnet line ending
cleaned := strings.TrimRight(line, "\r")
if err := s.WriteString(cleaned + "\r\n"); err != nil {
return err
}
lineCount++
if lineCount >= pageLines {
s.WriteString("\r\n" + AnsiBold + "--- More ---" + AnsiReset + " ")
ch, err := s.ReadKey(timeout)
if err != nil {
return err
}
// Clear the "more" prompt
s.WriteString("\r" + AnsiClearLine)
if ch == 'q' || ch == 'Q' {
return nil
}
lineCount = 0
}
}
return nil
}

249
internal/session/session.go Normal file
View file

@ -0,0 +1,249 @@
// Package session provides the core I/O abstraction for a BBS connection.
//
// In the original TAG-BBS, I/O was managed through global variables
// (IO_Flags[], ReadSerReq, WriteConReq, etc.) and errors like carrier
// loss or timeout were handled by longjmp() back to the main loop.
//
// Session replaces all of that. Each connected user gets a Session that
// wraps their network connection, manages input/output, tracks timing,
// and uses Go's context.Context for cancellation instead of longjmp.
// Everything above this layer — menus, commands, editors — talks to
// a Session and never touches the raw connection.
package session
import (
"context"
"fmt"
"net"
"sync"
"time"
)
// DisconnectReason describes why a session ended.
// These map to the original TAG-BBS logoff types in DEFINES.H.
type DisconnectReason int
const (
DisconnectNormal DisconnectReason = iota // User typed 'goodbye' (STANDARD_LOGOFF)
DisconnectTimeout // Idle timeout (SLEEP_LOGOFF)
DisconnectKicked // Sysop force-disconnect (ILLEGAL_LOGOFF)
DisconnectDropped // Connection lost (CARRIER_LOGOFF)
DisconnectOvertime // Time limit exceeded (OVERTIME_LOGOFF)
)
func (d DisconnectReason) String() string {
switch d {
case DisconnectNormal:
return "normal logoff"
case DisconnectTimeout:
return "idle timeout"
case DisconnectKicked:
return "kicked by sysop"
case DisconnectDropped:
return "connection lost"
case DisconnectOvertime:
return "time limit exceeded"
default:
return "unknown"
}
}
// Session represents a single user's connection to the BBS.
// It is the modern equivalent of the original's combination of
// serial/console device handles, IO_Flags, and the jmp_buf Environment.
type Session struct {
conn net.Conn
filter *telnetFilter
// Context controls the session lifetime. Cancelling it is the
// equivalent of longjmp(Environment, ...) in the original — it
// unwinds all blocking I/O and returns control to the connection
// handler.
ctx context.Context
cancel context.CancelFunc
// Node number for this session (like the original's implicit
// single-node, but now we can have many).
Node int
// User identity — set after authentication. Used by NodeManager
// to report who's logged in at each node.
UserName string
UserID int64
// Terminal dimensions reported by the client via NAWS.
// Zero means unknown.
Width int
Height int
// Timing — replaces Time_connect, Time_limit, Time_menu_entry, etc.
ConnectedAt time.Time
TimeLimit time.Duration
timeUsed time.Duration
lastCheck time.Time
// Disconnect tracking
disconnectReason DisconnectReason
mu sync.Mutex
// Input buffer — raw bytes from the connection are read here,
// filtered through the telnet IAC stripper, then individual
// bytes are delivered to ReadChar() via the channel.
inputCh chan byte
inputDone chan struct{}
}
// New creates a Session wrapping the given network connection.
// The session starts its input reader goroutine immediately.
func New(conn net.Conn, node int) *Session {
ctx, cancel := context.WithCancel(context.Background())
s := &Session{
conn: conn,
filter: newTelnetFilter(),
ctx: ctx,
cancel: cancel,
Node: node,
ConnectedAt: time.Now(),
TimeLimit: 2 * time.Hour, // Default; overridden after auth
lastCheck: time.Now(),
inputCh: make(chan byte, 256),
inputDone: make(chan struct{}),
}
go s.readLoop()
return s
}
// Context returns the session's context. Command handlers can select
// on ctx.Done() to detect disconnection, or pass it to other functions
// that accept a context.
func (s *Session) Context() context.Context {
return s.ctx
}
// Close ends the session. This cancels the context (which unblocks any
// pending ReadChar), closes the network connection (which terminates
// the read loop), and waits for the read loop to finish.
func (s *Session) Close(reason DisconnectReason) {
s.mu.Lock()
s.disconnectReason = reason
s.mu.Unlock()
s.cancel()
s.conn.Close()
<-s.inputDone // Wait for readLoop to exit
}
// DisconnectReason returns why the session ended.
func (s *Session) Reason() DisconnectReason {
s.mu.Lock()
defer s.mu.Unlock()
return s.disconnectReason
}
// RemoteAddr returns the client's network address.
func (s *Session) RemoteAddr() string {
return s.conn.RemoteAddr().String()
}
// TerminalSize returns the client's terminal dimensions if known.
// Returns 80x24 as defaults if the client didn't send NAWS.
func (s *Session) TerminalSize() (width, height int) {
s.mu.Lock()
defer s.mu.Unlock()
w, h := s.Width, s.Height
if w == 0 {
w = 80
}
if h == 0 {
h = 24
}
return w, h
}
// CheckTime updates the session's time accounting and returns an error
// if the time limit has been exceeded. This replaces Check_Online_Status()
// from the original, minus the carrier detect check (TCP handles that
// via the read loop detecting a closed connection).
func (s *Session) CheckTime() error {
now := time.Now()
elapsed := now.Sub(s.lastCheck)
s.lastCheck = now
s.timeUsed += elapsed
remaining := s.TimeLimit - s.timeUsed
if remaining <= 0 {
return fmt.Errorf("time limit exceeded")
}
// One-minute warning, like the original
if remaining <= time.Minute && remaining+elapsed > time.Minute {
s.WriteString("\r\n*** One minute warning ***\r\n")
}
return nil
}
// TimeRemaining returns how much time the user has left.
func (s *Session) TimeRemaining() time.Duration {
remaining := s.TimeLimit - s.timeUsed
if remaining < 0 {
return 0
}
return remaining
}
// Negotiate sends the initial telnet option negotiation to the client.
// This must be called before any other I/O on the session.
func (s *Session) Negotiate() error {
_, err := s.conn.Write(telnetNegotiation())
return err
}
// readLoop runs in its own goroutine for the lifetime of the session.
// It reads raw bytes from the connection, strips telnet IAC sequences
// via the filter, and feeds clean data bytes into inputCh one at a time.
//
// This is the modern equivalent of the original's SendIO(ReadSerReq)
// that kept an async read pending on the serial port at all times.
// When the connection closes (or the context is cancelled), the loop
// exits and closes inputDone to signal completion.
func (s *Session) readLoop() {
defer close(s.inputDone)
defer close(s.inputCh)
buf := make([]byte, 256)
for {
n, err := s.conn.Read(buf)
if err != nil {
// Connection closed or errored — equivalent to carrier loss.
s.mu.Lock()
if s.disconnectReason == DisconnectNormal {
s.disconnectReason = DisconnectDropped
}
s.mu.Unlock()
s.cancel()
return
}
clean := s.filter.Filter(buf[:n])
// Update terminal size if NAWS was received
if s.filter.Width > 0 {
s.mu.Lock()
s.Width = s.filter.Width
s.Height = s.filter.Height
s.mu.Unlock()
}
for _, b := range clean {
select {
case s.inputCh <- b:
case <-s.ctx.Done():
return
}
}
}
}

163
internal/session/telnet.go Normal file
View file

@ -0,0 +1,163 @@
package session
// Telnet protocol constants.
// These are the IAC (Interpret As Command) sequences used to negotiate
// terminal options between server and client. In the original TAG-BBS,
// none of this existed — the serial port was a raw byte stream. Telnet
// adds a signaling layer on top of TCP that we need to handle.
const (
iacIAC byte = 255 // Interpret As Command — escapes the byte that follows
iacDONT byte = 254 // Demand the client stop using an option
iacDO byte = 253 // Request the client start using an option
iacWONT byte = 252 // Refuse to use an option
iacWILL byte = 251 // Offer to use an option
iacSB byte = 250 // Subnegotiation Begin
iacSE byte = 240 // Subnegotiation End
iacNOP byte = 241 // No Operation
iacGA byte = 249 // Go Ahead
// Telnet options we care about
optEcho byte = 1 // Server controls echo
optSGA byte = 3 // Suppress Go-Ahead (character-at-a-time mode)
optNAWS byte = 31 // Negotiate About Window Size
optTTYPE byte = 24 // Terminal Type
optLINEMODE byte = 34 // Linemode
)
// telnetNegotiation returns the IAC sequence to send at connection start.
// This puts the client into character-at-a-time mode with server-side echo,
// which is what a BBS needs for single-keypress menus.
//
// We send:
// - WILL ECHO: "I (server) will handle echoing characters back to you"
// - WILL SGA: "I won't send Go-Ahead signals" (enables character mode)
// - DO NAWS: "Please tell me your terminal dimensions"
// - DONT LINEMODE: "Don't use linemode" (reinforces character-at-a-time)
func telnetNegotiation() []byte {
return []byte{
iacIAC, iacWILL, optEcho,
iacIAC, iacWILL, optSGA,
iacIAC, iacDO, optNAWS,
iacIAC, iacDONT, optLINEMODE,
}
}
// telnetState tracks the IAC parser state for stripping telnet commands
// from the data stream. Without this, IAC sequences show up as garbage
// characters in user input.
type telnetState int
const (
tsData telnetState = iota // Normal data
tsIAC // Got IAC, next byte is command
tsWill // Got WILL, next byte is option
tsWont // Got WONT, next byte is option
tsDo // Got DO, next byte is option
tsDont // Got DONT, next byte is option
tsSB // Inside subnegotiation
tsSBIAC // Got IAC inside subnegotiation
)
// telnetFilter strips IAC sequences from raw telnet data and returns
// only the clean user input bytes. It also captures window size if the
// client sends NAWS subnegotiation.
//
// The filter is stateful — it tracks where it is in the IAC parse across
// calls, since IAC sequences can be split across TCP reads.
type telnetFilter struct {
state telnetState
sbBuffer []byte // Accumulates subnegotiation data
sbOption byte // Which option the subnegotiation is for
Width int // Terminal width from NAWS (0 = unknown)
Height int // Terminal height from NAWS (0 = unknown)
}
func newTelnetFilter() *telnetFilter {
return &telnetFilter{
state: tsData,
}
}
// Filter processes raw bytes from the TCP connection and returns only
// the clean data bytes (user input). Telnet commands are consumed silently.
func (f *telnetFilter) Filter(raw []byte) []byte {
clean := make([]byte, 0, len(raw))
for _, b := range raw {
switch f.state {
case tsData:
if b == iacIAC {
f.state = tsIAC
} else {
clean = append(clean, b)
}
case tsIAC:
switch b {
case iacIAC:
// Escaped 0xFF — literal data byte
clean = append(clean, 0xFF)
f.state = tsData
case iacWILL:
f.state = tsWill
case iacWONT:
f.state = tsWont
case iacDO:
f.state = tsDo
case iacDONT:
f.state = tsDont
case iacSB:
f.state = tsSB
f.sbBuffer = f.sbBuffer[:0]
case iacNOP, iacGA:
f.state = tsData
default:
// Unknown command, skip
f.state = tsData
}
case tsWill, tsWont, tsDo, tsDont:
// Option byte — consumed, back to data
f.state = tsData
case tsSB:
if b == iacIAC {
f.state = tsSBIAC
} else {
if len(f.sbBuffer) == 0 {
f.sbOption = b
}
f.sbBuffer = append(f.sbBuffer, b)
}
case tsSBIAC:
if b == iacSE {
// Subnegotiation complete
f.handleSubnegotiation()
f.state = tsData
} else {
// Escaped IAC within subnegotiation
f.sbBuffer = append(f.sbBuffer, b)
f.state = tsSB
}
}
}
return clean
}
// handleSubnegotiation processes a completed subnegotiation sequence.
func (f *telnetFilter) handleSubnegotiation() {
if len(f.sbBuffer) < 1 {
return
}
switch f.sbOption {
case optNAWS:
// NAWS: option(1) + width(2) + height(2) = 5 bytes
if len(f.sbBuffer) >= 5 {
f.Width = int(f.sbBuffer[1])<<8 | int(f.sbBuffer[2])
f.Height = int(f.sbBuffer[3])<<8 | int(f.sbBuffer[4])
}
}
}

1119
internal/store/sqlite.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,423 @@
package store
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/urit/urit/internal/models"
)
// testStore creates a temporary SQLite store for testing.
// The database is removed after the test completes.
func testStore(t *testing.T) *SQLiteStore {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
s, err := OpenSQLite(path)
if err != nil {
t.Fatalf("OpenSQLite: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// --- User tests ---
func TestUserCRUD(t *testing.T) {
s := testStore(t)
// Create
user := &models.User{
Name: "TestUser",
PasswordHash: "$2a$10$fakehash",
Comments: "Test account",
Active: true,
SecStatus: 2,
SecBoard: 2,
SecLibrary: 2,
SecBulletin: 2,
TimeLimit: 3600,
LastOn: timePtr(time.Now()),
}
if err := s.CreateUser(user); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if user.ID == 0 {
t.Fatal("CreateUser did not set ID")
}
// Read by ID
got, err := s.GetUser(user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got == nil {
t.Fatal("GetUser returned nil")
}
if got.Name != "TestUser" {
t.Errorf("Name = %q, want %q", got.Name, "TestUser")
}
if got.SecStatus != 2 {
t.Errorf("SecStatus = %d, want 2", got.SecStatus)
}
// Read by name (case-insensitive)
got, err = s.GetUserByName("testuser")
if err != nil {
t.Fatalf("GetUserByName: %v", err)
}
if got == nil {
t.Fatal("GetUserByName returned nil for case-insensitive match")
}
if got.ID != user.ID {
t.Errorf("ID = %d, want %d", got.ID, user.ID)
}
// Read nonexistent
got, err = s.GetUser(9999)
if err != nil {
t.Fatalf("GetUser(9999): %v", err)
}
if got != nil {
t.Error("GetUser(9999) should return nil")
}
// Update
user.SecStatus = 255
user.MessagesPosted = 42
if err := s.UpdateUser(user); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
got, _ = s.GetUser(user.ID)
if got.SecStatus != 255 {
t.Errorf("SecStatus after update = %d, want 255", got.SecStatus)
}
if got.MessagesPosted != 42 {
t.Errorf("MessagesPosted = %d, want 42", got.MessagesPosted)
}
// List
users, err := s.ListUsers(0, 100)
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if len(users) != 1 {
t.Errorf("ListUsers returned %d users, want 1", len(users))
}
// Count
count, err := s.CountUsers()
if err != nil {
t.Fatalf("CountUsers: %v", err)
}
if count != 1 {
t.Errorf("CountUsers = %d, want 1", count)
}
// Delete (soft)
if err := s.DeleteUser(user.ID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
count, _ = s.CountUsers()
if count != 0 {
t.Errorf("CountUsers after delete = %d, want 0", count)
}
// Verify the record still exists but is inactive
got, _ = s.GetUser(user.ID)
if got == nil {
t.Fatal("Soft-deleted user should still be readable by ID")
}
if got.Active {
t.Error("Soft-deleted user should have Active=false")
}
}
func TestUserDuplicateName(t *testing.T) {
s := testStore(t)
user1 := &models.User{Name: "Sysop", Active: true}
if err := s.CreateUser(user1); err != nil {
t.Fatalf("CreateUser 1: %v", err)
}
user2 := &models.User{Name: "Sysop", Active: true}
err := s.CreateUser(user2)
if err == nil {
t.Fatal("Expected error for duplicate name, got nil")
}
}
// --- Board and Message tests ---
func TestBoardAndMessages(t *testing.T) {
s := testStore(t)
// Create board
board := &models.Board{
Name: "General",
ReadLow: 0,
ReadHigh: 255,
WriteLow: 1,
WriteHigh: 255,
MaxPosts: 100,
}
if err := s.CreateBoard(board); err != nil {
t.Fatalf("CreateBoard: %v", err)
}
if board.ID == 0 {
t.Fatal("CreateBoard did not set ID")
}
// List boards
boards, err := s.ListBoards()
if err != nil {
t.Fatalf("ListBoards: %v", err)
}
if len(boards) != 1 {
t.Errorf("ListBoards returned %d, want 1", len(boards))
}
// Create messages
msg1 := &models.Message{
BoardID: board.ID,
Title: "First Post",
Author: "Sysop",
AuthorID: 1,
Body: "Welcome to the BBS!",
}
if err := s.CreateMessage(msg1); err != nil {
t.Fatalf("CreateMessage 1: %v", err)
}
if msg1.Number != 1 {
t.Errorf("First message number = %d, want 1", msg1.Number)
}
msg2 := &models.Message{
BoardID: board.ID,
Title: "Reply",
Author: "User",
AuthorID: 2,
Body: "Thanks!",
ReplyTo: msg1.ID,
}
if err := s.CreateMessage(msg2); err != nil {
t.Fatalf("CreateMessage 2: %v", err)
}
if msg2.Number != 2 {
t.Errorf("Second message number = %d, want 2", msg2.Number)
}
// Verify board post_count updated
got, _ := s.GetBoard(board.ID)
if got.PostCount != 2 {
t.Errorf("Board PostCount = %d, want 2", got.PostCount)
}
// List messages
msgs, err := s.ListMessages(board.ID, 0, 50)
if err != nil {
t.Fatalf("ListMessages: %v", err)
}
if len(msgs) != 2 {
t.Errorf("ListMessages returned %d, want 2", len(msgs))
}
if msgs[0].Title != "First Post" {
t.Errorf("First msg title = %q", msgs[0].Title)
}
if msgs[1].ReplyTo != msg1.ID {
t.Errorf("Reply message ReplyTo = %d, want %d", msgs[1].ReplyTo, msg1.ID)
}
// Count messages
count, _ := s.CountMessages(board.ID)
if count != 2 {
t.Errorf("CountMessages = %d, want 2", count)
}
// Delete message
if err := s.DeleteMessage(msg1.ID); err != nil {
t.Fatalf("DeleteMessage: %v", err)
}
count, _ = s.CountMessages(board.ID)
if count != 1 {
t.Errorf("CountMessages after delete = %d, want 1", count)
}
// Delete board (cascades to messages)
if err := s.DeleteBoard(board.ID); err != nil {
t.Fatalf("DeleteBoard: %v", err)
}
count, _ = s.CountMessages(board.ID)
if count != 0 {
t.Errorf("Messages should be cascade-deleted with board")
}
}
// --- Mail tests ---
func TestMail(t *testing.T) {
s := testStore(t)
mail := &models.Mail{
Title: "Hello",
Author: "Alice",
FromID: 1,
ToID: 2,
Recipient: "Bob",
Body: "How's it going?",
}
if err := s.CreateMail(mail); err != nil {
t.Fatalf("CreateMail: %v", err)
}
// Count unread
count, _ := s.CountUnreadMail(2)
if count != 1 {
t.Errorf("Unread mail for Bob = %d, want 1", count)
}
// List mail
mails, err := s.ListMailFor(2)
if err != nil {
t.Fatalf("ListMailFor: %v", err)
}
if len(mails) != 1 {
t.Fatalf("ListMailFor returned %d, want 1", len(mails))
}
if mails[0].Read {
t.Error("New mail should be unread")
}
// Mark read
if err := s.MarkMailRead(mail.ID); err != nil {
t.Fatalf("MarkMailRead: %v", err)
}
count, _ = s.CountUnreadMail(2)
if count != 0 {
t.Errorf("Unread after marking = %d, want 0", count)
}
// Delete
if err := s.DeleteMail(mail.ID); err != nil {
t.Fatalf("DeleteMail: %v", err)
}
got, _ := s.GetMail(mail.ID)
if got != nil {
t.Error("Deleted mail should be nil")
}
}
// --- Library tests ---
func TestLibraryAndFiles(t *testing.T) {
s := testStore(t)
lib := &models.Library{
Name: "Downloads",
FilePath: "/data/files/downloads",
UploadLow: 2,
UploadHigh: 255,
DownloadLow: 0,
DownloadHigh: 255,
MaxFiles: 50,
}
if err := s.CreateLibrary(lib); err != nil {
t.Fatalf("CreateLibrary: %v", err)
}
// Create file entry
file := &models.LibraryFile{
LibraryID: lib.ID,
Filename: "readme.txt",
Description: "Read this first",
UploaderID: 1,
Uploader: "Sysop",
FileSize: 1024,
}
if err := s.CreateLibraryFile(file); err != nil {
t.Fatalf("CreateLibraryFile: %v", err)
}
// Verify library file_count updated
got, _ := s.GetLibrary(lib.ID)
if got.FileCount != 1 {
t.Errorf("Library FileCount = %d, want 1", got.FileCount)
}
// List files
files, err := s.ListLibraryFiles(lib.ID, 0, 50)
if err != nil {
t.Fatalf("ListLibraryFiles: %v", err)
}
if len(files) != 1 {
t.Errorf("ListLibraryFiles returned %d, want 1", len(files))
}
// Delete file
if err := s.DeleteLibraryFile(file.ID); err != nil {
t.Fatalf("DeleteLibraryFile: %v", err)
}
got, _ = s.GetLibrary(lib.ID)
if got.FileCount != 0 {
t.Errorf("FileCount after delete = %d, want 0", got.FileCount)
}
}
// --- Bulletin tests ---
func TestBulletins(t *testing.T) {
s := testStore(t)
b := &models.Bulletin{
Name: "System Rules",
FilePath: "./screens/rules.ans",
ReadLow: 0,
ReadHigh: 255,
}
if err := s.CreateBulletin(b); err != nil {
t.Fatalf("CreateBulletin: %v", err)
}
bulletins, err := s.ListBulletins()
if err != nil {
t.Fatalf("ListBulletins: %v", err)
}
if len(bulletins) != 1 {
t.Errorf("ListBulletins returned %d, want 1", len(bulletins))
}
if bulletins[0].Name != "System Rules" {
t.Errorf("Name = %q, want %q", bulletins[0].Name, "System Rules")
}
if err := s.DeleteBulletin(b.ID); err != nil {
t.Fatalf("DeleteBulletin: %v", err)
}
bulletins, _ = s.ListBulletins()
if len(bulletins) != 0 {
t.Error("Bulletins should be empty after delete")
}
}
// --- Schema test ---
func TestOpenSQLiteCreatesDirectory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "subdir", "deep", "test.db")
s, err := OpenSQLite(path)
if err != nil {
t.Fatalf("OpenSQLite with nested path: %v", err)
}
s.Close()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("Database file was not created")
}
}
func timePtr(t time.Time) *time.Time { return &t }

101
internal/store/store.go Normal file
View file

@ -0,0 +1,101 @@
// Package store defines the storage interface for URIT BBS.
//
// In the original TAG-BBS, data access was done through direct file I/O:
// open("User.Data"), lseek() to a slot offset, read()/write() a raw struct.
// Each data type (users, boards, mail, libraries) had its own flat file.
//
// The Store interface abstracts all of that behind methods that the BBS
// logic calls without knowing whether the backend is SQLite, PostgreSQL,
// or something else entirely. The default implementation is SQLite
// (see sqlite.go), which provides zero-configuration embedded storage.
//
// To add a new backend (e.g., PostgreSQL for clustered deployments),
// implement this interface in a new file and wire it up in the config
// loader. The BBS application code needs zero changes.
package store
import (
"github.com/urit/urit/internal/models"
)
// Store is the complete data access interface for the BBS.
// Every method that can fail returns an error.
type Store interface {
// Lifecycle
Close() error
// Users — replaces Load_Account, Save_Account, Find_Open_Account,
// ForceSave_Account from ACCOUNTS.C
CreateUser(user *models.User) error
GetUser(id int64) (*models.User, error)
GetUserByName(name string) (*models.User, error)
UpdateUser(user *models.User) error
DeleteUser(id int64) error // Soft delete (sets active=false)
HardDeleteUser(id int64) error // Permanent delete (removes record and mail)
ListUsers(offset, limit int) ([]*models.User, error)
ListAllUsers(offset, limit int) ([]*models.User, error)
CountUsers() (int, error)
// Boards — replaces the in-memory linked list of Board_Header
// that was loaded from System.Data at startup
CreateBoard(board *models.Board) error
GetBoard(id int64) (*models.Board, error)
ListBoards() ([]*models.Board, error)
UpdateBoard(board *models.Board) error
DeleteBoard(id int64) error
// Messages — replaces the .Keys + .Data file pair per board
CreateMessage(msg *models.Message) error
GetMessage(id int64) (*models.Message, error)
ListMessages(boardID int64, offset, limit int) ([]*models.Message, error)
ListMessagesSince(boardID int64, since int64) ([]*models.Message, error)
CountMessages(boardID int64) (int, error)
DeleteMessage(id int64) error
// Mail — replaces the Mail .Keys + .Data files
CreateMail(mail *models.Mail) error
GetMail(id int64) (*models.Mail, error)
ListMailFor(userID int64) ([]*models.Mail, error)
CountUnreadMail(userID int64) (int, error)
MarkMailRead(id int64) error
DeleteMail(id int64) error
// Libraries — replaces the in-memory linked list of Library_Header
CreateLibrary(lib *models.Library) error
GetLibrary(id int64) (*models.Library, error)
ListLibraries() ([]*models.Library, error)
UpdateLibrary(lib *models.Library) error
DeleteLibrary(id int64) error
// Library files — replaces the .Keys file per library
CreateLibraryFile(file *models.LibraryFile) error
GetLibraryFile(id int64) (*models.LibraryFile, error)
ListLibraryFiles(libraryID int64, offset, limit int) ([]*models.LibraryFile, error)
CountLibraryFiles(libraryID int64) (int, error)
DeleteLibraryFile(id int64) error
IncrementDownloads(fileID int64) error
// Bulletins — replaces the linked list of Bulletin_Header
CreateBulletin(b *models.Bulletin) error
GetBulletin(id int64) (*models.Bulletin, error)
ListBulletins() ([]*models.Bulletin, error)
UpdateBulletin(b *models.Bulletin) error
DeleteBulletin(id int64) error
// Call log — replaces Append_Stat(STAT_LOGON/LOGOFF) from TAG.C
LogEvent(event string, userID int64, userName string, node int, remoteAddr, detail string) error
ListCallLog(limit int) ([]*models.CallLogEntry, error)
ListCallLogForUser(userID int64, limit int) ([]*models.CallLogEntry, error)
GetLastCaller(excludeUserID int64) (*models.CallLogEntry, error)
// Stats counters — replaces Daily_Statistics/Overall_Statistics
IncrementStat(key string, delta int64) error
GetStat(key string) (int64, error)
GetAllStats() (map[string]int64, error)
// Web sessions — HTTP auth tokens for browser-based library access
CreateWebSession(s *models.WebSession) error
GetWebSession(token string) (*models.WebSession, error)
DeleteWebSession(token string) error
CleanExpiredWebSessions() (int64, error)
}

7
screens/README Normal file
View file

@ -0,0 +1,7 @@
# Place ANSI art display files (.ans) here.
#
# Supported screen files:
# welcome.ans - Displayed before login prompt
# goodbye.ans - Displayed at logoff
# newuser.ans - Displayed to new users after account creation
# mainmenu.ans - Main menu header