JWT Token Chat Moderation Rules

Add support for your website to apply chat moderation rules to users
as they log onto the chat room.
This commit is contained in:
Noah 2024-09-18 22:16:33 -07:00
parent bbd6836c68
commit 16b148fc92
12 changed files with 780 additions and 561 deletions

View File

@ -25,6 +25,7 @@ Configure a shared secret key (random text string) in both the BareRTC settings
"url": "/u/username", // user profile URL "url": "/u/username", // user profile URL
"gender": "m", // gender (m, f, o) "gender": "m", // gender (m, f, o)
"emoji": "🤖", // emoji icon "emoji": "🤖", // emoji icon
"rules": ["redcam", "noimage"], // moderation rules (optional)
// Standard JWT claims that we support: // Standard JWT claims that we support:
"iss": "my own app", // Issuer name "iss": "my own app", // Issuer name
@ -112,6 +113,8 @@ Here is in-depth documentation on what custom claims are supported by BareRTC an
* Country flag emojis, to indicate where your users are connecting from. * Country flag emojis, to indicate where your users are connecting from.
* Robot emojis, to indicate bot users. * Robot emojis, to indicate bot users.
* Any emoji you want! Mark your special guests or VIP users, etc. * Any emoji you want! Mark your special guests or VIP users, etc.
* **Rules** (`rules`): a string array of moderation rules to apply to the joining user, dictated by your website.
* See [JWT Moderation Rules](./Configuration.md#jwt-moderation-rules) for available values.
## JWT Strict Mode ## JWT Strict Mode

View File

@ -70,7 +70,9 @@ PreviewImageWidth = 360
[[ModerationRule]] [[ModerationRule]]
Username = "example" Username = "example"
CameraAlwaysNSFW = true CameraAlwaysNSFW = true
DisableCamera = false NoBroadcast = false
NoVideo = false
NoImage = false
[DirectMessageHistory] [DirectMessageHistory]
Enabled = true Enabled = true
@ -155,7 +157,34 @@ Settings in the `[[ModerationRule]]` array include:
* **Username** (string): the username on chat to apply the rule to. * **Username** (string): the username on chat to apply the rule to.
* **CameraAlwaysNSFW** (bool): if true, the user's camera is forced to NSFW and they will receive a ChatServer message when they try and remove the flag themselves. * **CameraAlwaysNSFW** (bool): if true, the user's camera is forced to NSFW and they will receive a ChatServer message when they try and remove the flag themselves.
* **DisableCamera** (bool): if true, the user is not allowed to share their webcam and the server will send them a 'cut' message any time they go live, along with a ChatServer message informing them of this. * **NoBroadcast** (bool): if true, the user is not allowed to share their webcam and the server will send them a 'cut' message any time they go live, along with a ChatServer message informing them of this.
* **NoVideo** (bool): if true, the user is not allowed to broadcast their camera OR watch any camera on chat.
* **NoImage** (bool): if true, the user is not allowed to share images or see images shared by others on chat.
### JWT Moderation Rules
Rather than in the server-side settings.toml, you can enable these moderation rules from your website's side as well by including them in the "rules" custom key of your JWT token.
The "rules" key is a string array with short labels representing each of the rules:
| Moderation Rule | JWT "Rules" Value |
|------------------|-------------------|
| CameraAlwaysNSFW | redcam |
| NoBroadcast | nobroadcast |
| NoVideo | novideo |
| NoImage | noimage |
An example JWT token claims object may look like:
```javascript
{
"sub": "username", // Username for chat
"nick": "Display name", // Friendly name
"img": "/static/photos/username.jpg", // user picture URL
"url": "/u/username", // user profile URL
"rules": ["redcam", "noimage"], // moderation rules
}
```
## Direct Message History ## Direct Message History

View File

@ -38,6 +38,7 @@
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 UserJWTRules = {{.JWTClaims.Rules.ToDict}};
const CachedBlocklist = {{.CachedBlocklist}}; const CachedBlocklist = {{.CachedBlocklist}};
const CacheHash = {{.CacheHash}}; const CacheHash = {{.CacheHash}};

View File

@ -125,7 +125,9 @@ type Logging struct {
type ModerationRule struct { type ModerationRule struct {
Username string Username string
CameraAlwaysNSFW bool CameraAlwaysNSFW bool
DisableCamera bool NoBroadcast bool
NoVideo bool
NoImage bool
} }
// Current loaded configuration. // Current loaded configuration.

View File

@ -304,6 +304,20 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
return return
} }
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from watching cameras on chat?
if rule.NoImage {
sub.ChatServer(
"A chat server moderation rule is currently in place which restricts your ability to share images. Please " +
"contact a chat operator for more information.",
)
return
}
}
// Detect image type and convert it into an <img src="data:"> tag. // Detect image type and convert it into an <img src="data:"> tag.
var ( var (
filename = msg.Message filename = msg.Message
@ -390,10 +404,10 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
log.Debug("User %s turns on their video feed", sub.Username) log.Debug("User %s turns on their video feed", sub.Username)
// Moderation rules? // Moderation rules?
if rule := config.Current.GetModerationRule(sub.Username); rule != nil { if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from sharing their camera on chat? // Are they barred from sharing their camera on chat?
if rule.DisableCamera { if rule.NoBroadcast || rule.NoVideo {
sub.SendCut() sub.SendCut()
sub.ChatServer( sub.ChatServer(
"A chat server moderation rule is currently in place which restricts your ability to share your webcam. Please " + "A chat server moderation rule is currently in place which restricts your ability to share your webcam. Please " +
@ -452,6 +466,20 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera. // OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera.
func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) { func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from watching cameras on chat?
if rule.NoVideo {
sub.ChatServer(
"A chat server moderation rule is currently in place which restricts your ability to watch webcams. Please " +
"contact a chat operator for more information.",
)
return
}
}
// Look up the other subscriber. // Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username) other, err := s.GetSubscriber(msg.Username)
if err != nil { if err != nil {

View File

@ -20,6 +20,7 @@ type Claims struct {
Nick string `json:"nick,omitempty"` Nick string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"` Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"` Gender string `json:"gender,omitempty"`
Rules Rules `json:"rules,omitempty"`
// Standard claims. Notes: // Standard claims. Notes:
// subject = username // subject = username

65
pkg/jwt/rules.go Normal file
View File

@ -0,0 +1,65 @@
package jwt
// Rule options for the JWT custom key.
//
// Safely check its settings with the Is() functions which handle superset rules
// which imply other rules, for example novideo > nobroadcast.
type Rule string
// Available Rules your site can include in the JWT token: to enforce moderator
// rules on the user logging into chat.
const (
// Webcam restrictions.
NoVideoRule = Rule("novideo") // Can not use video features at all
NoBroadcastRule = Rule("nobroadcast") // They can not share their webcam
NoImageRule = Rule("noimage") // Can not upload or see images
RedCamRule = Rule("redcam") // Their camera is force marked NSFW
)
func (r Rule) IsNoVideoRule() bool {
return r == NoVideoRule
}
func (r Rule) IsNoImageRule() bool {
return r == NoImageRule
}
func (r Rule) IsNoBroadcastRule() bool {
return r == NoVideoRule || r == NoBroadcastRule
}
func (r Rule) IsRedCamRule() bool {
return r == RedCamRule
}
// Rules are the plural set of rules as shown on a JWT token (string array),
// with some extra functionality attached such as an easy serializer to JSON.
type Rules []Rule
// ToDict serializes a Rules string-array into a map of the Is* functions, for easy
// front-end access to the currently enabled rules.
func (r Rules) ToDict() map[string]bool {
var result = map[string]bool{
"IsNoVideoRule": false,
"IsNoImageRule": false,
"IsNoBroadcastRule": false,
"IsRedCamRule": false,
}
for _, rule := range r {
if v := rule.IsNoVideoRule(); v {
result["IsNoVideoRule"] = true
}
if v := rule.IsNoImageRule(); v {
result["IsNoImageRule"] = true
}
if v := rule.IsNoBroadcastRule(); v {
result["IsNoBroadcastRule"] = true
}
if v := rule.IsRedCamRule(); v {
result["IsRedCamRule"] = true
}
}
return result
}

39
pkg/moderation_rules.go Normal file
View File

@ -0,0 +1,39 @@
package barertc
import (
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
)
/*
GetModerationRule loads any moderation rules applied to the user.
Moderation rules can be applied by your chat server (in settings.toml) or provided
by your website (in the custom JWT claims "rules" key).
*/
func (sub *Subscriber) GetModerationRule() *config.ModerationRule {
// Get server side mod rules to start.
rules := config.Current.GetModerationRule(sub.Username)
if rules == nil {
rules = &config.ModerationRule{}
}
// Add in client side (JWT) rules.
if sub.JWTClaims != nil {
for _, rule := range sub.JWTClaims.Rules {
if rule.IsRedCamRule() {
rules.CameraAlwaysNSFW = true
}
if rule.IsNoVideoRule() {
rules.NoVideo = true
}
if rule.IsNoBroadcastRule() {
rules.NoBroadcast = true
}
}
}
log.Error("GetModerationRule(%s): %+v", sub.Username, rules)
return rules
}

565
pkg/subscriber.go Normal file
View File

@ -0,0 +1,565 @@
package barertc
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"nhooyr.io/websocket"
)
// Auto incrementing Subscriber ID, assigned in AddSubscriber.
var SubscriberID int
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
ChatStatus string
VideoStatus int
DND bool // Do Not Disturb status (DMs are closed)
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
loginAt time.Time
// Connection details (WebSocket).
conn *websocket.Conn // WebSocket user
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
// Polling API users.
usePolling bool
sessionID string
lastPollAt time.Time
lastPollJWT time.Time // give a new JWT once in a while
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
blocked map[string]struct{} // usernames you have blocked
muted map[string]struct{} // usernames you muted
// Admin "unblockable" override command, e.g. especially for your chatbot so it can
// still moderate the chat even if users had blocked it. The /unmute-all admin command
// will toggle this setting: then the admin chatbot will appear in the Who's Online list
// as normal and it can see user messages in chat.
unblockable bool
// Record which message IDs belong to this user.
midMu sync.Mutex
messageIDs map[int64]struct{}
// Logging.
log bool
logfh map[string]io.WriteCloser
}
// NewSubscriber initializes a connected chat user.
func (s *Server) NewSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
return &Subscriber{
ctx: ctx,
cancel: cancelFunc,
messages: make(chan []byte, s.subscriberMessageBuffer),
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
blocked: make(map[string]struct{}),
messageIDs: make(map[int64]struct{}),
ChatStatus: "online",
}
}
// NewWebSocketSubscriber returns a new subscriber with a WebSocket connection.
func (s *Server) NewWebSocketSubscriber(ctx context.Context, conn *websocket.Conn, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.conn = conn
sub.closeSlow = func() {
conn.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
}
return sub
}
// NewPollingSubscriber returns a new subscriber using the polling API.
func (s *Server) NewPollingSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.usePolling = true
sub.lastPollAt = time.Now()
sub.lastPollJWT = time.Now()
sub.closeSlow = func() {
// Their outbox is filled up, disconnect them.
log.Error("Polling subscriber %s#%d: inbox is filled up!", sub.Username, sub.ID)
// Send an exit message.
if sub.authenticated && sub.ChatStatus != "hidden" {
sub.authenticated = false
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
s.SendWhoList()
}
s.DeleteSubscriber(sub)
}
return sub
}
// OnClientMessage handles a chat protocol message from the user's WebSocket or polling API.
func (s *Server) OnClientMessage(sub *Subscriber, msg messages.Message) {
// What action are they performing?
switch msg.Action {
case messages.ActionLogin:
s.OnLogin(sub, msg)
case messages.ActionMessage:
s.OnMessage(sub, msg)
case messages.ActionFile:
s.OnFile(sub, msg)
case messages.ActionMe:
s.OnMe(sub, msg)
case messages.ActionOpen:
s.OnOpen(sub, msg)
case messages.ActionBoot:
s.OnBoot(sub, msg, true)
case messages.ActionUnboot:
s.OnBoot(sub, msg, false)
case messages.ActionMute, messages.ActionUnmute:
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
case messages.ActionBlock:
s.OnBlock(sub, msg)
case messages.ActionBlocklist:
s.OnBlocklist(sub, msg)
case messages.ActionCandidate:
s.OnCandidate(sub, msg)
case messages.ActionSDP:
s.OnSDP(sub, msg)
case messages.ActionWatch:
s.OnWatch(sub, msg)
case messages.ActionUnwatch:
s.OnUnwatch(sub, msg)
case messages.ActionTakeback:
s.OnTakeback(sub, msg)
case messages.ActionReact:
s.OnReact(sub, msg)
case messages.ActionReport:
s.OnReport(sub, msg)
case messages.ActionPing:
default:
sub.ChatServer("Unsupported message type: %s", msg.Action)
}
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
func (sub *Subscriber) ReadLoop(s *Server) {
go func() {
for {
msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil {
log.Error("ReadLoop error(%d=%s): %+v", sub.ID, sub.Username, err)
s.DeleteSubscriber(sub)
// Notify if this user was auth'd and not hidden
if sub.authenticated && sub.ChatStatus != "hidden" {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
s.SendWhoList()
}
return
}
if msgType != websocket.MessageText {
log.Error("Unexpected MessageType")
continue
}
// Read the user's posted message.
var msg messages.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Read(%d=%s) Message error: %s", sub.ID, sub.Username, err)
continue
}
if msg.Action != messages.ActionFile {
log.Debug("Read(%d=%s): %s", sub.ID, sub.Username, data)
}
// Handle their message.
s.OnClientMessage(sub, msg)
}
}()
}
// IsAdmin safely checks if the subscriber is an admin.
func (sub *Subscriber) IsAdmin() bool {
return sub.JWTClaims != nil && sub.JWTClaims.IsAdmin
}
// IsVIP safely checks if the subscriber has VIP status.
func (sub *Subscriber) IsVIP() bool {
return sub.JWTClaims != nil && sub.JWTClaims.VIP
}
// SendJSON sends a JSON message to the websocket client.
func (sub *Subscriber) SendJSON(v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
// Add the message to the recipient's queue. If the queue is too full,
// disconnect the client as they can't keep up.
select {
case sub.messages <- data:
default:
go sub.closeSlow()
}
return nil
}
// SendMe sends the current user state to the client.
func (sub *Subscriber) SendMe() {
sub.SendJSON(messages.Message{
Action: messages.ActionMe,
Username: sub.Username,
VideoStatus: sub.VideoStatus,
})
}
// SendCut sends the client a 'cut' message to deactivate their camera.
func (sub *Subscriber) SendCut() {
sub.SendJSON(messages.Message{
Action: messages.ActionCut,
})
}
// ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(message, v...),
})
}
// AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: ID #%d", sub.ID)
s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock()
}
// GetSubscriber by username.
func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
for _, sub := range s.IterSubscribers() {
if sub.Username == username {
return sub, nil
}
}
return nil, errors.New("not found")
}
// DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) {
if sub == nil {
return
}
log.Error("DeleteSubscriber: %s", sub.Username)
// Cancel its context to clean up the for-loop goroutine.
if sub.cancel != nil {
log.Info("Calling sub.cancel() on subscriber: %s#%d", sub.Username, sub.ID)
sub.cancel()
}
// Clean up any log files.
sub.teardownLogs()
s.subscribersMu.Lock()
delete(s.subscribers, sub)
s.subscribersMu.Unlock()
}
// IterSubscribers loops over the subscriber list with a read lock.
func (s *Server) IterSubscribers() []*Subscriber {
var result = []*Subscriber{}
// Lock for reads.
s.subscribersMu.RLock()
for sub := range s.subscribers {
result = append(result, sub)
}
s.subscribersMu.RUnlock()
return result
}
// UniqueUsername ensures a username will be unique or renames it. If the name is already unique, the error result is nil.
func (s *Server) UniqueUsername(username string) (string, error) {
var (
subs = s.IterSubscribers()
usernames = map[string]interface{}{}
origUsername = username
counter = 2
)
for _, sub := range subs {
usernames[sub.Username] = nil
}
// Check until unique.
for {
if _, ok := usernames[username]; ok {
username = fmt.Sprintf("%s %d", origUsername, counter)
counter++
} else {
break
}
}
if username != origUsername {
return username, errors.New("username was not unique and a unique name has been returned")
}
return username, nil
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg messages.Message) {
if len(msg.Message) < 1024 {
log.Debug("Broadcast: %+v", msg)
}
// Get the sender of this message.
sender, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error("Broadcast: sender name %s not found as a current subscriber!", msg.Username)
sender = nil
}
// Get the list of users who are online NOW, so we don't hold the mutex lock too long.
// Example: sending a fat GIF to a large audience could hang up the server for a long
// time until every copy of the GIF has been sent.
var subs = s.IterSubscribers()
for _, sub := range subs {
if !sub.authenticated {
continue
}
// Don't deliver it if the receiver has muted us.
if sub.Mutes(msg.Username) {
log.Debug("Do not broadcast message to %s: they have muted or booted %s", sub.Username, msg.Username)
continue
}
// Don't deliver it if there is any blocking between sender and receiver.
if sender != nil && sender.Blocks(sub) {
log.Debug("Do not broadcast message to %s: blocking between them and %s", msg.Username, sub.Username)
continue
}
// VIP channels: only deliver to subscribed VIP users.
if ch, ok := config.Current.GetChannel(msg.Channel); ok && ch.VIP && !sub.IsVIP() && !sub.IsAdmin() {
log.Debug("Do not broadcast message to %s: VIP channel and they are not VIP", sub.Username)
continue
}
sub.SendJSON(msg)
}
}
// SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg messages.Message) error {
log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@")
var found bool
var subs = s.IterSubscribers()
for _, sub := range subs {
if sub.Username == username {
found = true
sub.SendJSON(messages.Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
MessageID: msg.MessageID,
})
}
}
if !found {
return fmt.Errorf("%s is not online", username)
}
return nil
}
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var (
subscribers = s.IterSubscribers()
usernames = []string{} // distinct and sorted usernames
userSub = map[string]*Subscriber{}
)
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
usernames = append(usernames, sub.Username)
userSub[sub.Username] = sub
}
sort.Strings(usernames)
// Build the WhoList for each subscriber.
// TODO: it's the only way to fake videoActive for booted user views.
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
var users = []messages.WhoList{}
for _, un := range usernames {
user := userSub[un]
if user.ChatStatus == "hidden" {
continue
}
// Blocking: hide the presence of both people from the Who List.
if user.Blocks(sub) {
log.Debug("WhoList: hide %s from %s (blocking)", user.Username, sub.Username)
continue
}
who := messages.WhoList{
Username: user.Username,
Status: user.ChatStatus,
Video: user.VideoStatus,
DND: user.DND,
LoginAt: user.loginAt.Unix(),
}
// Hide video flags of other users (never for the current user).
if user.Username != sub.Username {
// If this person had booted us, force their camera to "off"
if user.Boots(sub.Username) || user.Mutes(sub.Username) {
if sub.IsAdmin() {
// They kicked the admin off, but admin can reopen the cam if they want.
// But, unset the user's "auto-open your camera" flag, so if the admin
// reopens it, the admin's cam won't open on the recipient's screen.
who.Video ^= messages.VideoFlagMutualOpen
} else {
// Force their video to "off"
who.Video = 0
}
}
// If this person's VideoFlag is set to VIP Only, force their camera to "off"
// except when the person looking has the VIP status.
if (user.VideoStatus&messages.VideoFlagOnlyVIP == messages.VideoFlagOnlyVIP) && !sub.IsVIP() {
who.Video = 0
}
}
if user.JWTClaims != nil {
who.Operator = user.JWTClaims.IsAdmin
who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = user.JWTClaims.ProfileURL
who.Nickname = user.JWTClaims.Nick
who.Emoji = user.JWTClaims.Emoji
who.Gender = user.JWTClaims.Gender
// VIP flags: if we are in MutuallySecret mode, only VIPs can see
// other VIP flags on the Who List.
if config.Current.VIP.MutuallySecret {
if sub.IsVIP() {
who.VIP = user.JWTClaims.VIP
}
} else {
who.VIP = user.JWTClaims.VIP
}
}
users = append(users, who)
}
sub.SendJSON(messages.Message{
Action: messages.ActionWhoList,
WhoList: users,
})
}
}
// Boots checks whether the subscriber has blocked username from their camera.
func (s *Subscriber) Boots(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.booted[username]
return ok
}
// Mutes checks whether the subscriber has muted username.
func (s *Subscriber) Mutes(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.muted[username]
return ok
}
// Blocks checks whether the subscriber blocks the username, or vice versa (blocking goes both directions).
func (s *Subscriber) Blocks(other *Subscriber) bool {
if s == nil || other == nil {
return false
}
// Admin blocking behavior: by default, admins are NOT blockable by users and retain visibility on
// chat, especially to moderate webcams (messages may still be muted between blocked users).
//
// If your chat server allows admins to be blockable:
if !config.Current.BlockableAdmins && (s.IsAdmin() || other.IsAdmin()) {
return false
} else {
// Admins are blockable, unless they have the unblockable flag - e.g. if you have an admin chatbot on
// your server it will send the `/unmute-all` command to still retain visibility into user messages for
// auto-moderation. The `/unmute-all` sets the unblockable flag, so your admin chatbot still appears
// on the Who's Online list as well.
unblockable := (s.IsAdmin() && s.unblockable) || (other.IsAdmin() && other.unblockable)
if unblockable {
return false
}
}
s.muteMu.RLock()
defer s.muteMu.RUnlock()
// Forward block?
if _, ok := s.blocked[other.Username]; ok {
return true
}
// Reverse block?
other.muteMu.RLock()
defer other.muteMu.RUnlock()
_, ok := other.blocked[s.Username]
return ok
}

View File

@ -2,260 +2,17 @@ package barertc
import ( import (
"context" "context"
"encoding/json"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"sort"
"strings"
"sync"
"time" "time"
"git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages" "git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/util" "git.kirsle.net/apps/barertc/pkg/util"
"nhooyr.io/websocket" "nhooyr.io/websocket"
) )
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
ChatStatus string
VideoStatus int
DND bool // Do Not Disturb status (DMs are closed)
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
loginAt time.Time
// Connection details (WebSocket).
conn *websocket.Conn // WebSocket user
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
// Polling API users.
usePolling bool
sessionID string
lastPollAt time.Time
lastPollJWT time.Time // give a new JWT once in a while
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
blocked map[string]struct{} // usernames you have blocked
muted map[string]struct{} // usernames you muted
// Admin "unblockable" override command, e.g. especially for your chatbot so it can
// still moderate the chat even if users had blocked it. The /unmute-all admin command
// will toggle this setting: then the admin chatbot will appear in the Who's Online list
// as normal and it can see user messages in chat.
unblockable bool
// Record which message IDs belong to this user.
midMu sync.Mutex
messageIDs map[int64]struct{}
// Logging.
log bool
logfh map[string]io.WriteCloser
}
// NewSubscriber initializes a connected chat user.
func (s *Server) NewSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
return &Subscriber{
ctx: ctx,
cancel: cancelFunc,
messages: make(chan []byte, s.subscriberMessageBuffer),
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
blocked: make(map[string]struct{}),
messageIDs: make(map[int64]struct{}),
ChatStatus: "online",
}
}
// NewWebSocketSubscriber returns a new subscriber with a WebSocket connection.
func (s *Server) NewWebSocketSubscriber(ctx context.Context, conn *websocket.Conn, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.conn = conn
sub.closeSlow = func() {
conn.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
}
return sub
}
// NewPollingSubscriber returns a new subscriber using the polling API.
func (s *Server) NewPollingSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.usePolling = true
sub.lastPollAt = time.Now()
sub.lastPollJWT = time.Now()
sub.closeSlow = func() {
// Their outbox is filled up, disconnect them.
log.Error("Polling subscriber %s#%d: inbox is filled up!", sub.Username, sub.ID)
// Send an exit message.
if sub.authenticated && sub.ChatStatus != "hidden" {
sub.authenticated = false
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
s.SendWhoList()
}
s.DeleteSubscriber(sub)
}
return sub
}
// OnClientMessage handles a chat protocol message from the user's WebSocket or polling API.
func (s *Server) OnClientMessage(sub *Subscriber, msg messages.Message) {
// What action are they performing?
switch msg.Action {
case messages.ActionLogin:
s.OnLogin(sub, msg)
case messages.ActionMessage:
s.OnMessage(sub, msg)
case messages.ActionFile:
s.OnFile(sub, msg)
case messages.ActionMe:
s.OnMe(sub, msg)
case messages.ActionOpen:
s.OnOpen(sub, msg)
case messages.ActionBoot:
s.OnBoot(sub, msg, true)
case messages.ActionUnboot:
s.OnBoot(sub, msg, false)
case messages.ActionMute, messages.ActionUnmute:
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
case messages.ActionBlock:
s.OnBlock(sub, msg)
case messages.ActionBlocklist:
s.OnBlocklist(sub, msg)
case messages.ActionCandidate:
s.OnCandidate(sub, msg)
case messages.ActionSDP:
s.OnSDP(sub, msg)
case messages.ActionWatch:
s.OnWatch(sub, msg)
case messages.ActionUnwatch:
s.OnUnwatch(sub, msg)
case messages.ActionTakeback:
s.OnTakeback(sub, msg)
case messages.ActionReact:
s.OnReact(sub, msg)
case messages.ActionReport:
s.OnReport(sub, msg)
case messages.ActionPing:
default:
sub.ChatServer("Unsupported message type: %s", msg.Action)
}
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
func (sub *Subscriber) ReadLoop(s *Server) {
go func() {
for {
msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil {
log.Error("ReadLoop error(%d=%s): %+v", sub.ID, sub.Username, err)
s.DeleteSubscriber(sub)
// Notify if this user was auth'd and not hidden
if sub.authenticated && sub.ChatStatus != "hidden" {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
s.SendWhoList()
}
return
}
if msgType != websocket.MessageText {
log.Error("Unexpected MessageType")
continue
}
// Read the user's posted message.
var msg messages.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Read(%d=%s) Message error: %s", sub.ID, sub.Username, err)
continue
}
if msg.Action != messages.ActionFile {
log.Debug("Read(%d=%s): %s", sub.ID, sub.Username, data)
}
// Handle their message.
s.OnClientMessage(sub, msg)
}
}()
}
// IsAdmin safely checks if the subscriber is an admin.
func (sub *Subscriber) IsAdmin() bool {
return sub.JWTClaims != nil && sub.JWTClaims.IsAdmin
}
// IsVIP safely checks if the subscriber has VIP status.
func (sub *Subscriber) IsVIP() bool {
return sub.JWTClaims != nil && sub.JWTClaims.VIP
}
// SendJSON sends a JSON message to the websocket client.
func (sub *Subscriber) SendJSON(v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
// Add the message to the recipient's queue. If the queue is too full,
// disconnect the client as they can't keep up.
select {
case sub.messages <- data:
default:
go sub.closeSlow()
}
return nil
}
// SendMe sends the current user state to the client.
func (sub *Subscriber) SendMe() {
sub.SendJSON(messages.Message{
Action: messages.ActionMe,
Username: sub.Username,
VideoStatus: sub.VideoStatus,
})
}
// SendCut sends the client a 'cut' message to deactivate their camera.
func (sub *Subscriber) SendCut() {
sub.SendJSON(messages.Message{
Action: messages.ActionCut,
})
}
// ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(message, v...),
})
}
// WebSocket handles the /ws websocket connection endpoint. // WebSocket handles the /ws websocket connection endpoint.
func (s *Server) WebSocket() http.HandlerFunc { func (s *Server) WebSocket() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -318,316 +75,6 @@ func (s *Server) WebSocket() http.HandlerFunc {
}) })
} }
// Auto incrementing Subscriber ID, assigned in AddSubscriber.
var SubscriberID int
// AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: ID #%d", sub.ID)
s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock()
}
// GetSubscriber by username.
func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
for _, sub := range s.IterSubscribers() {
if sub.Username == username {
return sub, nil
}
}
return nil, errors.New("not found")
}
// DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) {
if sub == nil {
return
}
log.Error("DeleteSubscriber: %s", sub.Username)
// Cancel its context to clean up the for-loop goroutine.
if sub.cancel != nil {
log.Info("Calling sub.cancel() on subscriber: %s#%d", sub.Username, sub.ID)
sub.cancel()
}
// Clean up any log files.
sub.teardownLogs()
s.subscribersMu.Lock()
delete(s.subscribers, sub)
s.subscribersMu.Unlock()
}
// IterSubscribers loops over the subscriber list with a read lock.
func (s *Server) IterSubscribers() []*Subscriber {
var result = []*Subscriber{}
// Lock for reads.
s.subscribersMu.RLock()
for sub := range s.subscribers {
result = append(result, sub)
}
s.subscribersMu.RUnlock()
return result
}
// UniqueUsername ensures a username will be unique or renames it. If the name is already unique, the error result is nil.
func (s *Server) UniqueUsername(username string) (string, error) {
var (
subs = s.IterSubscribers()
usernames = map[string]interface{}{}
origUsername = username
counter = 2
)
for _, sub := range subs {
usernames[sub.Username] = nil
}
// Check until unique.
for {
if _, ok := usernames[username]; ok {
username = fmt.Sprintf("%s %d", origUsername, counter)
counter++
} else {
break
}
}
if username != origUsername {
return username, errors.New("username was not unique and a unique name has been returned")
}
return username, nil
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg messages.Message) {
if len(msg.Message) < 1024 {
log.Debug("Broadcast: %+v", msg)
}
// Get the sender of this message.
sender, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error("Broadcast: sender name %s not found as a current subscriber!", msg.Username)
sender = nil
}
// Get the list of users who are online NOW, so we don't hold the mutex lock too long.
// Example: sending a fat GIF to a large audience could hang up the server for a long
// time until every copy of the GIF has been sent.
var subs = s.IterSubscribers()
for _, sub := range subs {
if !sub.authenticated {
continue
}
// Don't deliver it if the receiver has muted us.
if sub.Mutes(msg.Username) {
log.Debug("Do not broadcast message to %s: they have muted or booted %s", sub.Username, msg.Username)
continue
}
// Don't deliver it if there is any blocking between sender and receiver.
if sender != nil && sender.Blocks(sub) {
log.Debug("Do not broadcast message to %s: blocking between them and %s", msg.Username, sub.Username)
continue
}
// VIP channels: only deliver to subscribed VIP users.
if ch, ok := config.Current.GetChannel(msg.Channel); ok && ch.VIP && !sub.IsVIP() && !sub.IsAdmin() {
log.Debug("Do not broadcast message to %s: VIP channel and they are not VIP", sub.Username)
continue
}
sub.SendJSON(msg)
}
}
// SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg messages.Message) error {
log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@")
var found bool
var subs = s.IterSubscribers()
for _, sub := range subs {
if sub.Username == username {
found = true
sub.SendJSON(messages.Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
MessageID: msg.MessageID,
})
}
}
if !found {
return fmt.Errorf("%s is not online", username)
}
return nil
}
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var (
subscribers = s.IterSubscribers()
usernames = []string{} // distinct and sorted usernames
userSub = map[string]*Subscriber{}
)
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
usernames = append(usernames, sub.Username)
userSub[sub.Username] = sub
}
sort.Strings(usernames)
// Build the WhoList for each subscriber.
// TODO: it's the only way to fake videoActive for booted user views.
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
var users = []messages.WhoList{}
for _, un := range usernames {
user := userSub[un]
if user.ChatStatus == "hidden" {
continue
}
// Blocking: hide the presence of both people from the Who List.
if user.Blocks(sub) {
log.Debug("WhoList: hide %s from %s (blocking)", user.Username, sub.Username)
continue
}
who := messages.WhoList{
Username: user.Username,
Status: user.ChatStatus,
Video: user.VideoStatus,
DND: user.DND,
LoginAt: user.loginAt.Unix(),
}
// Hide video flags of other users (never for the current user).
if user.Username != sub.Username {
// If this person had booted us, force their camera to "off"
if user.Boots(sub.Username) || user.Mutes(sub.Username) {
if sub.IsAdmin() {
// They kicked the admin off, but admin can reopen the cam if they want.
// But, unset the user's "auto-open your camera" flag, so if the admin
// reopens it, the admin's cam won't open on the recipient's screen.
who.Video ^= messages.VideoFlagMutualOpen
} else {
// Force their video to "off"
who.Video = 0
}
}
// If this person's VideoFlag is set to VIP Only, force their camera to "off"
// except when the person looking has the VIP status.
if (user.VideoStatus&messages.VideoFlagOnlyVIP == messages.VideoFlagOnlyVIP) && !sub.IsVIP() {
who.Video = 0
}
}
if user.JWTClaims != nil {
who.Operator = user.JWTClaims.IsAdmin
who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = user.JWTClaims.ProfileURL
who.Nickname = user.JWTClaims.Nick
who.Emoji = user.JWTClaims.Emoji
who.Gender = user.JWTClaims.Gender
// VIP flags: if we are in MutuallySecret mode, only VIPs can see
// other VIP flags on the Who List.
if config.Current.VIP.MutuallySecret {
if sub.IsVIP() {
who.VIP = user.JWTClaims.VIP
}
} else {
who.VIP = user.JWTClaims.VIP
}
}
users = append(users, who)
}
sub.SendJSON(messages.Message{
Action: messages.ActionWhoList,
WhoList: users,
})
}
}
// Boots checks whether the subscriber has blocked username from their camera.
func (s *Subscriber) Boots(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.booted[username]
return ok
}
// Mutes checks whether the subscriber has muted username.
func (s *Subscriber) Mutes(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.muted[username]
return ok
}
// Blocks checks whether the subscriber blocks the username, or vice versa (blocking goes both directions).
func (s *Subscriber) Blocks(other *Subscriber) bool {
if s == nil || other == nil {
return false
}
// Admin blocking behavior: by default, admins are NOT blockable by users and retain visibility on
// chat, especially to moderate webcams (messages may still be muted between blocked users).
//
// If your chat server allows admins to be blockable:
if !config.Current.BlockableAdmins && (s.IsAdmin() || other.IsAdmin()) {
return false
} else {
// Admins are blockable, unless they have the unblockable flag - e.g. if you have an admin chatbot on
// your server it will send the `/unmute-all` command to still retain visibility into user messages for
// auto-moderation. The `/unmute-all` sets the unblockable flag, so your admin chatbot still appears
// on the Who's Online list as well.
unblockable := (s.IsAdmin() && s.unblockable) || (other.IsAdmin() && other.unblockable)
if unblockable {
return false
}
}
s.muteMu.RLock()
defer s.muteMu.RUnlock()
// Forward block?
if _, ok := s.blocked[other.Username]; ok {
return true
}
// Reverse block?
other.muteMu.RLock()
defer other.muteMu.RUnlock()
_, ok := other.blocked[s.Username]
return ok
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error { func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()

View File

@ -38,6 +38,10 @@ img {
background-color: rgb(250, 250, 192); background-color: rgb(250, 250, 192);
} }
.has-text-private {
color: #CC00CC !important;
}
/* Truncate long text, e.g. usernames in the who list */ /* Truncate long text, e.g. usernames in the who list */
.truncate-text-line { .truncate-text-line {
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@ -120,7 +120,8 @@ export default {
jwt: { jwt: {
token: UserJWTToken, token: UserJWTToken,
valid: UserJWTValid, valid: UserJWTValid,
claims: UserJWTClaims claims: UserJWTClaims,
rules: UserJWTRules
}, },
channel: "lobby", channel: "lobby",
@ -420,7 +421,7 @@ export default {
window.addEventListener("click", () => { window.addEventListener("click", () => {
this.setupSounds(); this.setupSounds();
}); });
window.addEventListener("keydown", () => { window.addEventListener("keyup", () => {
this.setupSounds(); this.setupSounds();
}); });
@ -663,6 +664,9 @@ export default {
return status === null || status.name !== "online"; return status === null || status.name !== "online";
}, },
canUploadFile() { canUploadFile() {
// User has the NoImage rule set.
if (this.jwt.rules.IsNoImageRule) return false;
// Public channels: check whether it PermitsPhotos. // Public channels: check whether it PermitsPhotos.
if (!this.isDM) { if (!this.isDM) {
for (let cfg of this.config.channels) { for (let cfg of this.config.channels) {
@ -1157,6 +1161,9 @@ export default {
// Emoji reactions // Emoji reactions
sendReact(message, emoji) { sendReact(message, emoji) {
// Suppress reactions on restricted messages (e.g. when NoImage rule enabled and user did not see the image)
if (message.message.indexOf("barertc-no-emoji-reactions") > -1) return;
this.client.send({ this.client.send({
action: 'react', action: 'react',
msgID: message.msgID, msgID: message.msgID,
@ -2106,6 +2113,14 @@ export default {
startVideo({ force = false, changeCamera = false }) { startVideo({ force = false, changeCamera = false }) {
if (this.webcam.busy) return; if (this.webcam.busy) return;
// Is a moderation rule in place?
if (this.jwt.rules.IsNoBroadcastRule) {
return this.modalAlert({
title: "Broadcasting video is not allowed for you",
message: "A chat room moderation rule is currently in place which restricts your ability to broadcast your webcam.\n\nPlease contact a chat operator for more information.",
});
}
// Before they go on cam the first time, ATTEMPT to get their device names. // Before they go on cam the first time, ATTEMPT to get their device names.
// - If they had never granted permission, we won't get the names of // - If they had never granted permission, we won't get the names of
// the devices and no big deal. // the devices and no big deal.
@ -2332,6 +2347,15 @@ export default {
return; return;
} }
// A chat moderation rule?
if (this.jwt.rules.IsNoVideoRule) {
return this.modalAlert({
title: "Videos are not available to you",
message: "A chat room moderation rule is currently in place which restricts your ability to watch webcams.\n\n" +
"Please contact a chat operator for more information.",
});
}
// If we have muted the target, we shouldn't view their video. // If we have muted the target, we shouldn't view their video.
if (this.isMutedUser(user.username) && !this.isOp) { if (this.isMutedUser(user.username) && !this.isOp) {
this.ChatClient(`You have muted <strong>${user.username}</strong> and so should not see their camera.`); this.ChatClient(`You have muted <strong>${user.username}</strong> and so should not see their camera.`);
@ -2581,6 +2605,9 @@ export default {
isVideoNotAllowed(user) { isVideoNotAllowed(user) {
// Returns whether the video button to open a user's cam will be not allowed (crossed out) // Returns whether the video button to open a user's cam will be not allowed (crossed out)
// If the user is under the NoVideo rule, always cross it out.
if (this.jwt.rules.IsNoVideoRule) return true;
// Mutual video sharing is required on this camera, and ours is not active // Mutual video sharing is required on this camera, and ours is not active
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired)) { if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired)) {
// A nuance to the mutual video required: if we DO have our cam on, but ours is VIP only, and the // A nuance to the mutual video required: if we DO have our cam on, but ours is VIP only, and the
@ -3004,7 +3031,15 @@ export default {
// Image handling per the user's preference. // Image handling per the user's preference.
if (message.indexOf("<img") > -1) { if (message.indexOf("<img") > -1) {
if (this.imageDisplaySetting === "hide") { if (this.jwt.rules.IsNoImageRule) {
// User is under the NoImage moderation rule.
message = `
<span class="has-text-danger barertc-no-emoji-reactions">
<i class="fa fa-image mr-1"></i>
An image was shared, but is not visible to you due to a chat moderation rule on your account.
</span>`;
} else if (this.imageDisplaySetting === "hide") {
// User hides all images in their chat preferences.
return; return;
} else if (this.imageDisplaySetting === "collapse") { } else if (this.imageDisplaySetting === "collapse") {
// Put a collapser link. // Put a collapser link.