diff --git a/go.mod b/go.mod index 78dd3f5..e3b136e 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect diff --git a/go.sum b/go.sum index 180680e..2a30bda 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= +github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= diff --git a/server.go b/server.go index 4e09981..adf4f75 100644 --- a/server.go +++ b/server.go @@ -7,13 +7,18 @@ import ( "net" "strings" "sync" + + gonanoid "github.com/matoous/go-nanoid/v2" ) +// broadcast represents a line to send to all connections with the ability +// to exclude a single connection (e.g., exclude self on join). type broadcast struct { text string exclude net.Conn } +// Hub manages the set of connected clients and fan-out of messages. type Hub struct { mu sync.Mutex conns map[net.Conn]struct{} @@ -51,6 +56,7 @@ func (h *Hub) Run() { if msg.exclude != nil && c == msg.exclude { continue } + // Newline-delimited messages fmt.Fprintln(c, msg.text) } h.mu.Unlock() @@ -58,32 +64,105 @@ func (h *Hub) Run() { } } +// sanitizeUsername enforces server rules on allowed usernames. +// - letters, digits, '_', '-', '.' allowed +// - spaces converted to '_' +// - trimmed of leading/trailing '.', '_' or '-' +// - empty after sanitization is invalid +// - max length limited +func sanitizeUsername(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + const maxLen = 12 + var out []rune + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '_', r == '-', r == '.': + out = append(out, r) + case r == ' ': + out = append(out, '_') + default: + // skip everything else + } + if len(out) >= maxLen { + break + } + } + res := strings.Trim(string(out), "._-") + return res +} + func handleConn(h *Hub, c net.Conn) { defer func() { h.leaveCh <- c }() h.joinCh <- c - name := c.RemoteAddr().String() - fmt.Fprintf(c, "Welcome %s\n", name) - h.msgCh <- broadcast{text: fmt.Sprintf("[join] %s", name), exclude: c} + // Generate per-connection ID + id, err := gonanoid.Generate("ABCDEF0123456789", 6) + if err != nil || id == "" { + // Fallback to remote addr if generation fails + id = c.RemoteAddr().String() + } + + // Default username is server-controlled; not necessarily unique + defaultName := "user_" + strings.ToLower(id) + username := defaultName + + // Greet client and instruct on setting username + fmt.Fprintf(c, "Welcome %s (%s)\n", username, id) + fmt.Fprintln(c, "Use /name to set your username. Allowed: [A-Za-z0-9_.-] (spaces become _)") + // Announce join to others, exclude self + h.msgCh <- broadcast{text: fmt.Sprintf("[join] %s (%s)", username, id), exclude: c} scanner := bufio.NewScanner(c) + // Allow reasonably large lines scanner.Buffer(make([]byte, 0, 1024), 64*1024) + for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } + + // Commands if line == "/quit" { - break + break // unified leave handling below } - h.msgCh <- broadcast{text: fmt.Sprintf("%s: %s", name, line)} + if strings.HasPrefix(line, "/name ") { + desired := strings.TrimSpace(strings.TrimPrefix(line, "/name ")) + newName := sanitizeUsername(desired) + if newName == "" { + fmt.Fprintln(c, "[error] invalid username") + continue + } + if newName == username { + // No change + fmt.Fprintf(c, "[info] username unchanged: %s\n", username) + continue + } + old := username + username = newName + // Broadcast rename to everyone (including the renamer) + h.msgCh <- broadcast{text: fmt.Sprintf("[rename] %s (%s) -> %s", old, id, username)} + continue + } + + // Regular chat message + h.msgCh <- broadcast{text: fmt.Sprintf("%s (%s): %s", username, id, line)} } if err := scanner.Err(); err != nil { - log.Printf("read err from %s: %v", name, err) + log.Printf("read err from %s (%s): %v", username, id, err) } - h.msgCh <- broadcast{text: fmt.Sprintf("[leave] %s", name)} + + // Single, consistent leave announcement + h.msgCh <- broadcast{text: fmt.Sprintf("[leave] %s (%s)", username, id)} } +// startTCPServer starts a TCP chat server and never returns unless an error occurs. func startTCPServer(addr string) error { ln, err := net.Listen("tcp", addr) if err != nil {