feat(server): usernames, IDs, and commands; sanitize names; richer join/leave messages
- Add go-nanoid to generate 6-char per-connection IDs; fallback to RemoteAddr - Introduce sanitizeUsername with rules: [A-Za-z0-9_.-], spaces→_, trim ._-, max 12 chars - Support /name <username> to change username with validation and broadcast [rename] - Greet client with default username user_<id> and usage hint for /name - Broadcast joins/leaves and messages including username and ID; exclude self on join - Handle /quit to trigger unified leave flow; consistent leave announcement - Comment/document broadcast struct, Hub, and startTCPServer - Increase scanner buffer and add newline-delimited send in broadcaster
This commit is contained in:
parent
b80c5190a3
commit
2c884d030a
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
95
server.go
95
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 <username> 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 err := scanner.Err(); err != nil {
|
||||
log.Printf("read err from %s: %v", name, err)
|
||||
if newName == username {
|
||||
// No change
|
||||
fmt.Fprintf(c, "[info] username unchanged: %s\n", username)
|
||||
continue
|
||||
}
|
||||
h.msgCh <- broadcast{text: fmt.Sprintf("[leave] %s", name)}
|
||||
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 (%s): %v", username, id, err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user