From ecc75a08c0ea425105649bfebdb9669347508506 Mon Sep 17 00:00:00 2001 From: strangeprogram <> Date: Mon, 15 Jul 2024 20:55:11 -0600 Subject: [PATCH] initial commit yo --- README.md | 101 ++++++++++- cmd/gbbs/main.go | 105 +++++++++++ config.json | 8 + guestbook.txt | 0 internal/ansi/ansi.go | 13 ++ internal/config/config.go | 52 ++++++ internal/messageboard/messageboard.go | 68 ++++++++ internal/prompt/prompt.go | 18 ++ internal/ssh/ssh.go | 241 ++++++++++++++++++++++++++ internal/telnet/telnet.go | 170 ++++++++++++++++++ internal/user/user.go | 95 ++++++++++ internal/web/web.go | 99 +++++++++++ welcome.ans | 10 ++ 13 files changed, 979 insertions(+), 1 deletion(-) create mode 100644 cmd/gbbs/main.go create mode 100644 config.json create mode 100644 guestbook.txt create mode 100644 internal/ansi/ansi.go create mode 100644 internal/config/config.go create mode 100644 internal/messageboard/messageboard.go create mode 100644 internal/prompt/prompt.go create mode 100644 internal/ssh/ssh.go create mode 100644 internal/telnet/telnet.go create mode 100644 internal/user/user.go create mode 100644 internal/web/web.go create mode 100644 welcome.ans diff --git a/README.md b/README.md index 44dbc56..f53c57f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,101 @@ -# gBBS +# GBBS (Go Bulletin Board System) +GBBS is a modern implementation of a classic Bulletin Board System (BBS) written in Go. It provides a nostalgic interface with modern backend technologies, supporting Telnet, SSH, and Web access. + +## Features + +- Multi-protocol support: Telnet, SSH, and Web +- User authentication and registration +- Message board functionality +- ANSI color support for Telnet and SSH clients +- Customizable welcome screen +- SQLite database for user management +- Concurrent connections handling + +## Getting Started + +### Prerequisites + +- Go 1.16 or later +- SQLite + +### Installation + +1. Clone the repository: + ``` + git clone https://github.com/strangeprogram/gbbs.git + cd gbbs + ``` + +2. Install dependencies: + ``` + go mod tidy + ``` + +3. Create a `config.json` file in the project root: + ```json + { + "telnet_port": 2323, + "ssh_port": 2222, + "web_port": 8080, + "guestbook_path": "guestbook.txt", + "web_root": "web", + "welcome_screen_path": "welcome.ans" + } + ``` + +4. Create a `welcome.ans` file with your desired ANSI art welcome screen. + +### Running the BBS + +To run the BBS in debug mode: + +``` +go run cmd/gbbs/main.go --debug +``` + +To compile and run: + +``` +go build -o gbbs cmd/gbbs/main.go +./gbbs +``` + +## Connecting to the BBS + +- Telnet: `telnet localhost 2323` +- SSH: `ssh localhost -p 2222` +- Web: Open a browser and navigate to `http://localhost:8080` + +## Version History + +- v0.1: Initial implementation with basic Telnet support +- v0.2: Added SSH support +- v0.3: Implemented web interface +- v0.4: Added user authentication and registration +- v0.5: Introduced message board functionality +- v0.6: Improved ANSI color support and welcome screen customization +- v0.7: Fixed SSH input handling issues + +## TODO + +- [ ] Implement IRC link integration +- [ ] Add file transfer capabilities +- [ ] Create a more robust web interface +- [ ] Implement user roles and permissions +- [ ] Add support for multiple message boards/forums +- [ ] Implement private messaging between users +- [ ] Create a plugin system for easy feature extensions +- [ ] Add support for external authentication methods (e.g., OAuth) +- [ ] Implement a basic game or interactive feature +- [ ] Create a telnet/SSH client specifically designed for this BBS + +## License + +This project is licensed under the GNU License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- MysticBBS | Oblivion/v2 +- BBS +- archive the dream diff --git a/cmd/gbbs/main.go b/cmd/gbbs/main.go new file mode 100644 index 0000000..4616e9b --- /dev/null +++ b/cmd/gbbs/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + "sync" + + "gbbs/internal/config" + "gbbs/internal/messageboard" + "gbbs/internal/ssh" + "gbbs/internal/telnet" + "gbbs/internal/user" + "gbbs/internal/web" +) + +var debug = flag.Bool("debug", false, "Enable debug mode") + +func main() { + flag.Parse() + + if *debug { + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + } else { + log.SetOutput(os.NewFile(0, os.DevNull)) + } + + // Get the executable path + ex, err := os.Executable() + if err != nil { + log.Fatalf("Failed to get executable path: %v", err) + } + exePath := filepath.Dir(ex) + + // Get the current working directory + cwd, err := os.Getwd() + if err != nil { + log.Fatalf("Failed to get current working directory: %v", err) + } + + log.Printf("Executable directory: %s", exePath) + log.Printf("Current working directory: %s", cwd) + + // Try to load config from multiple locations + configPaths := []string{ + filepath.Join(cwd, "config.json"), + filepath.Join(cwd, "cmd", "gbbs", "config.json"), + filepath.Join(exePath, "config.json"), + filepath.Join(exePath, "cmd", "gbbs", "config.json"), + } + + var cfg *config.Config + var configErr error + for _, path := range configPaths { + cfg, configErr = config.Load(path) + if configErr == nil { + log.Printf("Loaded configuration from: %s", path) + break + } + } + + if configErr != nil { + log.Fatalf("Failed to load configuration: %v\nTried paths: %v", configErr, configPaths) + } + + log.Printf("Loaded configuration: %+v", cfg) + + userManager, err := user.NewManager("bbs.db") + if err != nil { + log.Fatalf("Failed to initialize user manager: %v", err) + } + defer userManager.Close() + + messageBoard, err := messageboard.New(cfg.GuestbookPath) + if err != nil { + log.Fatalf("Failed to initialize message board: %v", err) + } + + var wg sync.WaitGroup + + wg.Add(3) + go func() { + defer wg.Done() + if err := telnet.Serve(cfg, userManager, messageBoard); err != nil { + log.Printf("Telnet server error: %v", err) + } + }() + go func() { + defer wg.Done() + if err := ssh.Serve(cfg, userManager, messageBoard); err != nil { + log.Printf("SSH server error: %v", err) + } + }() + go func() { + defer wg.Done() + if err := web.Serve(cfg.WebPort, cfg.WebRoot, userManager, messageBoard); err != nil { + log.Printf("Web server error: %v", err) + } + }() + + log.Printf("BBS is running. Telnet: %d, SSH: %d, Web: %d", cfg.TelnetPort, cfg.SSHPort, cfg.WebPort) + + wg.Wait() +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..1a77b08 --- /dev/null +++ b/config.json @@ -0,0 +1,8 @@ +{ + "telnet_port": 31337, + "ssh_port": 22, + "web_port": 8080, + "guestbook_path": "guestbook.txt", + "web_root": "web", + "welcome_screen_path": "welcome.ans" +} diff --git a/guestbook.txt b/guestbook.txt new file mode 100644 index 0000000..e69de29 diff --git a/internal/ansi/ansi.go b/internal/ansi/ansi.go new file mode 100644 index 0000000..8ec73ce --- /dev/null +++ b/internal/ansi/ansi.go @@ -0,0 +1,13 @@ +package ansi + +const ( + ColorBlack = "\033[0;30m" + ColorRed = "\033[0;31m" + ColorGreen = "\033[0;32m" + ColorYellow = "\033[0;33m" + ColorBlue = "\033[0;34m" + ColorMagenta = "\033[0;35m" + ColorCyan = "\033[0;36m" + ColorWhite = "\033[0;37m" + ColorReset = "\033[0m" +) \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e245205 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Config struct { + TelnetPort int `json:"telnet_port"` + SSHPort int `json:"ssh_port"` + WebPort int `json:"web_port"` + GuestbookPath string `json:"guestbook_path"` + WebRoot string `json:"web_root"` + WelcomeScreenPath string `json:"welcome_screen_path"` +} + +func Load(configPath string) (*Config, error) { + cfg := &Config{ + TelnetPort: 2323, + SSHPort: 2222, + WebPort: 8080, + GuestbookPath: "guestbook.txt", + WebRoot: "web", + WelcomeScreenPath: "welcome.ans", + } + + file, err := os.Open(configPath) + if err != nil { + return nil, fmt.Errorf("error opening config file: %v", err) + } + defer file.Close() + + if err := json.NewDecoder(file).Decode(cfg); err != nil { + return nil, fmt.Errorf("error decoding config file: %v", err) + } + + // Convert relative paths to absolute paths + cfg.GuestbookPath = makeAbsolute(filepath.Dir(configPath), cfg.GuestbookPath) + cfg.WebRoot = makeAbsolute(filepath.Dir(configPath), cfg.WebRoot) + cfg.WelcomeScreenPath = makeAbsolute(filepath.Dir(configPath), cfg.WelcomeScreenPath) + + return cfg, nil +} + +func makeAbsolute(basePath, path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(basePath, path) +} diff --git a/internal/messageboard/messageboard.go b/internal/messageboard/messageboard.go new file mode 100644 index 0000000..36524e5 --- /dev/null +++ b/internal/messageboard/messageboard.go @@ -0,0 +1,68 @@ +package messageboard + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + "time" +) + +type MessageBoard struct { + filePath string + mu sync.Mutex +} + +func New(filePath string) (*MessageBoard, error) { + file, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return nil, err + } + file.Close() + + return &MessageBoard{filePath: filePath}, nil +} + +func (mb *MessageBoard) PostMessage(username, message string) error { + if len(message) == 0 { + return fmt.Errorf("message cannot be empty") + } + if len(message) > 500 { + return fmt.Errorf("message too long (max 500 characters)") + } + + mb.mu.Lock() + defer mb.mu.Unlock() + + file, err := os.OpenFile(mb.filePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(fmt.Sprintf("[%s] %s: %s\n", time.Now().Format("2006-01-02 15:04:05"), username, message)) + return err +} + +func (mb *MessageBoard) GetMessages() ([]string, error) { + mb.mu.Lock() + defer mb.mu.Unlock() + + content, err := os.ReadFile(mb.filePath) + if err != nil { + return nil, err + } + + scanner := bufio.NewScanner(strings.NewReader(string(content))) + var messages []string + for scanner.Scan() { + messages = append(messages, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return messages, nil +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..15077f6 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,18 @@ +package prompt + +import ( + "fmt" + "gbbs/internal/config" + "os" +) + +func ReadWelcomeScreen(cfg *config.Config) (string, error) { + content, err := os.ReadFile(cfg.WelcomeScreenPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Sprintf("Welcome to GBBS!\n\nWelcome screen file not found: %s\n", cfg.WelcomeScreenPath), nil + } + return "", fmt.Errorf("error reading welcome screen: %v", err) + } + return string(content), nil +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..def03ba --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,241 @@ +package ssh + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "io" + "log" + "net" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/term" + + "gbbs/internal/config" + "gbbs/internal/messageboard" + "gbbs/internal/prompt" + "gbbs/internal/user" +) + +func generateSSHKey() (ssh.Signer, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return ssh.NewSignerFromKey(key) +} + +func Serve(cfg *config.Config, userManager *user.Manager, messageBoard *messageboard.MessageBoard) error { + signer, err := generateSSHKey() + if err != nil { + return fmt.Errorf("failed to generate SSH key: %v", err) + } + + config := &ssh.ServerConfig{ + NoClientAuth: true, + } + config.AddHostKey(signer) + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.SSHPort)) + if err != nil { + return err + } + log.Printf("SSH server listening on port %d", cfg.SSHPort) + + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("Failed to accept incoming connection: %v", err) + continue + } + go handleConnection(conn, config, cfg, userManager, messageBoard) + } +} + +func handleConnection(conn net.Conn, config *ssh.ServerConfig, cfg *config.Config, userManager *user.Manager, messageBoard *messageboard.MessageBoard) { + defer conn.Close() + + sshConn, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + log.Printf("Failed to handshake: %v", err) + return + } + defer sshConn.Close() + + log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion()) + + go ssh.DiscardRequests(reqs) + + for newChannel := range chans { + if newChannel.ChannelType() != "session" { + newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + continue + } + channel, requests, err := newChannel.Accept() + if err != nil { + log.Printf("Could not accept channel: %v", err) + continue + } + + go func(in <-chan *ssh.Request) { + for req := range in { + ok := false + switch req.Type { + case "shell": + ok = true + if len(req.Payload) > 0 { + ok = false + } + case "pty-req": + ok = true + } + req.Reply(ok, nil) + } + }(requests) + + term := term.NewTerminal(channel, "> ") + go handleSSHSession(term, cfg, userManager, messageBoard) + } +} + +func handleSSHSession(term *term.Terminal, cfg *config.Config, userManager *user.Manager, messageBoard *messageboard.MessageBoard) { + defer term.Write([]byte("Goodbye!\n")) + + welcomeScreen, err := prompt.ReadWelcomeScreen(cfg) + if err != nil { + fmt.Fprintf(term, "Error reading welcome screen: %v\n", err) + return + } + + term.Write([]byte(welcomeScreen)) + term.Write([]byte("\n\n\n\n\n")) // new lines to make it look real nice yo + + for { + term.SetPrompt("\033[0;32mChoose (L)ogin or (R)egister: \033[0m") + choice, err := term.ReadLine() + if err != nil { + if err == io.EOF { + return + } + fmt.Fprintf(term, "\033[0;31mError reading input: %v\033[0m\n", err) + continue + } + + switch strings.ToLower(strings.TrimSpace(choice)) { + case "l": + username, err := login(term, userManager) + if err != nil { + fmt.Fprintf(term, "\033[0;31mLogin failed: %v\033[0m\n", err) + continue + } + fmt.Fprintf(term, "\n\033[1;32mLogin successful! Welcome, %s!\033[0m\n", username) + time.Sleep(2 * time.Second) + handleBBS(term, username, messageBoard) + return + case "r": + username, err := register(term, userManager) + if err != nil { + fmt.Fprintf(term, "\033[0;31mRegistration failed: %v\033[0m\n", err) + continue + } + fmt.Fprintf(term, "\n\033[1;32mRegistration successful! Welcome, %s!\033[0m\n", username) + time.Sleep(2 * time.Second) + handleBBS(term, username, messageBoard) + return + default: + fmt.Fprintf(term, "\033[0;31mInvalid choice. Please enter 'L' or 'R'.\033[0m\n") + } + } +} + +func login(term *term.Terminal, userManager *user.Manager) (string, error) { + term.SetPrompt("Username: ") + username, err := term.ReadLine() + if err != nil { + return "", err + } + + term.SetPrompt("Password: ") + password, err := term.ReadPassword("Password: ") + if err != nil { + return "", err + } + + authenticated, err := userManager.Authenticate(username, password) + if err != nil { + return "", err + } + if !authenticated { + return "", fmt.Errorf("invalid username or password") + } + + return username, nil +} + +func register(term *term.Terminal, userManager *user.Manager) (string, error) { + term.SetPrompt("Choose a username: ") + username, err := term.ReadLine() + if err != nil { + return "", err + } + + term.SetPrompt("Choose a password: ") + password, err := term.ReadPassword("Choose a password: ") + if err != nil { + return "", err + } + + err = userManager.CreateUser(username, password) + if err != nil { + return "", err + } + + return username, nil +} + +func handleBBS(term *term.Terminal, username string, messageBoard *messageboard.MessageBoard) { + for { + term.Write([]byte("\n\033[0;36mBBS Menu:\033[0m\n")) + term.Write([]byte("1. Read messages\n")) + term.Write([]byte("2. Post message\n")) + term.Write([]byte("3. Logout\n")) + term.SetPrompt("Choice: ") + + choice, err := term.ReadLine() + if err != nil { + fmt.Fprintf(term, "\033[0;31mError reading input: %v\033[0m\n", err) + continue + } + + switch strings.TrimSpace(choice) { + case "1": + messages, err := messageBoard.GetMessages() + if err != nil { + fmt.Fprintf(term, "\033[0;31mError reading messages: %v\033[0m\n", err) + } else { + for _, msg := range messages { + fmt.Fprintf(term, "%s\n", msg) + } + } + case "2": + term.SetPrompt("Enter your message: ") + message, err := term.ReadLine() + if err != nil { + fmt.Fprintf(term, "\033[0;31mError reading message: %v\033[0m\n", err) + continue + } + err = messageBoard.PostMessage(username, message) + if err != nil { + fmt.Fprintf(term, "\033[0;31mError posting message: %v\033[0m\n", err) + } else { + fmt.Fprintf(term, "\033[0;32mMessage posted successfully!\033[0m\n") + } + case "3": + return + default: + fmt.Fprintf(term, "\033[0;31mInvalid choice. Please try again.\033[0m\n") + } + } +} diff --git a/internal/telnet/telnet.go b/internal/telnet/telnet.go new file mode 100644 index 0000000..79ba05d --- /dev/null +++ b/internal/telnet/telnet.go @@ -0,0 +1,170 @@ +package telnet + +import ( + "bufio" + "fmt" + "gbbs/internal/config" + "gbbs/internal/messageboard" + "gbbs/internal/prompt" + "gbbs/internal/user" + "net" + "strings" + "time" +) + +func Serve(cfg *config.Config, userManager *user.Manager, messageBoard *messageboard.MessageBoard) error { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.TelnetPort)) + if err != nil { + return err + } + defer listener.Close() + + for { + conn, err := listener.Accept() + if err != nil { + continue + } + go handleConnection(conn, cfg, userManager, messageBoard) + } +} + +func handleConnection(conn net.Conn, cfg *config.Config, userManager *user.Manager, messageBoard *messageboard.MessageBoard) { + defer conn.Close() + + writer := bufio.NewWriter(conn) + reader := bufio.NewReader(conn) + + welcomeScreen, err := prompt.ReadWelcomeScreen(cfg) + if err != nil { + fmt.Fprintf(writer, "Error reading welcome screen: %v\n", err) + return + } + + fmt.Fprint(writer, welcomeScreen) + writer.Flush() + + for { + fmt.Fprintf(writer, "\n\033[0;31mPlease choose an option:\033[0m\n") + fmt.Fprintf(writer, "\033[1;32mL\033[0m - Login\n") + fmt.Fprintf(writer, "\033[1;32mR\033[0m - Register\n\n") + fmt.Fprintf(writer, "\033[0;32mYour choice: \033[0m") + writer.Flush() + + choice, _ := reader.ReadString('\n') + choice = strings.TrimSpace(choice) + + if strings.EqualFold(choice, "L") { + username, err := login(reader, writer, userManager) + if err != nil { + fmt.Fprintf(writer, "\033[0;31mLogin failed: %v\033[0m\n", err) + writer.Flush() + continue + } + fmt.Fprintf(writer, "\n\033[1;32mLogin successful! Welcome, %s!\033[0m\n", username) + writer.Flush() + time.Sleep(2 * time.Second) // Pause for 2 seconds to show the message + handleBBS(username, reader, writer, messageBoard) + return + } else if strings.EqualFold(choice, "R") { + username, err := register(reader, writer, userManager) + if err != nil { + fmt.Fprintf(writer, "\033[0;31mRegistration failed: %v\033[0m\n", err) + writer.Flush() + continue + } + fmt.Fprintf(writer, "\n\033[1;32mRegistration successful! Welcome, %s!\033[0m\n", username) + writer.Flush() + time.Sleep(2 * time.Second) // Pause for 2 seconds to show the message + handleBBS(username, reader, writer, messageBoard) + return + } else { + fmt.Fprintf(writer, "\033[0;31mInvalid choice. Please try again.\033[0m\n") + writer.Flush() + } + } +} + +func login(reader *bufio.Reader, writer *bufio.Writer, userManager *user.Manager) (string, error) { + fmt.Fprintf(writer, "Username: ") + writer.Flush() + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + + fmt.Fprintf(writer, "Password: ") + writer.Flush() + password, _ := reader.ReadString('\n') + password = strings.TrimSpace(password) + + authenticated, err := userManager.Authenticate(username, password) + if err != nil { + return "", err + } + if !authenticated { + return "", fmt.Errorf("invalid username or password") + } + + return username, nil +} + +func register(reader *bufio.Reader, writer *bufio.Writer, userManager *user.Manager) (string, error) { + fmt.Fprintf(writer, "Choose a username: ") + writer.Flush() + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + + fmt.Fprintf(writer, "Choose a password: ") + writer.Flush() + password, _ := reader.ReadString('\n') + password = strings.TrimSpace(password) + + err := userManager.CreateUser(username, password) + if err != nil { + return "", err + } + + return username, nil +} + +func handleBBS(username string, reader *bufio.Reader, writer *bufio.Writer, messageBoard *messageboard.MessageBoard) { + for { + fmt.Fprintf(writer, "\n\033[0;36mBBS Menu:\033[0m\n") + fmt.Fprintf(writer, "1. Read messages\n") + fmt.Fprintf(writer, "2. Post message\n") + fmt.Fprintf(writer, "3. Logout\n") + fmt.Fprintf(writer, "Choice: ") + writer.Flush() + + choice, _ := reader.ReadString('\n') + choice = strings.TrimSpace(choice) + + switch choice { + case "1": + messages, err := messageBoard.GetMessages() + if err != nil { + fmt.Fprintf(writer, "\033[0;31mError reading messages: %v\033[0m\n", err) + } else { + for _, msg := range messages { + fmt.Fprintf(writer, "%s\n", msg) + } + } + case "2": + fmt.Fprintf(writer, "Enter your message: ") + writer.Flush() + message, _ := reader.ReadString('\n') + message = strings.TrimSpace(message) + err := messageBoard.PostMessage(username, message) + if err != nil { + fmt.Fprintf(writer, "\033[0;31mError posting message: %v\033[0m\n", err) + } else { + fmt.Fprintf(writer, "\033[0;32mMessage posted successfully!\033[0m\n") + } + case "3": + fmt.Fprintf(writer, "\033[0;33mGoodbye!\033[0m\n") + writer.Flush() + return + default: + fmt.Fprintf(writer, "\033[0;31mInvalid choice. Please try again.\033[0m\n") + } + writer.Flush() + } +} diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..ef443c1 --- /dev/null +++ b/internal/user/user.go @@ -0,0 +1,95 @@ +package user + +import ( + "database/sql" + "errors" + + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" +) + +type Manager struct { + db *sql.DB +} + +var ( + ErrInvalidUsername = errors.New("invalid username") + ErrInvalidPassword = errors.New("invalid password") + ErrUserExists = errors.New("user already exists") +) + +func NewManager(dbPath string) (*Manager, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE, + password TEXT + ) + `) + if err != nil { + return nil, err + } + + return &Manager{db: db}, nil +} + +func (m *Manager) Close() error { + return m.db.Close() +} + +func (m *Manager) Authenticate(username, password string) (bool, error) { + var storedPassword string + err := m.db.QueryRow("SELECT password FROM users WHERE username = ?", username).Scan(&storedPassword) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + + err = bcrypt.CompareHashAndPassword([]byte(storedPassword), []byte(password)) + return err == nil, nil +} + +func (m *Manager) CreateUser(username, password string) error { + if err := validateUsername(username); err != nil { + return err + } + if err := validatePassword(password); err != nil { + return err + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + _, err = m.db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, string(hashedPassword)) + if err != nil { + if err.Error() == "UNIQUE constraint failed: users.username" { + return ErrUserExists + } + return err + } + + return nil +} + +func validateUsername(username string) error { + if len(username) < 3 || len(username) > 20 { + return ErrInvalidUsername + } + return nil +} + +func validatePassword(password string) error { + if len(password) < 8 { + return ErrInvalidPassword + } + return nil +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..e6bf94d --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,99 @@ +package web + +import ( + "encoding/json" + "fmt" + "gbbs/internal/messageboard" + "gbbs/internal/user" + "net/http" +) + +func Serve(port int, webRoot string, userManager *user.Manager, messageBoard *messageboard.MessageBoard) error { + http.Handle("/", http.FileServer(http.Dir(webRoot))) + http.HandleFunc("/api/login", loginHandler(userManager)) + http.HandleFunc("/api/register", registerHandler(userManager)) + http.HandleFunc("/api/messages", messagesHandler(messageBoard)) + + return http.ListenAndServe(fmt.Sprintf(":%d", port), nil) +} + +func loginHandler(userManager *user.Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + authenticated, err := userManager.Authenticate(creds.Username, creds.Password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if !authenticated { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Login successful"}) + } +} + +func registerHandler(userManager *user.Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var creds struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := userManager.CreateUser(creds.Username, creds.Password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "User created successfully"}) + } +} + +func messagesHandler(messageBoard *messageboard.MessageBoard) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + messages, err := messageBoard.GetMessages() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(messages) + case http.MethodPost: + var msg struct { + Username string `json:"username"` + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := messageBoard.PostMessage(msg.Username, msg.Message); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "Message posted successfully"}) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + } +} diff --git a/welcome.ans b/welcome.ans new file mode 100644 index 0000000..4037499 --- /dev/null +++ b/welcome.ans @@ -0,0 +1,10 @@ +Welcome to + ░▒▓██████▓▒░░▒▓███████▓▒░░▒▓███████▓▒░ ░▒▓███████▓▒░ +░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ +░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ +░▒▓█▓▒▒▓███▓▒░▒▓███████▓▒░░▒▓███████▓▒░ ░▒▓██████▓▒░ +░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ +░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░ + ░▒▓██████▓▒░░▒▓███████▓▒░░▒▓███████▓▒░░▒▓███████▓▒░ + + ver 0.7 alpha \ No newline at end of file