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.