From a1b0d2e96570943e40ca96b5d2feba6b06d6281e Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 30 Sep 2023 12:32:09 -0700 Subject: [PATCH] Blocklist improvements + WebSocket timeout tweak --- Protocol.md | 6 ++- pkg/api.go | 113 +++++++++++++++++++++++++++++++++++++++++ pkg/handlers.go | 3 +- pkg/message_filters.go | 7 +++ pkg/server.go | 1 + pkg/websocket.go | 2 +- src/App.vue | 25 ++++++++- 7 files changed, 153 insertions(+), 4 deletions(-) diff --git a/Protocol.md b/Protocol.md index ffe0907..8d652c9 100644 --- a/Protocol.md +++ b/Protocol.md @@ -345,7 +345,7 @@ The `unmute` action does the opposite and removes the mute status: ## Block -Sent by: Client. +Sent by: Client, Server. The block command places a hard block between the current user and the target. @@ -364,6 +364,10 @@ When either user blocks the other: } ``` +The server may send a "block" message to the client in response to the BlockNow API endpoint: your main website can communicate that a block was just added, so if either user is currently in chat the block can apply immediately instead of at either user's next re-join of the room. + +The server "block" message follows the same format, having the username of the other party. + ## Blocklist Sent by: Client. diff --git a/pkg/api.go b/pkg/api.go index b0ca0da..dbc37d8 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -354,6 +354,119 @@ func (s *Server) BlockList() http.HandlerFunc { }) } +// BlockNow (/api/block/now) allows your website to add to a current online chatter's +// blocked list immediately. +// +// For example: the BlockList endpoint does a bulk sync of the blocklist at the time +// a user joins the chat room, but if users are already on chat when the blocking begins, +// it doesn't take effect until one or the other re-joins the room. This API endpoint +// can apply the blocking immediately to the currently online users. +// +// It is a POST request with a json body containing the following schema: +// +// { +// "APIKey": "from settings.toml", +// "Usernames": [ "source", "target" ] +// } +// +// The pair of usernames will be the two users who block one another (in any order). +// If any of the users are currently connected to the chat, they will all mutually +// block one another immediately. +func (s *Server) BlockNow() http.HandlerFunc { + type request struct { + APIKey string + Usernames []string + } + + type result struct { + OK bool + Error string `json:",omitempty"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // JSON writer for the response. + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + // Parse the request. + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only POST methods allowed", + }) + return + } else if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only application/json content-types allowed", + }) + return + } + + defer r.Body.Close() + + // Parse the request payload. + var ( + params request + dec = json.NewDecoder(r.Body) + ) + if err := dec.Decode(¶ms); err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Validate the API key. + if params.APIKey != config.Current.AdminAPIKey { + w.WriteHeader(http.StatusUnauthorized) + enc.Encode(result{ + Error: "Authentication denied.", + }) + return + } + + // Check if any of these users are online, and update their blocklist accordingly. + var changed bool + for _, username := range params.Usernames { + if sub, err := s.GetSubscriber(username); err == nil { + for _, otherName := range params.Usernames { + if username == otherName { + continue + } + log.Info("BlockNow API: %s is currently on chat, add block for %+v", username, otherName) + + sub.muteMu.Lock() + sub.muted[otherName] = struct{}{} + sub.blocked[otherName] = struct{}{} + sub.muteMu.Unlock() + + // Changes have been made to online users. + changed = true + + // Send a server-side "block" command to the subscriber, so their front-end page might + // update the cachedBlocklist so there's no leakage in case of chat server rebooting. + sub.SendJSON(messages.Message{ + Action: messages.ActionBlock, + Username: otherName, + }) + } + } + } + + // If any changes to blocklists were made: send the Who List. + if changed { + s.SendWhoList() + } + + enc.Encode(result{ + OK: true, + }) + }) +} + // Blocklist cache sent over from your website. var ( // Map of username to the list of usernames they block. diff --git a/pkg/handlers.go b/pkg/handlers.go index 303e1e8..58ab1a0 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -179,6 +179,7 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) { // If the user is OP, just tell them we would. if sub.IsAdmin() { sub.ChatServer("Your recent chat context would have been reported to your main website.") + return } // Send the report to the main website. @@ -483,7 +484,7 @@ func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) { // OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website. func (s *Server) OnBlocklist(sub *Subscriber, msg messages.Message) { - log.Info("%s syncs their blocklist: %s", sub.Username, msg.Usernames) + log.Info("[%s] syncs their blocklist: %s", sub.Username, msg.Usernames) sub.muteMu.Lock() for _, username := range msg.Usernames { diff --git a/pkg/message_filters.go b/pkg/message_filters.go index f86f67e..e6e33e3 100644 --- a/pkg/message_filters.go +++ b/pkg/message_filters.go @@ -28,6 +28,13 @@ func (s *Server) filterMessage(sub *Subscriber, rawMsg messages.Message, msg *me if strings.HasPrefix(msg.Channel, "@") { // DM pushDirectMessageContext(sub, sub.Username, msg.Channel[1:], rawMsg) + + // If either party is an admin user, waive filtering this DM chat. + if sub.IsAdmin() { + return nil, false + } else if other, err := s.GetSubscriber(msg.Channel[1:]); err == nil && other.IsAdmin() { + return nil, false + } } else { // Public channel pushMessageContext(sub, msg.Channel, rawMsg) diff --git a/pkg/server.go b/pkg/server.go index a3ad1c4..39ddc15 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -36,6 +36,7 @@ func (s *Server) Setup() error { mux.Handle("/ws", s.WebSocket()) mux.Handle("/api/statistics", s.Statistics()) mux.Handle("/api/blocklist", s.BlockList()) + mux.Handle("/api/block/now", s.BlockNow()) mux.Handle("/api/authenticate", s.Authenticate()) mux.Handle("/api/shutdown", s.ShutdownAPI()) mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets")))) diff --git a/pkg/websocket.go b/pkg/websocket.go index b21fd37..5c86acc 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -218,7 +218,7 @@ func (s *Server) WebSocket() http.HandlerFunc { for { select { case msg := <-sub.messages: - err = writeTimeout(ctx, time.Second*5, c, msg) + err = writeTimeout(ctx, time.Second*15, c, msg) if err != nil { return } diff --git a/src/App.vue b/src/App.vue index 1d3bdbb..0eeb27e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -957,6 +957,21 @@ export default { } }, + // Server side "block" event: for when the main website sends a BlockNow API request. + onBlock(msg) { + // Close any video connections we had with this user. + this.closeVideo(msg.username); + + // Add it to our CachedBlocklist so in case the server reboots, we continue to sync it on reconnect. + for (let existing of this.config.CachedBlocklist) { + if (existing === msg.username) { + return; + } + } + + this.config.CachedBlocklist.push(msg.username); + }, + // Mute or unmute a user. muteUser(username) { username = this.normalizeUsername(username); @@ -1184,6 +1199,11 @@ export default { this.ws.connected = true; this.ChatClient("Websocket connected!"); + // Upload our blocklist to the server before login. This resolves a bug where if a block + // was added recently (other user still online in chat), that user would briefly see your + // "has entered the room" message followed by you immediately not being online. + this.bulkMuteUsers(); + // Tell the server our username. this.ws.conn.send(JSON.stringify({ action: "login", @@ -1249,6 +1269,9 @@ export default { case "unwatch": this.onUnwatch(msg); break; + case "block": + this.onBlock(msg); + break; case "error": this.pushHistory({ channel: msg.channel, @@ -3733,7 +3756,7 @@ export default { label on the chat history panel -->