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:
parent
84da298c12
commit
029f25029d
20
Protocol.md
20
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.
|
||||
|
|
36
README.md
36
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.
|
||||
|
|
1
go.mod
1
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
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
117
pkg/api.go
117
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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
11
pkg/pages.go
11
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{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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/*';
|
||||
|
|
|
@ -924,7 +924,7 @@
|
|||
|
||||
<div class="columns is-mobile">
|
||||
<div class="column"
|
||||
:class="{'pr-1': !isDM}">
|
||||
:class="{'pr-1': canUploadFile}">
|
||||
<form @submit.prevent="sendMessage()">
|
||||
<input type="text" class="input"
|
||||
v-model="message"
|
||||
|
@ -933,7 +933,7 @@
|
|||
:disabled="!ws.connected">
|
||||
</form>
|
||||
</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"
|
||||
@click="uploadFile()">
|
||||
<i class="fa fa-image"></i>
|
||||
|
@ -1110,6 +1110,7 @@ const TURN = {{.Config.TURN}};
|
|||
const UserJWTToken = {{.JWTTokenString}};
|
||||
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
|
||||
const UserJWTClaims = {{.JWTClaims.ToJSON}};
|
||||
const CachedBlocklist = {{.CachedBlocklist}};
|
||||
</script>
|
||||
|
||||
<script src="/static/js/vue-3.2.45.js"></script>
|
||||
|
|
Loading…
Reference in New Issue
Block a user