Initial Commit

This commit is contained in:
Aadi Desai 2024-01-14 17:59:51 +00:00
commit 6a94af7daf
Signed by: supleed2
SSH key fingerprint: SHA256:CkbNRs0yVzXEiUp2zd0PSxsfRUMFF9bLlKXtE1xEbKM
6 changed files with 838 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -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

334
client/main.go Normal file
View file

@ -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 <string>
set your nick
ls
get the available rooms
cd <string>
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
}

25
common/types.go Normal file
View file

@ -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
}

32
go.mod Normal file
View file

@ -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
)

64
go.sum Normal file
View file

@ -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=

359
server/main.go Normal file
View file

@ -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
}