commit 6a94af7daf32e4abd3813a5710257aee5d31e967 Author: Aadi Desai <21363892+supleed2@users.noreply.github.com> Date: Sun Jan 14 17:59:51 2024 +0000 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e3876d --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ + +# Go workspace file +go.work + +# Project specific files +nicks.json diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..b3e8f1e --- /dev/null +++ b/client/main.go @@ -0,0 +1,334 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + c "go-chat/common" + + "github.com/alexflint/go-arg" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + ws "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" +) + +const manText string = `The current available commands are: + man + prints this message + mv + set your nick + ls + get the available rooms + cd + connect to a room + who + list users in the current room + moo + :)` + +const mooText string = ` + (__) + (oo) + /------\/ + / | || + * /\---/\ + ~~ ~~ +..."Have you mooed today?"...` + +type showTim int + +const ( + off showTim = iota + short + full +) + +type model struct { + history viewport.Model + msgs []c.SMsg + showTim showTim + tz time.Location + input textinput.Model + idStyle lipgloss.Style + pStyle lipgloss.Style + help help.Model + recvCh chan c.SMsg + sendCh chan c.CMsg + conn *ws.Conn + exitCh chan exit +} + +type args struct { + Address string `arg:"positional" default:"localhost:8000" help:"address to connect to, without ws://" placeholder:"HOST[:PORT]"` + Timestamps showTim `arg:"-t" default:"off" help:"display timestamps of messages, ctrl+t to cycle after startup [off, short, full]" placeholder:"CHOICE"` + Nick *string `arg:"-n" help:"attempt to automatically set nick after connecting"` + Password *string `arg:"-p" help:"password, if required"` +} + +func main() { + ctx := context.Background() + + var a args + arg.MustParse(&a) + + conn, _, err := ws.Dial(ctx, "ws://"+a.Address, nil) + if err != nil { + log.Fatal(err) + } + defer conn.Close(ws.StatusNormalClosure, "") + + local, err := time.LoadLocation("Local") + if err != nil { + log.Fatal(err) + } + + p := tea.NewProgram(initModel(ctx, conn, a, *local), tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} + +func initModel(ctx context.Context, conn *ws.Conn, a args, tz time.Location) model { + discCtx, disc := context.WithCancel(ctx) + exitCh := make(chan exit) + + recvCh := make(chan c.SMsg) + go func() { + smsg := c.SMsg{} + for { + err := wsjson.Read(ctx, conn, &smsg) + if err != nil { + if ws.CloseStatus(err) != ws.StatusNormalClosure { + log.Println(err) + } + disc() + return + } + recvCh <- smsg + } + }() + + sendCh := make(chan c.CMsg) + go func() { + for { + select { + case cmsg := <-sendCh: + err := wsjson.Write(ctx, conn, cmsg) + if err != nil { + recvCh <- c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("wsjson error when sending message: %v", err)} + } + case <-discCtx.Done(): + exitCh <- exit{} + return + } + } + }() + + ta := textinput.New() + ta.Placeholder = "Send a message (or a command with /)" + ta.Focus() + ta.CharLimit = 128 + ta.Width = 60 + + vp := viewport.New(60, 5) + vp.KeyMap = viewport.KeyMap{ + PageDown: key.NewBinding( + key.WithKeys("pgdown"), + key.WithHelp("pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("pgup", "page up"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + } + + if a.Nick != nil { + login := *a.Nick + if a.Password != nil { + login += ":" + *a.Password + } + sendCh <- c.CMsg{Typ: c.Mv, Msg: login} + } + + messages := []c.SMsg{{Tim: time.Now(), Id: "system", Msg: "Welcome to the chat room! Press Enter to send, /man for more info :)"}} + + return model{ + input: ta, + msgs: messages, + showTim: a.Timestamps, + tz: tz, + history: vp, + idStyle: lipgloss.NewStyle().Width(30), + pStyle: lipgloss.NewStyle().Bold(true), + help: help.New(), + recvCh: recvCh, + sendCh: sendCh, + conn: conn, + exitCh: exitCh, + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + tea.SetWindowTitle("go-chat by 8bit"), + textinput.Blink, + getNextSMsg(m.recvCh), + getExitMsg(m.exitCh), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var tiCmd, vpCmd, clCmd, smCmd tea.Cmd + m.input, tiCmd = m.input.Update(msg) + m.history, vpCmd = m.history.Update(msg) + + switch msg := msg.(type) { + case exit: + return m, tea.Quit + case c.SMsg: + m.msgs = append(m.msgs, msg) + m.history.SetContent(m.viewMessages()) + m.history.GotoBottom() + smCmd = getNextSMsg(m.recvCh) + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyCtrlT: + m.showTim = (m.showTim + 1) % 3 + m.history.SetContent(m.viewMessages()) + case tea.KeyEnter: + text := strings.TrimSpace(m.input.Value()) + if text, ok := strings.CutPrefix(text, "/"); ok { + if text == "man" { + m.recvCh <- c.SMsg{Tim: time.Now(), Id: "system", Msg: manText} + } else if text, ok := strings.CutPrefix(text, "mv "); ok { + m.sendCh <- c.CMsg{Typ: c.Mv, Msg: text} + } else if text == "ls" { + m.sendCh <- c.CMsg{Typ: c.Ls, Msg: ""} + } else if text, ok := strings.CutPrefix(text, "cd "); ok { + m.sendCh <- c.CMsg{Typ: c.Cd, Msg: text} + } else if text == "who" { + m.sendCh <- c.CMsg{Typ: c.Who, Msg: ""} + } else if text, ok := strings.CutPrefix(text, "sudo "); ok { + m.sendCh <- c.CMsg{Typ: c.Sudo, Msg: text} + } else if text == "moo" { + m.recvCh <- c.SMsg{Tim: time.Now(), Id: "cow", Msg: mooText} + } else { + m.recvCh <- c.SMsg{Tim: time.Now(), Id: "system", Msg: "Unrecognised command, use /man for more info"} + } + } else if text != "" { + m.sendCh <- c.CMsg{Typ: c.Echo, Msg: text} + } + m.input.Reset() + } + case tea.WindowSizeMsg: + clCmd = tea.Sequence(tea.ExitAltScreen, tea.EnterAltScreen) + m.history.Height = msg.Height - 2 + m.history.Width = msg.Width + m.history.GotoBottom() + m.input.Width = msg.Width - 3 + m.idStyle.Width(msg.Width) + m.help.Width = msg.Width - 1 + m.history.SetContent(m.viewMessages()) + } + + return m, tea.Batch(tiCmd, vpCmd, clCmd, smCmd) +} + +func (m model) View() string { + return fmt.Sprintf( + "%s\n%s\n%s", + m.history.View(), + m.input.View(), + m.help.View(m), + ) +} + +func (m model) ShortHelp() []key.Binding { + return []key.Binding{ + m.history.KeyMap.PageDown, + m.history.KeyMap.PageUp, + m.history.KeyMap.Down, + m.history.KeyMap.Up, + key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "toggle timestamps"), + ), + } +} + +func (m model) FullHelp() [][]key.Binding { + return nil +} + +func (m model) viewMessages() string { + s := "" + for i := range m.msgs { + prefix := "" + if m.showTim == short { + prefix += m.msgs[i].Tim.In(&m.tz).Format(time.TimeOnly) + " " + } else if m.showTim == full { + prefix += m.msgs[i].Tim.In(&m.tz).Format(time.DateTime) + " " + } + if m.msgs[i].Id == "system" { + prefix += m.pStyle.Foreground(lipgloss.Color("201")).Render("system:") + } else { + prefix += m.pStyle.Foreground(lipgloss.Color(prefixColor(m.msgs[i].Id))).Render(m.msgs[i].Id + ":") + } + s += m.idStyle.SetString(prefix).Render(m.msgs[i].Msg) + "\n" + } + return s[:len(s)-1] +} + +func prefixColor(s string) string { + if len(s) == 0 { + s = "missing" + } + return fmt.Sprint(uint(s[0]+s[len(s)-1]) % 8) +} + +func getNextSMsg(c <-chan c.SMsg) tea.Cmd { + return func() tea.Msg { + return <-c + } +} + +type exit struct{} + +func getExitMsg(c <-chan exit) tea.Cmd { + return func() tea.Msg { + return <-c + } +} + +func (st *showTim) UnmarshalText(b []byte) error { + s := string(b) + switch s { + case "off": + *st = off + case "short": + *st = short + case "full": + *st = full + default: + return fmt.Errorf("invalid choice: %s [off, short, full]", s) + } + return nil +} diff --git a/common/types.go b/common/types.go new file mode 100644 index 0000000..26921fe --- /dev/null +++ b/common/types.go @@ -0,0 +1,25 @@ +package common + +import "time" + +type SMsg struct { + Tim time.Time + Id string + Msg string +} + +type CMsgT int + +const ( + Sudo CMsgT = iota + Echo + Mv + Ls + Cd + Who +) + +type CMsg struct { + Typ CMsgT + Msg string +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ffee524 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module go-chat + +go 1.21.5 + +require ( + github.com/alexflint/go-arg v1.4.3 + github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + nhooyr.io/websocket v1.8.10 +) + +require ( + github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/lucasb-eyer/go-colorful v1.2.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.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/stretchr/testify v1.8.4 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dc8642e --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= +github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= +github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= +github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= +nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..cbbd243 --- /dev/null +++ b/server/main.go @@ -0,0 +1,359 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "time" + + c "go-chat/common" + + "github.com/alexflint/go-arg" + ws "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" +) + +type user struct { + room string + nick string +} + +type conns struct { + sm sync.Mutex + cm map[*ws.Conn]user +} + +type server struct { + admin string + logFn func(string, ...interface{}) + conns *conns + rooms map[string]struct{} + rhist map[string][]c.SMsg + rhlen int + nickm map[string]string +} + +func main() { + log := log.New(os.Stderr, "ws server 🚀 ", log.LstdFlags|log.Lshortfile|log.Lmsgprefix) + + var args struct { + Admin string `arg:"-a" default:"8bit" help:"admin user nick, allows access to /sudo" placeholder:"NICK"` + HistLen uint `arg:"-h" default:"10" help:"set message history size" placeholder:"N"` + Port uint `arg:"positional" default:"0" help:"port to listen on, random available port if not set"` + NickMap *string `arg:"-n" help:"path to nick:pass JSON file" placeholder:"FILE"` + } + arg.MustParse(&args) + + nickMap, err := loadNickMap(args.NickMap) + if err != nil { + log.Fatal(err) + } + + err = run("localhost:"+fmt.Sprint(args.Port), nickMap, args.Admin, int(args.HistLen), log) + if err != nil { + log.Fatal(err) + } +} + +func run(addr string, nickMap map[string]string, admin string, rhlen int, log *log.Logger) error { + listener, err := net.Listen("tcp4", addr) + if err != nil { + return err + } + + log.Printf("listening on ws://%v", listener.Addr()) + + server := &http.Server{ + Handler: server{ + admin: admin, + logFn: log.Printf, + conns: &conns{cm: make(map[*ws.Conn]user)}, + rooms: map[string]struct{}{"general": {}, "test1": {}, "test2": {}}, + rhist: make(map[string][]c.SMsg), + rhlen: rhlen, + nickm: nickMap, + }, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + errch := make(chan error, 1) + go func() { + errch <- server.Serve(listener) + }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + select { + case err := <-errch: + log.Printf("failed to serve: %v", err) + case signal := <-signals: + log.Printf("quitting: %v", signal) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return server.Shutdown(ctx) +} + +func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + conn, err := ws.Accept(w, r, nil) + if err != nil { + s.logFn("%v", err) + return + } + defer conn.CloseNow() + + if conn.Subprotocol() != "" { + return + } + + port := strings.Split(r.RemoteAddr, ":")[1] + s.conns.sm.Lock() + s.conns.cm[conn] = user{room: "general", nick: port} + s.conns.sm.Unlock() + defer func() { + s.conns.sm.Lock() + delete(s.conns.cm, conn) + s.logFn("Remaining connections: %v", len(s.conns.cm)) + s.conns.sm.Unlock() + }() + + s.logFn("connected: %v", r.RemoteAddr) + for i := range s.rhist["general"] { + wsjson.Write(ctx, conn, s.rhist["general"][i]) + } + cmsg := c.CMsg{} + smsg := c.SMsg{Id: port} + for { + err := func(ctx context.Context, conn *ws.Conn) error { + err := wsjson.Read(ctx, conn, &cmsg) + if err != nil { + return err + } + + switch cmsg.Typ { + case c.Sudo: + s.logFn("(%v) sudo: %v", smsg.Id, cmsg.Msg) + if smsg.Id == s.admin { + cmd := strings.Split(cmsg.Msg, " ") + if len(cmd) == 2 { + if cmd[0] == "mk" { + if _, ok := s.rooms[cmd[1]]; ok { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Room exists: %v", cmd)}) + } else { + s.rooms[cmd[1]] = struct{}{} + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Created room: %v", cmd)}) + } + } else if cmd[0] == "rm" { + if _, ok := s.rooms[cmd[1]]; ok && cmd[1] != "general" { + delete(s.rooms, cmd[1]) + s.rhist[cmd[1]] = []c.SMsg{} + tim := time.Now() + s.conns.sm.Lock() + for cn, r := range s.conns.cm { + if r.room == cmd[1] { + r.room = "general" + s.conns.cm[cn] = r + wsjson.Write(ctx, cn, c.SMsg{Tim: tim, Id: "system", Msg: "room deleted, reconnected to general"}) + } + } + s.conns.sm.Unlock() + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Deleted room: %v", cmd)}) + } else { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Room does not exist: %v", cmd)}) + } + } else if cmd[0] == "yeet" { + found := false + s.conns.sm.Lock() + for c, r := range s.conns.cm { + if r.nick == cmd[1] { + c.Close(ws.StatusNormalClosure, "Kicked") + found = true + break + } + } + s.conns.sm.Unlock() + if found { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Yeet: %v", cmd[1])}) + } else { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Not found: %v", cmd[1])}) + } + } else { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Invalid command: %v", cmd)}) + } + } else if cmd[0] == "wc" { + s.conns.sm.Lock() + wc := len(s.conns.cm) + s.conns.sm.Unlock() + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Online: %v", wc)}) + } else if cmd[0] == "man" { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: "Available commands: man, mk, rm, wc, yeet"}) + } else { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("Invalid command: %v", cmd)}) + } + } else { + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: "Unrecognised command, use /man for more info"}) + } + case c.Echo: + s.logFn("(%v) echo: %v", smsg.Id, cmsg.Msg) + s.conns.sm.Lock() + room := s.conns.cm[conn].room + s.conns.sm.Unlock() + smsg.Tim = time.Now() + smsg.Msg = cmsg.Msg + if len(s.rhist[room]) < s.rhlen { + s.rhist[room] = append(s.rhist[room], smsg) + } else { + s.rhist[room] = append(s.rhist[room][1:], smsg) + } + s.conns.sm.Lock() + for c, r := range s.conns.cm { + if r.room == room { + wsjson.Write(ctx, c, &smsg) + } + } + s.conns.sm.Unlock() + case c.Mv: + switch nick, valid := verifyNick(&s, cmsg.Msg); valid { + case nickOk: + s.logFn("(%v) mv: %v", smsg.Id, cmsg.Msg) + smsg.Id = nick + s.conns.sm.Lock() + u := s.conns.cm[conn] + u.nick = smsg.Id + s.conns.cm[conn] = u + s.conns.sm.Unlock() + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("nick set: %v", nick)}) + case nickUsed: + s.logFn("(%v) mv used: %v", smsg.Id, cmsg.Msg) + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("nick in use: %v", cmsg.Msg)}) + case nickInvalid: + s.logFn("(%v) mv invalid: %v", smsg.Id, cmsg.Msg) + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("invalid nick: %v", cmsg.Msg)}) + } + case c.Ls: + s.logFn("(%v) ls", smsg.Id) + s.conns.sm.Lock() + room := s.conns.cm[conn].room + s.conns.sm.Unlock() + avRooms := "" + for r := range s.rooms { + avRooms += r + ", " + } + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("connected to: %v, available: %v", room, avRooms[:len(avRooms)-2])}) + case c.Cd: + if _, ok := s.rooms[cmsg.Msg]; ok { + s.logFn("(%v) cd: %v", smsg.Id, cmsg.Msg) + s.conns.sm.Lock() + u := s.conns.cm[conn] + u.room = cmsg.Msg + s.conns.cm[conn] = u + s.conns.sm.Unlock() + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("connected to: %v", u.room)}) + rhistSize := max(len(s.rhist[u.room])-10, 0) + recentHistory := s.rhist[u.room][rhistSize:] + for i := range recentHistory { + wsjson.Write(ctx, conn, recentHistory[i]) + } + } else { + s.logFn("(%v) cd invalid: %v", smsg.Id, cmsg.Msg) + wsjson.Write(ctx, conn, c.SMsg{Tim: time.Now(), Id: "system", Msg: fmt.Sprintf("unchanged, invalid room: %v", cmsg.Msg)}) + } + case c.Who: + s.conns.sm.Lock() + room := s.conns.cm[conn].room + s.logFn("(%v) who: %v", smsg.Id, room) + users := fmt.Sprintf("users in %v: ", room) + for _, r := range s.conns.cm { + if r.room == room { + users += fmt.Sprintf("%v, ", r.nick) + } + } + s.conns.sm.Unlock() + wsjson.Write(ctx, conn, &c.SMsg{Tim: time.Now(), Id: "system", Msg: users[:len(users)-2]}) + } + return nil + }(ctx, conn) + + if ws.CloseStatus(err) == ws.StatusNormalClosure { + s.logFn("disconnected: %v", r.RemoteAddr) + return + } + if err != nil { + s.logFn("failed, addr %v: %v", r.RemoteAddr, err) + return + } + } +} + +type nickErr int + +const ( + nickOk nickErr = iota + nickUsed + nickInvalid +) + +func verifyNick(s *server, n string) (string, nickErr) { + nick, pass, _ := strings.Cut(n, ":") + + s.conns.sm.Lock() + defer s.conns.sm.Unlock() + for _, u := range s.conns.cm { + if u.nick == nick { + return "", nickUsed + } + } + + expPass, needAuth := s.nickm[nick] + + if (!needAuth || pass == expPass) && alphanumeric(nick) { + return nick, nickOk + } else { + return "", nickInvalid + } +} + +func loadNickMap(m *string) (map[string]string, error) { + nm := make(map[string]string) + if m == nil { + return nm, nil + } + + path, err := filepath.Abs(*m) + if err != nil { + return nil, err + } + + file, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + err = json.Unmarshal(file, &nm) + if err != nil { + return nil, err + } + + return nm, nil +} + +func alphanumeric(s string) bool { + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') { + return false + } + } + return true +}