diff --git a/.gitignore b/.gitignore index 63e3778..e617b81 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ !cmd/ !pkg/ !pkg/api/ +!pkg/chat/ !pkg/filesystem/ !pkg/game/ !pkg/game/argument/ diff --git a/cmd/server.go b/cmd/server.go index 2a92130..f5615f8 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -50,16 +50,19 @@ func main() { handler := handler.NewHandler(&settings). WithGamehandler(). WithUserhandler(errCtx, errgrp). - WithDownloadHandler() + WithDownloadHandler(). + WithChatHandler() log.Trace().Msg("handler created") gameRoutes := router.GameRoutes(handler) userRoutes := router.UserRoutes(handler) downloadRoutes := router.DownloadRoutes(handler) + chatRoutes := router.ChatRoutes(handler) log.Trace().Msg("routes created") router := router.NewRouter(). WithRoutes(gameRoutes). WithRoutes(userRoutes). - WithRoutes(downloadRoutes) + WithRoutes(downloadRoutes). + WithRoutes(chatRoutes) log.Trace().Msg("router created") listener, err := net.Listen("tcp4", ":"+strconv.Itoa(settings.ServerPort)) diff --git a/go.mod b/go.mod index a405fa5..7bfdb3b 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.3 github.com/rs/zerolog v1.31.0 golang.org/x/sync v0.6.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 diff --git a/go.sum b/go.sum index 455f5a6..6374516 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= diff --git a/pkg/api/chatservice.go b/pkg/api/chatservice.go new file mode 100644 index 0000000..ef8a6d4 --- /dev/null +++ b/pkg/api/chatservice.go @@ -0,0 +1,98 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/seternate/go-lanty/pkg/chat" +) + +type ChatService struct { + client *Client + ws *websocket.Conn + + Messages chan chat.Message + Error error +} + +// If client.baseURL is changed while being connected a Reconnect for this service has to be made +func (service *ChatService) Connect() (resp *http.Response, err error) { + defer func() { service.Error = err }() + path, err := service.client.router.Get("Chat").URLPath() + if err != nil { + return + } + path.Scheme = "ws" + url := service.client.buildURL(*path) + service.ws, resp, err = websocket.DefaultDialer.Dial((&url).String(), nil) + if err != nil { + err = fmt.Errorf("error connecting to server chat websocket: %w", err) + return + } + service.Error = nil + go service.run() + return +} + +func (service *ChatService) Disconnect() (err error) { + if service.ws == nil { + return + } + err = service.ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + if err != nil { + service.Error = err + } + return +} + +func (service *ChatService) Reconnect() (err error) { + defer func() { service.Error = err }() + err = service.Disconnect() + _, err = service.Connect() + return +} + +func (service *ChatService) SendMessage(message chat.Message) (err error) { + if service.ws == nil { + return fmt.Errorf("can not send chat message: %w", service.Error) + } + message.SetTime(time.Now()) + err = service.ws.WriteJSON(message) + if err != nil { + service.Error = err + } + return +} + +func (service *ChatService) run() { + for service.Error == nil && service.ws != nil { + messageType, reader, err := service.ws.NextReader() + if err != nil { + service.Error = fmt.Errorf("%w: permanent error reading from client websocket", err) + service.Disconnect() + return + } + if messageType == websocket.BinaryMessage { + continue + } + + message, err := chat.ReadMessage(reader) + if err != nil { + continue + } + if len(strings.TrimSpace(message.GetMessage())) == 0 { + continue + } + if len(strings.TrimSpace(message.GetUser().Name)) == 0 || len(strings.TrimSpace(message.GetUser().IP)) == 0 { + continue + } + if message.GetTime().IsZero() { + continue + } + + service.Messages <- message + } +} diff --git a/pkg/api/client.go b/pkg/api/client.go index 3946046..baf0a85 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -6,6 +6,7 @@ import ( "net/url" "time" + "github.com/seternate/go-lanty/pkg/chat" "github.com/seternate/go-lanty/pkg/router" ) @@ -16,6 +17,7 @@ type Client struct { Game *GameService User *UserService + Chat *ChatService } func NewClient(baseURL string, timeout time.Duration) (client *Client, err error) { @@ -25,7 +27,8 @@ func NewClient(baseURL string, timeout time.Duration) (client *Client, err error router := router.NewRouter(). WithRoutes(router.GameRoutes(nil)). - WithRoutes(router.UserRoutes(nil)) + WithRoutes(router.UserRoutes(nil)). + WithRoutes(router.ChatRoutes(nil)) client = &Client{ httpclient: httpclient, @@ -34,6 +37,10 @@ func NewClient(baseURL string, timeout time.Duration) (client *Client, err error err = client.SetBaseURL(baseURL) client.Game = &GameService{client: client} client.User = &UserService{client: client} + client.Chat = &ChatService{ + client: client, + Messages: make(chan chat.Message, 100), + } return } @@ -49,8 +56,10 @@ func (c *Client) SetBaseURL(baseURL string) (err error) { return } -func (c *Client) BuildURL(url url.URL) url.URL { - url.Scheme = "http" +func (c *Client) buildURL(url url.URL) url.URL { + if len(url.Scheme) == 0 { + url.Scheme = c.baseURL.Scheme + } url.Host = c.baseURL.Host return url } diff --git a/pkg/api/gameservice.go b/pkg/api/gameservice.go index fe80a2b..a0b13b2 100644 --- a/pkg/api/gameservice.go +++ b/pkg/api/gameservice.go @@ -25,7 +25,7 @@ func (service *GameService) GetGames() (games []string, err error) { if err != nil { return } - request, err := service.client.newRESTRequest(http.MethodGet, service.client.BuildURL(*path), nil, nil) + request, err := service.client.newRESTRequest(http.MethodGet, service.client.buildURL(*path), nil, nil) if err != nil { return } @@ -54,7 +54,7 @@ func (service *GameService) GetGame(slug string) (game game.Game, err error) { if err != nil { return } - request, err := service.client.newRESTRequest(http.MethodGet, service.client.BuildURL(*path), nil, nil) + request, err := service.client.newRESTRequest(http.MethodGet, service.client.buildURL(*path), nil, nil) if err != nil { return } @@ -80,7 +80,7 @@ func (service *GameService) GetIcon(game game.Game) (image image.Image, err erro return } - download, err := network.NewDownload(service.client.BuildURL(*path)) + download, err := network.NewDownload(service.client.buildURL(*path)) if err != nil { return } @@ -107,7 +107,7 @@ func (service *GameService) Download(ctx context.Context, game game.Game, direct return } - download, err = network.NewDownload(service.client.BuildURL(*path)) + download, err = network.NewDownload(service.client.buildURL(*path)) if err != nil { return } diff --git a/pkg/api/userservice.go b/pkg/api/userservice.go index 5a0d350..e4dae6d 100644 --- a/pkg/api/userservice.go +++ b/pkg/api/userservice.go @@ -18,7 +18,7 @@ func (service *UserService) GetUsers() (users []string, err error) { if err != nil { return } - request, err := service.client.newRESTRequest(http.MethodGet, service.client.BuildURL(*path), nil, nil) + request, err := service.client.newRESTRequest(http.MethodGet, service.client.buildURL(*path), nil, nil) if err != nil { return } @@ -43,7 +43,7 @@ func (service *UserService) GetUser(ip string) (user user.User, err error) { if err != nil { return } - request, err := service.client.newRESTRequest(http.MethodGet, service.client.BuildURL(*path), nil, nil) + request, err := service.client.newRESTRequest(http.MethodGet, service.client.buildURL(*path), nil, nil) if err != nil { return } @@ -73,7 +73,7 @@ func (service *UserService) CreateNewUser(user user.User) (u user.User, err erro if err != nil { return } - request, err := service.client.newRESTRequest(http.MethodPost, service.client.BuildURL(*path), nil, bytes.NewReader(userjson)) + request, err := service.client.newRESTRequest(http.MethodPost, service.client.buildURL(*path), nil, bytes.NewReader(userjson)) if err != nil { return } diff --git a/pkg/chat/message.go b/pkg/chat/message.go new file mode 100644 index 0000000..d638508 --- /dev/null +++ b/pkg/chat/message.go @@ -0,0 +1,49 @@ +package chat + +import ( + "encoding/json" + "fmt" + "io" + "time" + + "github.com/seternate/go-lanty/pkg/user" +) + +type Message interface { + GetType() Type + GetUser() user.User + GetMessage() string + GetTime() time.Time + SetTime(time.Time) +} + +type tmpMessage struct { + Type Type `json:"type"` + User user.User `json:"user"` + Time time.Time `json:"time"` +} + +func ReadMessage(r io.Reader) (Message, error) { + tmpMessageJson, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + tmpMessage := &tmpMessage{} + err = json.Unmarshal(tmpMessageJson, tmpMessage) + if err == io.EOF { + err = io.ErrUnexpectedEOF + return nil, err + } + + switch tmpMessage.Type { + case TYPE_TEXT: + textmessage := &TextMessage{} + err = json.Unmarshal(tmpMessageJson, textmessage) + if err != nil { + return nil, err + } + return textmessage, err + } + return nil, fmt.Errorf("undefined message type: %s", tmpMessage.Type.String()) +} diff --git a/pkg/chat/textmessage.go b/pkg/chat/textmessage.go new file mode 100644 index 0000000..1c5db0b --- /dev/null +++ b/pkg/chat/textmessage.go @@ -0,0 +1,47 @@ +package chat + +import ( + "time" + + "github.com/seternate/go-lanty/pkg/user" +) + +type TextMessage struct { + Type Type `json:"type"` + User user.User `json:"user"` + Message textMessageText `json:"message"` + Time time.Time `json:"time"` +} + +type textMessageText struct { + Text string `json:"text"` +} + +func NewTextMessage(user user.User, message string) *TextMessage { + return &TextMessage{ + Type: TYPE_TEXT, + User: user, + Message: textMessageText{message}, + Time: time.Now(), + } +} + +func (message *TextMessage) GetType() Type { + return message.Type +} + +func (message *TextMessage) GetUser() user.User { + return message.User +} + +func (message *TextMessage) GetMessage() string { + return message.Message.Text +} + +func (message *TextMessage) GetTime() time.Time { + return message.Time +} + +func (message *TextMessage) SetTime(t time.Time) { + message.Time = t +} diff --git a/pkg/chat/type.go b/pkg/chat/type.go new file mode 100644 index 0000000..ccf47ba --- /dev/null +++ b/pkg/chat/type.go @@ -0,0 +1,49 @@ +package chat + +import ( + "encoding/json" + "errors" + "fmt" +) + +func TypeFromString(s string) (Type, error) { + switch s { + case TYPE_TEXT.slug: + return TYPE_TEXT, nil + } + return TYPE_UNDEFINED, errors.New("unknown type: " + s) +} + +var ( + TYPE_UNDEFINED = Type{""} + TYPE_TEXT = Type{"text"} +) + +type Type struct { + slug string +} + +func (t Type) String() string { + return t.slug +} + +func (t *Type) UnmarshalJSON(data []byte) (err error) { + // Ignore null, like in the main JSON package. + if string(data) == "null" || string(data) == `""` { + return nil + } + var field string + err = json.Unmarshal(data, &field) + if err != nil { + return + } + *t, err = TypeFromString(field) + if err != nil { + err = fmt.Errorf("%w: %w", errors.New("error unmarshal json"), err) + } + return +} + +func (t Type) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} diff --git a/pkg/handler/chat.go b/pkg/handler/chat.go new file mode 100644 index 0000000..f502275 --- /dev/null +++ b/pkg/handler/chat.go @@ -0,0 +1,97 @@ +package handler + +import ( + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog/log" + "github.com/seternate/go-lanty/pkg/chat" +) + +type ChatHandler struct { + parent *Handler + clients map[*websocket.Conn]bool + upgrader websocket.Upgrader + broadcaster chan chat.Message + mutex sync.RWMutex +} + +func NewChatHandler(parent *Handler) (handler *ChatHandler) { + handler = &ChatHandler{ + parent: parent, + clients: make(map[*websocket.Conn]bool), + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + broadcaster: make(chan chat.Message, 100), + } + go handler.run() + return +} + +func (handler *ChatHandler) Chat(w http.ResponseWriter, req *http.Request) { + client, err := handler.upgrader.Upgrade(w, req, nil) + if err != nil { + log.Error().Err(err).Msg("failed to upgrade incoming websocket connection") + return + } + defer client.Close() + log.Debug().Str("remoteAddress", client.RemoteAddr().String()).Msg("successfully upgraded incoming websocket connection") + + handler.mutex.Lock() + handler.clients[client] = true + handler.mutex.Unlock() + + for { + messageType, reader, err := client.NextReader() + if err != nil { + handler.mutex.Lock() + delete(handler.clients, client) + handler.mutex.Unlock() + log.Error().Err(err).Str("remoteAddress", client.RemoteAddr().String()).Msg("permanent error reading from client websocket") + client.Close() + return + } + if messageType == websocket.BinaryMessage { + log.Warn().Str("remoteAddress", client.RemoteAddr().String()).Msg("ignoring message from /chat websocket connection due to BinaryMessage") + continue + } + + message, err := chat.ReadMessage(reader) + if err != nil { + log.Warn().Err(err).Str("remoteAddress", client.RemoteAddr().String()).Msg("ignoring message due to error reading chat message") + continue + } + if len(strings.TrimSpace(message.GetMessage())) == 0 { + log.Warn().Str("remoteAddress", client.RemoteAddr().String()).Msg("ignoring message due to empty body") + continue + } + if len(strings.TrimSpace(message.GetUser().Name)) == 0 || len(strings.TrimSpace(message.GetUser().IP)) == 0 { + log.Warn().Str("remoteAddress", client.RemoteAddr().String()).Interface("user", message.GetUser()).Msg("ignoring message due to malformed user") + continue + } + if message.GetTime().IsZero() { + log.Warn().Str("remoteAddress", client.RemoteAddr().String()).Msg("added missing timestamp to received message") + message.SetTime(time.Now()) + } + + log.Debug().Str("remoteAddress", client.RemoteAddr().String()).Msg("broadcasting newly received message") + handler.broadcaster <- message + } +} + +func (handler *ChatHandler) run() { + for message := range handler.broadcaster { + handler.mutex.RLock() + clients := handler.clients + handler.mutex.RUnlock() + for client := range clients { + client.WriteJSON(message) + } + } +} diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 1d54a93..8cc28c8 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -14,14 +14,12 @@ type Handler struct { Gamehandler *Gamehandler Userhandler *Userhandler Downloadhandler *Downloadhandler + Chathandler *ChatHandler } func NewHandler(settings *setting.Settings) *Handler { return &Handler{ - Setting: settings, - Gamehandler: nil, - Userhandler: nil, - Downloadhandler: nil, + Setting: settings, } } @@ -44,3 +42,8 @@ func (handler *Handler) WithDownloadHandler() *Handler { handler.Downloadhandler = NewDownloadHandler(handler) return handler } + +func (handler *Handler) WithChatHandler() *Handler { + handler.Chathandler = NewChatHandler(handler) + return handler +} diff --git a/pkg/router/chat.go b/pkg/router/chat.go new file mode 100644 index 0000000..424c6f2 --- /dev/null +++ b/pkg/router/chat.go @@ -0,0 +1,23 @@ +package router + +import ( + "github.com/rs/zerolog/log" + "github.com/seternate/go-lanty/pkg/handler" +) + +func ChatRoutes(handler *handler.Handler) (routes Routes) { + routes = Routes{ + "Chat": Route{"Chat", "GET", "/chat", nil}, + } + + if handler == nil || handler.Chathandler == nil { + log.Trace().Msg("no HandlerFunc added to routes for chat") + return + } + + routes.UpdateHandlerFunc("Chat", handler.Chathandler.Chat) + + log.Trace().Msg("HandlerFunc added to routes for chat") + + return +}