diff --git a/Protocol.md b/Protocol.md index e2119f4..e19eb6e 100644 --- a/Protocol.md +++ b/Protocol.md @@ -343,6 +343,26 @@ The `unmute` action does the opposite and removes the mute status: } ``` +## Blocklist + +Sent by: Client. + +The blocklist command is basically a bulk mute for (potentially) many usernames at once. + +```javascript +// Client blocklist +{ + "action": "blocklist", + "usernames": [ "target1", "target2", "target3" ] +} +``` + +How this works: if you have an existing website and use JWT authentication to sign users into chat, your site can pre-emptively sync the user's block list **before** the user enters the room, using the `/api/blocklist` endpoint (see the README.md for BareRTC). + +The chat server holds onto blocklists temporarily in memory: when that user loads the chat room (with a JWT token!), the front-end page receives the cached blocklist. As part of the "on connected" handler, the chat page sends the `blocklist` command over WebSocket to perform a mass mute on these users in one go. + +The reason for this workflow is in case the chat server is rebooted _while_ the user is in the room. The cached blocklist pushed by your website is forgotten by the chat server back-end, but the client's page was still open with the cached blocklist already, and it will send the `blocklist` command to the server when it reconnects, eliminating any gaps. + ## Boot Sent by: Client. diff --git a/README.md b/README.md index 3dbdc3f..560fc71 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,42 @@ Returns basic info about the count and usernames of connected chatters: } ``` +* `POST /api/blocklist` + +Your server may pre-cache the user's blocklist for them **before** they +enter the chat room. Your site will use the `AdminAPIKey` parameter that +matches the setting in BareRTC's settings.toml (by default, a random UUID +is generated the first time). + +The request payload coming from your site will be an application/json +post body like: + +```json +{ + APIKey: "from your settings.toml", + Username: "soandso", + Blocklist: [ "usernames", "that", "they", "block" ], +} +``` + +The server holds onto these in memory and when that user enters the chat +room (**JWT authentication only**) the front-end page will embed their +cached blocklist. When they connect to the WebSocket server, they send a +`blocklist` message to push their blocklist to the server -- it is +basically a bulk `mute` action that mutes all these users pre-emptively: +the user will not see their chat messages and the muted users can not see +the user's webcam when they broadcast later, the same as a regular `mute` +action. + +The JSON response to this endpoint may look like: + +```json +{ + "OK": true, + "Error": "if error, or this key is omitted if OK" +} +``` + # Tour of the Codebase This app uses WebSockets and WebRTC at the very simplest levels, without using a framework like `Socket.io`. Here is a tour of the codebase with the more interesting modules listed first. diff --git a/go.mod b/go.mod index 7cf69a2..eb5c28e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/BurntSushi/toml v1.2.1 github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f github.com/golang-jwt/jwt/v4 v4.4.3 + github.com/google/uuid v1.3.0 github.com/mattn/go-shellwords v1.0.12 github.com/microcosm-cc/bluemonday v1.0.22 github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 diff --git a/go.sum b/go.sum index c6f9d72..8b479e0 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgj github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= diff --git a/pkg/api.go b/pkg/api.go index 9d73cda..affe542 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -3,8 +3,11 @@ package barertc import ( "encoding/json" "net/http" + "strings" + "sync" "git.kirsle.net/apps/barertc/pkg/config" + "git.kirsle.net/apps/barertc/pkg/log" ) // Statistics (/api/statistics) returns info about the users currently logged onto the chat, @@ -65,3 +68,117 @@ func (s *Server) Statistics() http.HandlerFunc { enc.Encode(result) }) } + +// BlockList (/api/blocklist) allows your website to pre-sync mute lists between your +// user accounts, so that when they see each other in chat they will pre-emptively mute +// or boot one another. +// +// It is a POST request with a json body containing the following schema: +// +// { +// "APIKey": "from settings.toml", +// "Username": "soandso", +// "Blocklist": [ "list", "of", "other", "usernames" ], +// } +// +// The chat server will remember these mappings (until rebooted). How they are +// used is that the blocklist is embedded in the front-end page when the username +// signs in later. As part of the On Connect handler, the front-end will send the +// list of usernames in a bulk `mute` command to the server. This way even if the +// chat server reboots while the user is connected, when it comes back up and the user +// reconnects they will retransmit their block list. +func (s *Server) BlockList() http.HandlerFunc { + type request struct { + APIKey string + Username string + Blocklist []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 + } + + // Store the cached blocklist. + SetCachedBlocklist(params.Username, params.Blocklist) + enc.Encode(result{ + OK: true, + }) + }) +} + +// Blocklist cache sent over from your website. +var ( + // Map of username to the list of usernames they block. + cachedBlocklist map[string][]string + cachedBlocklistMu sync.RWMutex +) + +func init() { + cachedBlocklist = map[string][]string{} +} + +// GetCachedBlocklist returns the blocklist for a username. +func GetCachedBlocklist(username string) []string { + cachedBlocklistMu.RLock() + defer cachedBlocklistMu.RUnlock() + if list, ok := cachedBlocklist[username]; ok { + log.Debug("GetCachedBlocklist(%s) blocks %s", username, list) + return list + } + log.Debug("GetCachedBlocklist(%s): no blocklist stored", username) + return []string{} +} + +// SetCachedBlocklist sets the blocklist cache for a user. +func SetCachedBlocklist(username string, blocklist []string) { + log.Info("SetCachedBlocklist: %s mutes users %s", username, strings.Join(blocklist, ", ")) + cachedBlocklistMu.Lock() + defer cachedBlocklistMu.Unlock() + cachedBlocklist[username] = blocklist +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ac42359..cbe08f5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,11 +8,12 @@ import ( "git.kirsle.net/apps/barertc/pkg/log" "github.com/BurntSushi/toml" + "github.com/google/uuid" ) // Version of the config format - when new fields are added, it will attempt // to write the settings.toml to disk so new defaults populate. -var currentVersion = 4 +var currentVersion = 5 // Config for your BareRTC app. type Config struct { @@ -29,8 +30,9 @@ type Config struct { Branding string WebsiteURL string - CORSHosts []string - PermitNSFW bool + CORSHosts []string + AdminAPIKey string + PermitNSFW bool UseXForwardedFor bool @@ -72,9 +74,10 @@ var Current = DefaultConfig() // settings.toml file to disk. func DefaultConfig() Config { var c = Config{ - Title: "BareRTC", - Branding: "BareRTC", - WebsiteURL: "https://www.example.com", + Title: "BareRTC", + Branding: "BareRTC", + WebsiteURL: "https://www.example.com", + AdminAPIKey: uuid.New().String(), CORSHosts: []string{ "https://www.example.com", }, diff --git a/pkg/handlers.go b/pkg/handlers.go index 71cf95f..883e8f1 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -374,6 +374,21 @@ func (s *Server) OnMute(sub *Subscriber, msg Message, mute bool) { s.SendWhoList() } +// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website. +func (s *Server) OnBlocklist(sub *Subscriber, msg Message) { + log.Info("%s syncs their blocklist: %s", sub.Username, msg.Usernames) + + sub.muteMu.Lock() + for _, username := range msg.Usernames { + sub.muted[username] = struct{}{} + } + + sub.muteMu.Unlock() + + // Send the Who List in case our cam will show as disabled to the muted party. + s.SendWhoList() +} + // OnCandidate handles WebRTC candidate signaling. func (s *Server) OnCandidate(sub *Subscriber, msg Message) { // Look up the other subscriber. diff --git a/pkg/messages.go b/pkg/messages.go index 7ce68e7..3588116 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -35,6 +35,9 @@ type Message struct { // Send on `file` actions, passing e.g. image data. Bytes []byte `json:"bytes,omitempty"` + // Send on `blocklist` actions, for doing a `mute` on a list of users + Usernames []string `json:"usernames,omitempty"` + // WebRTC negotiation messages: proxy their signaling messages // between the two users to negotiate peer connection. Candidate string `json:"candidate,omitempty"` // candidate @@ -43,10 +46,11 @@ type Message struct { const ( // Actions sent by the client side only - ActionLogin = "login" // post the username to backend - ActionBoot = "boot" // boot a user off your video feed - ActionMute = "mute" // mute a user's chat messages - ActionUnmute = "unmute" + ActionLogin = "login" // post the username to backend + ActionBoot = "boot" // boot a user off your video feed + ActionMute = "mute" // mute a user's chat messages + ActionUnmute = "unmute" + ActionBlocklist = "blocklist" // mute in bulk for usernames // Actions sent by server or client ActionMessage = "message" // post a message to the room diff --git a/pkg/pages.go b/pkg/pages.go index 7427179..90aeccd 100644 --- a/pkg/pages.go +++ b/pkg/pages.go @@ -20,9 +20,10 @@ func IndexPage() http.HandlerFunc { // Handle a JWT authentication token. var ( - tokenStr = r.FormValue("jwt") - claims = &jwt.Claims{} - authOK bool + tokenStr = r.FormValue("jwt") + claims = &jwt.Claims{} + authOK bool + blocklist = []string{} // cached blocklist from your website, for JWT auth only ) if tokenStr != "" { parsed, ok, err := jwt.ParseAndValidate(tokenStr) @@ -36,6 +37,7 @@ func IndexPage() http.HandlerFunc { authOK = ok claims = parsed + blocklist = GetCachedBlocklist(claims.Subject) } // Are we enforcing strict JWT authentication? @@ -66,6 +68,9 @@ func IndexPage() http.HandlerFunc { "JWTTokenString": tokenStr, "JWTAuthOK": authOK, "JWTClaims": claims, + + // Cached user blocklist sent by your website. + "CachedBlocklist": blocklist, } tmpl.Funcs(template.FuncMap{ diff --git a/pkg/server.go b/pkg/server.go index fa708c2..43eac81 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -34,6 +34,7 @@ func (s *Server) Setup() error { mux.Handle("/about", AboutPage()) mux.Handle("/ws", s.WebSocket()) mux.Handle("/api/statistics", s.Statistics()) + mux.Handle("/api/blocklist", s.BlockList()) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) s.mux = mux diff --git a/pkg/websocket.go b/pkg/websocket.go index 29ead14..15b7b8e 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -95,6 +95,8 @@ func (sub *Subscriber) ReadLoop(s *Server) { s.OnBoot(sub, msg) case ActionMute, ActionUnmute: s.OnMute(sub, msg, msg.Action == ActionMute) + case ActionBlocklist: + s.OnBlocklist(sub, msg) case ActionCandidate: s.OnCandidate(sub, msg) case ActionSDP: diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 9259533..4854552 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -72,7 +72,10 @@ const app = Vue.createApp({ ['😋', '⭐', '😇', '😴', '😱', '👀', '🎃'], ['🤮', '🥳', '🙏', '🤦', '💩', '🤯', '💯'], ['😏', '🙈', '🙉', '🙊', '☀️', '🌈', '🎂'], - ] + ], + + // Cached blocklist for the current user sent by your website. + CachedBlocklist: CachedBlocklist, }, // User JWT settings if available. @@ -365,6 +368,27 @@ const app = Vue.createApp({ // Is the current channel a DM? return this.channel.indexOf("@") === 0; }, + canUploadFile() { + // Public channels: OK + if (!this.channel.indexOf('@') === 0) { + return true; + } + + // User is an admin? + if (this.jwt.claims.op) { + return true; + } + + // User is in a DM thread with an admin? + if (this.isDM) { + let partner = this.normalizeUsername(this.channel); + if (this.whoMap[partner] != undefined && this.whoMap[partner].op) { + return true; + } + } + + return !this.isDM; + }, isOp() { // Returns if the current user has operator rights return this.jwt.claims.op; @@ -507,6 +531,11 @@ const app = Vue.createApp({ if (myNSFW != theirNSFW) { this.webcam.nsfw = theirNSFW; } + + // Note: Me events only come when we join the server or a moderator has + // flagged our video. This is as good of an "on connected" handler as we + // get, so push over our cached blocklist from the website now. + this.bulkMuteUsers(); }, // WhoList updates. @@ -575,6 +604,27 @@ const app = Vue.createApp({ isMutedUser(username) { return this.muted[this.normalizeUsername(username)] != undefined; }, + bulkMuteUsers() { + // On page load, if the website sent you a CachedBlocklist, mute all + // of these users in bulk when the server connects. + // this.ChatClient("BulkMuteUsers: sending our blocklist " + this.config.CachedBlocklist); + + if (this.config.CachedBlocklist.length === 0) { + return; // nothing to do + } + + // Set the client side mute. + let blocklist = this.config.CachedBlocklist; + for (let username of blocklist) { + this.muted[username] = true; + } + + // Send the username list to the server. + this.ws.conn.send(JSON.stringify({ + action: "blocklist", + usernames: blocklist, + })) + }, // Send a video request to access a user's camera. sendOpen(username) { @@ -1760,10 +1810,6 @@ const app = Vue.createApp({ // The image upload button handler. uploadFile() { - if (this.isDM) { - return; - } - let input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; diff --git a/web/templates/chat.html b/web/templates/chat.html index fface72..baa7dcc 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -924,7 +924,7 @@
+ :class="{'pr-1': canUploadFile}">
-
+