Cached Blocklist from your website

* New API endpoint: /api/blocklist where your site can pre-deliver muted
  username lists for users before they enter the chat.
* Image sharing in DMs is allowed if either party is an operator.
This commit is contained in:
Noah 2023-07-30 10:32:08 -07:00
parent 84da298c12
commit 029f25029d
13 changed files with 273 additions and 20 deletions

View File

@ -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 ## Boot
Sent by: Client. Sent by: Client.

View File

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

1
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/BurntSushi/toml v1.2.1 github.com/BurntSushi/toml v1.2.1
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/golang-jwt/jwt/v4 v4.4.3 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/mattn/go-shellwords v1.0.12
github.com/microcosm-cc/bluemonday v1.0.22 github.com/microcosm-cc/bluemonday v1.0.22
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629

2
go.sum
View File

@ -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 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/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 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 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= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=

View File

@ -3,8 +3,11 @@ package barertc
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"sync"
"git.kirsle.net/apps/barertc/pkg/config" "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, // 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) 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(&params); 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
}

View File

@ -8,11 +8,12 @@ import (
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/google/uuid"
) )
// Version of the config format - when new fields are added, it will attempt // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 4 var currentVersion = 5
// Config for your BareRTC app. // Config for your BareRTC app.
type Config struct { type Config struct {
@ -30,6 +31,7 @@ type Config struct {
WebsiteURL string WebsiteURL string
CORSHosts []string CORSHosts []string
AdminAPIKey string
PermitNSFW bool PermitNSFW bool
UseXForwardedFor bool UseXForwardedFor bool
@ -75,6 +77,7 @@ func DefaultConfig() Config {
Title: "BareRTC", Title: "BareRTC",
Branding: "BareRTC", Branding: "BareRTC",
WebsiteURL: "https://www.example.com", WebsiteURL: "https://www.example.com",
AdminAPIKey: uuid.New().String(),
CORSHosts: []string{ CORSHosts: []string{
"https://www.example.com", "https://www.example.com",
}, },

View File

@ -374,6 +374,21 @@ func (s *Server) OnMute(sub *Subscriber, msg Message, mute bool) {
s.SendWhoList() 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. // OnCandidate handles WebRTC candidate signaling.
func (s *Server) OnCandidate(sub *Subscriber, msg Message) { func (s *Server) OnCandidate(sub *Subscriber, msg Message) {
// Look up the other subscriber. // Look up the other subscriber.

View File

@ -35,6 +35,9 @@ type Message struct {
// Send on `file` actions, passing e.g. image data. // Send on `file` actions, passing e.g. image data.
Bytes []byte `json:"bytes,omitempty"` 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 // WebRTC negotiation messages: proxy their signaling messages
// between the two users to negotiate peer connection. // between the two users to negotiate peer connection.
Candidate string `json:"candidate,omitempty"` // candidate Candidate string `json:"candidate,omitempty"` // candidate
@ -47,6 +50,7 @@ const (
ActionBoot = "boot" // boot a user off your video feed ActionBoot = "boot" // boot a user off your video feed
ActionMute = "mute" // mute a user's chat messages ActionMute = "mute" // mute a user's chat messages
ActionUnmute = "unmute" ActionUnmute = "unmute"
ActionBlocklist = "blocklist" // mute in bulk for usernames
// Actions sent by server or client // Actions sent by server or client
ActionMessage = "message" // post a message to the room ActionMessage = "message" // post a message to the room

View File

@ -23,6 +23,7 @@ func IndexPage() http.HandlerFunc {
tokenStr = r.FormValue("jwt") tokenStr = r.FormValue("jwt")
claims = &jwt.Claims{} claims = &jwt.Claims{}
authOK bool authOK bool
blocklist = []string{} // cached blocklist from your website, for JWT auth only
) )
if tokenStr != "" { if tokenStr != "" {
parsed, ok, err := jwt.ParseAndValidate(tokenStr) parsed, ok, err := jwt.ParseAndValidate(tokenStr)
@ -36,6 +37,7 @@ func IndexPage() http.HandlerFunc {
authOK = ok authOK = ok
claims = parsed claims = parsed
blocklist = GetCachedBlocklist(claims.Subject)
} }
// Are we enforcing strict JWT authentication? // Are we enforcing strict JWT authentication?
@ -66,6 +68,9 @@ func IndexPage() http.HandlerFunc {
"JWTTokenString": tokenStr, "JWTTokenString": tokenStr,
"JWTAuthOK": authOK, "JWTAuthOK": authOK,
"JWTClaims": claims, "JWTClaims": claims,
// Cached user blocklist sent by your website.
"CachedBlocklist": blocklist,
} }
tmpl.Funcs(template.FuncMap{ tmpl.Funcs(template.FuncMap{

View File

@ -34,6 +34,7 @@ func (s *Server) Setup() error {
mux.Handle("/about", AboutPage()) mux.Handle("/about", AboutPage())
mux.Handle("/ws", s.WebSocket()) mux.Handle("/ws", s.WebSocket())
mux.Handle("/api/statistics", s.Statistics()) mux.Handle("/api/statistics", s.Statistics())
mux.Handle("/api/blocklist", s.BlockList())
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
s.mux = mux s.mux = mux

View File

@ -95,6 +95,8 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.OnBoot(sub, msg) s.OnBoot(sub, msg)
case ActionMute, ActionUnmute: case ActionMute, ActionUnmute:
s.OnMute(sub, msg, msg.Action == ActionMute) s.OnMute(sub, msg, msg.Action == ActionMute)
case ActionBlocklist:
s.OnBlocklist(sub, msg)
case ActionCandidate: case ActionCandidate:
s.OnCandidate(sub, msg) s.OnCandidate(sub, msg)
case ActionSDP: case ActionSDP:

View File

@ -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. // User JWT settings if available.
@ -365,6 +368,27 @@ const app = Vue.createApp({
// Is the current channel a DM? // Is the current channel a DM?
return this.channel.indexOf("@") === 0; 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() { isOp() {
// Returns if the current user has operator rights // Returns if the current user has operator rights
return this.jwt.claims.op; return this.jwt.claims.op;
@ -507,6 +531,11 @@ const app = Vue.createApp({
if (myNSFW != theirNSFW) { if (myNSFW != theirNSFW) {
this.webcam.nsfw = 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. // WhoList updates.
@ -575,6 +604,27 @@ const app = Vue.createApp({
isMutedUser(username) { isMutedUser(username) {
return this.muted[this.normalizeUsername(username)] != undefined; 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. // Send a video request to access a user's camera.
sendOpen(username) { sendOpen(username) {
@ -1760,10 +1810,6 @@ const app = Vue.createApp({
// The image upload button handler. // The image upload button handler.
uploadFile() { uploadFile() {
if (this.isDM) {
return;
}
let input = document.createElement('input'); let input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*'; input.accept = 'image/*';

View File

@ -924,7 +924,7 @@
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column" <div class="column"
:class="{'pr-1': !isDM}"> :class="{'pr-1': canUploadFile}">
<form @submit.prevent="sendMessage()"> <form @submit.prevent="sendMessage()">
<input type="text" class="input" <input type="text" class="input"
v-model="message" v-model="message"
@ -933,7 +933,7 @@
:disabled="!ws.connected"> :disabled="!ws.connected">
</form> </form>
</div> </div>
<div class="column pl-1 is-narrow" v-if="!isDM"> <div class="column pl-1 is-narrow" v-if="canUploadFile">
<button type="button" class="button" <button type="button" class="button"
@click="uploadFile()"> @click="uploadFile()">
<i class="fa fa-image"></i> <i class="fa fa-image"></i>
@ -1110,6 +1110,7 @@ const TURN = {{.Config.TURN}};
const UserJWTToken = {{.JWTTokenString}}; const UserJWTToken = {{.JWTTokenString}};
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}}; const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
const UserJWTClaims = {{.JWTClaims.ToJSON}}; const UserJWTClaims = {{.JWTClaims.ToJSON}};
const CachedBlocklist = {{.CachedBlocklist}};
</script> </script>
<script src="/static/js/vue-3.2.45.js"></script> <script src="/static/js/vue-3.2.45.js"></script>