From 16b148fc929bfced448ce438e3521bd0afdc68b8 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 18 Sep 2024 22:16:33 -0700 Subject: [PATCH] JWT Token Chat Moderation Rules Add support for your website to apply chat moderation rules to users as they log onto the chat room. --- docs/Authentication.md | 3 + docs/Configuration.md | 33 ++- index.html | 1 + pkg/config/config.go | 4 +- pkg/handlers.go | 32 ++- pkg/jwt/jwt.go | 1 + pkg/jwt/rules.go | 65 +++++ pkg/moderation_rules.go | 39 +++ pkg/subscriber.go | 565 +++++++++++++++++++++++++++++++++++++ pkg/websocket.go | 553 ------------------------------------ public/static/css/chat.css | 4 + src/App.vue | 41 ++- 12 files changed, 780 insertions(+), 561 deletions(-) create mode 100644 pkg/jwt/rules.go create mode 100644 pkg/moderation_rules.go create mode 100644 pkg/subscriber.go diff --git a/docs/Authentication.md b/docs/Authentication.md index 8366ab7..4b045bc 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -25,6 +25,7 @@ Configure a shared secret key (random text string) in both the BareRTC settings "url": "/u/username", // user profile URL "gender": "m", // gender (m, f, o) "emoji": "🤖", // emoji icon + "rules": ["redcam", "noimage"], // moderation rules (optional) // Standard JWT claims that we support: "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. * Robot emojis, to indicate bot users. * 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 diff --git a/docs/Configuration.md b/docs/Configuration.md index d1ecf87..6866ad3 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -70,7 +70,9 @@ PreviewImageWidth = 360 [[ModerationRule]] Username = "example" CameraAlwaysNSFW = true - DisableCamera = false + NoBroadcast = false + NoVideo = false + NoImage = false [DirectMessageHistory] Enabled = true @@ -155,7 +157,34 @@ Settings in the `[[ModerationRule]]` array include: * **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. -* **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 diff --git a/index.html b/index.html index 86d63b5..2ddb06c 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,7 @@ const UserJWTToken = {{.JWTTokenString}}; const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}}; const UserJWTClaims = {{.JWTClaims.ToJSON}}; + const UserJWTRules = {{.JWTClaims.Rules.ToDict}}; const CachedBlocklist = {{.CachedBlocklist}}; const CacheHash = {{.CacheHash}}; diff --git a/pkg/config/config.go b/pkg/config/config.go index aa2e328..c35b577 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -125,7 +125,9 @@ type Logging struct { type ModerationRule struct { Username string CameraAlwaysNSFW bool - DisableCamera bool + NoBroadcast bool + NoVideo bool + NoImage bool } // Current loaded configuration. diff --git a/pkg/handlers.go b/pkg/handlers.go index c1c5fb1..cd0b258 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -304,6 +304,20 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) { 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 tag. var ( 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) // 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? - if rule.DisableCamera { + if rule.NoBroadcast || rule.NoVideo { sub.SendCut() sub.ChatServer( "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. 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. other, err := s.GetSubscriber(msg.Username) if err != nil { diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go index 377ccae..cdc28b9 100644 --- a/pkg/jwt/jwt.go +++ b/pkg/jwt/jwt.go @@ -20,6 +20,7 @@ type Claims struct { Nick string `json:"nick,omitempty"` Emoji string `json:"emoji,omitempty"` Gender string `json:"gender,omitempty"` + Rules Rules `json:"rules,omitempty"` // Standard claims. Notes: // subject = username diff --git a/pkg/jwt/rules.go b/pkg/jwt/rules.go new file mode 100644 index 0000000..677159d --- /dev/null +++ b/pkg/jwt/rules.go @@ -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 +} diff --git a/pkg/moderation_rules.go b/pkg/moderation_rules.go new file mode 100644 index 0000000..91615c5 --- /dev/null +++ b/pkg/moderation_rules.go @@ -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 +} diff --git a/pkg/subscriber.go b/pkg/subscriber.go new file mode 100644 index 0000000..cf44025 --- /dev/null +++ b/pkg/subscriber.go @@ -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 +} diff --git a/pkg/websocket.go b/pkg/websocket.go index e8ec33d..c347969 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -2,260 +2,17 @@ package barertc import ( "context" - "encoding/json" - "errors" "fmt" - "io" "net/http" - "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" "git.kirsle.net/apps/barertc/pkg/util" "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. func (s *Server) WebSocket() http.HandlerFunc { 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 { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() diff --git a/public/static/css/chat.css b/public/static/css/chat.css index b81bad6..450dfba 100644 --- a/public/static/css/chat.css +++ b/public/static/css/chat.css @@ -38,6 +38,10 @@ img { background-color: rgb(250, 250, 192); } +.has-text-private { + color: #CC00CC !important; +} + /* Truncate long text, e.g. usernames in the who list */ .truncate-text-line { text-overflow: ellipsis; diff --git a/src/App.vue b/src/App.vue index 6ce8adb..7bc25b2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -120,7 +120,8 @@ export default { jwt: { token: UserJWTToken, valid: UserJWTValid, - claims: UserJWTClaims + claims: UserJWTClaims, + rules: UserJWTRules }, channel: "lobby", @@ -420,7 +421,7 @@ export default { window.addEventListener("click", () => { this.setupSounds(); }); - window.addEventListener("keydown", () => { + window.addEventListener("keyup", () => { this.setupSounds(); }); @@ -663,6 +664,9 @@ export default { return status === null || status.name !== "online"; }, canUploadFile() { + // User has the NoImage rule set. + if (this.jwt.rules.IsNoImageRule) return false; + // Public channels: check whether it PermitsPhotos. if (!this.isDM) { for (let cfg of this.config.channels) { @@ -1157,6 +1161,9 @@ export default { // Emoji reactions 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({ action: 'react', msgID: message.msgID, @@ -2106,6 +2113,14 @@ export default { startVideo({ force = false, changeCamera = false }) { 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. // - If they had never granted permission, we won't get the names of // the devices and no big deal. @@ -2332,6 +2347,15 @@ export default { 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 (this.isMutedUser(user.username) && !this.isOp) { this.ChatClient(`You have muted ${user.username} and so should not see their camera.`); @@ -2581,6 +2605,9 @@ export default { isVideoNotAllowed(user) { // 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 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 @@ -3004,7 +3031,15 @@ export default { // Image handling per the user's preference. if (message.indexOf(" -1) { - if (this.imageDisplaySetting === "hide") { + if (this.jwt.rules.IsNoImageRule) { + // User is under the NoImage moderation rule. + message = ` + + + An image was shared, but is not visible to you due to a chat moderation rule on your account. + `; + } else if (this.imageDisplaySetting === "hide") { + // User hides all images in their chat preferences. return; } else if (this.imageDisplaySetting === "collapse") { // Put a collapser link.