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:
parent
bbd6836c68
commit
16b148fc92
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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}};
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
65
pkg/jwt/rules.go
Normal 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
39
pkg/moderation_rules.go
Normal 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
565
pkg/subscriber.go
Normal 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
|
||||||
|
}
|
553
pkg/websocket.go
553
pkg/websocket.go
|
@ -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()
|
||||||
|
|
|
@ -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;
|
||||||
|
|
41
src/App.vue
41
src/App.vue
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue
Block a user