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
|
||||
"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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}};
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 <img src="data:"> 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 {
|
||||
|
|
|
@ -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
|
||||
|
|
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 (
|
||||
"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()
|
||||
|
|
|
@ -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;
|
||||
|
|
41
src/App.vue
41
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 <strong>${user.username}</strong> 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("<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;
|
||||
} else if (this.imageDisplaySetting === "collapse") {
|
||||
// Put a collapser link.
|
||||
|
|
Loading…
Reference in New Issue
Block a user