Initial Commit
This commit is contained in:
commit
57d32d0b58
2
.directory
Normal file
2
.directory
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[Desktop Entry]
|
||||
Icon=orange-folder-git
|
||||
23
CREDITS.md
Normal file
23
CREDITS.md
Normal 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
24
LICENSE
Normal 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
80
README.md
Normal 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
BIN
cmd/urit/data/urit.db
Normal file
Binary file not shown.
462
cmd/urit/init.go
Normal file
462
cmd/urit/init.go
Normal 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
127
cmd/urit/main.go
Normal 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
48
config.toml
Normal 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
268
docs/ROADMAP-v0.3.0.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# URIT BBS — v0.3.0 Roadmap
|
||||
|
||||
Status: **Planning**
|
||||
Previous: v0.2.0 (steps 10a–17) — 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 | Low–Med |
|
||||
| 23 | Full-text search | — | Low |
|
||||
| 24 | Telnet file listings | — | Low |
|
||||
| 25 | ANSI auto-detection | — | Low |
|
||||
| 26 | Rate limiting | — | Low |
|
||||
| 27 | Web admin expansion | — | High |
|
||||
BIN
docs/URIT-BBS-Sysop-Guide.docx
Normal file
BIN
docs/URIT-BBS-Sysop-Guide.docx
Normal file
Binary file not shown.
894
docs/sysop-guide.js
Normal file
894
docs/sysop-guide.js
Normal 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
10
go.mod
Normal 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
6
go.sum
Normal 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
396
internal/auth/auth.go
Normal 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
|
||||
}
|
||||
79
internal/auth/auth_test.go
Normal file
79
internal/auth/auth_test.go
Normal 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
148
internal/config/config.go
Normal 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
143
internal/menu/bulletins.go
Normal 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
289
internal/menu/chat.go
Normal 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
777
internal/menu/library.go
Normal 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
564
internal/menu/mail.go
Normal 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
863
internal/menu/menu.go
Normal 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
902
internal/menu/messages.go
Normal 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
40
internal/menu/nodes.go
Normal 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
1032
internal/menu/sysop.go
Normal file
File diff suppressed because it is too large
Load diff
502
internal/menu/sysop_boards.go
Normal file
502
internal/menu/sysop_boards.go
Normal 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)
|
||||
}
|
||||
399
internal/menu/sysop_bulletins.go
Normal file
399
internal/menu/sysop_bulletins.go
Normal 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)
|
||||
}
|
||||
477
internal/menu/sysop_libraries.go
Normal file
477
internal/menu/sysop_libraries.go
Normal 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
57
internal/models/board.go
Normal 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
|
||||
}
|
||||
20
internal/models/calllog.go
Normal file
20
internal/models/calllog.go
Normal 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
|
||||
}
|
||||
66
internal/models/library.go
Normal file
66
internal/models/library.go
Normal 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
23
internal/models/mail.go
Normal 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
88
internal/models/user.go
Normal 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"
|
||||
}
|
||||
}
|
||||
15
internal/models/websession.go
Normal file
15
internal/models/websession.go
Normal 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
464
internal/server/admin.go
Normal 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 ></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
131
internal/server/chat.go
Normal 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
1145
internal/server/http.go
Normal file
File diff suppressed because it is too large
Load diff
504
internal/server/server.go
Normal file
504
internal/server/server.go
Normal 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
163
internal/server/telnet.go
Normal 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
99
internal/server/tokens.go
Normal 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
229
internal/session/input.go
Normal 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
188
internal/session/output.go
Normal 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
249
internal/session/session.go
Normal 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
163
internal/session/telnet.go
Normal 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
1119
internal/store/sqlite.go
Normal file
File diff suppressed because it is too large
Load diff
423
internal/store/sqlite_test.go
Normal file
423
internal/store/sqlite_test.go
Normal 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
101
internal/store/store.go
Normal 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
7
screens/README
Normal 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
|
||||
Loading…
Reference in a new issue