BareRTC/pkg/handlers.go
Noah Petherbridge 9c77bdb62e New Op commands and fixes with blocking admin users
Add moderation rules:

* You can apply rules in the settings.toml to enforce moderator restrictions on
  certain users, e.g. to force their camera to always be NSFW or bar them from
  sharing their webcam at all anymore.

Chat UI improvements around users blocking admin accounts:

* When a main website block is in place, the DMs button in the Who List shows
  as greyed out with a cross through, as if that user had closed their DMs.
* Admin users are always able to watch the camera of people who have blocked
  them. The broadcaster is not notified about the watch.

New operator commands:

* /cut username: to tell a user to turn off their webcam.
* /unmute-all: to lift all mutes on your side, e.g. so your moderator chatbot
  can still see public messages from users who have blocked it.
* /help-advanced: moved the more dangerous admin command documentation here.

Miscellaneous fixes:

* The admin commands now tolerate an @ prefix in front of usernames.
* The /nsfw command won't fire unless the user's camera is actually active and
  not marked as explicit.
2024-05-17 17:15:48 -07:00

720 lines
21 KiB
Go

package barertc
import (
"encoding/base64"
"fmt"
"path/filepath"
"strings"
"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/models"
"git.kirsle.net/apps/barertc/pkg/util"
)
// OnLogin handles "login" actions from the client.
func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
// Using a JWT token for authentication?
var claims = &jwt.Claims{}
if msg.JWTToken != "" || (config.Current.JWT.Enabled && config.Current.JWT.Strict) {
parsed, ok, err := jwt.ParseAndValidate(msg.JWTToken)
if err != nil {
log.Error("Error parsing JWT token in WebSocket login: %s", err)
sub.ChatServer("Your authentication has expired. Please go back and launch the chat room again.")
return
}
// Sanity check the username.
if msg.Username != parsed.Subject {
log.Error("JWT login had a different username: %s vs %s", parsed.Subject, msg.Username)
}
// Strict enforcement?
if config.Current.JWT.Strict && !ok {
log.Error("JWT enforcement is strict and user did not pass JWT checks")
sub.ChatServer("Server side authentication is required. Please go back and launch the chat room from your logged-in account.")
return
}
claims = parsed
msg.Username = claims.Subject
sub.JWTClaims = claims
}
if claims.Subject != "" {
log.Debug("JWT claims: %+v", claims)
}
// Somehow no username?
if msg.Username == "" {
msg.Username = "anonymous"
}
// Ensure the username is unique, or rename it.
username, err := s.UniqueUsername(msg.Username)
if err != nil {
// If JWT authentication was used: disconnect the original (conflicting) username.
if claims.Subject == msg.Username {
if other, err := s.GetSubscriber(msg.Username); err == nil {
other.ChatServer("You have been signed out of chat because you logged in from another location.")
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
other.authenticated = false
other.Username = ""
}
// They will take over their original username.
username = msg.Username
}
// If JWT auth was not used: UniqueUsername already gave them a uniquely spelled name.
}
msg.Username = username
// Is the username currently banned?
if IsBanned(msg.Username) {
sub.ChatServer(
"You are currently banned from entering the chat room. Chat room bans are temporarily and usually last for " +
"24 hours. Please try coming back later.",
)
sub.SendJSON(messages.Message{
Action: messages.ActionKick,
})
return
}
// Use their username.
sub.Username = msg.Username
sub.authenticated = true
sub.DND = msg.DND
sub.loginAt = time.Now()
log.Debug("OnLogin: %s joins the room", sub.Username)
// Tell everyone they joined.
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: msg.Username,
Message: messages.PresenceJoined,
})
// Send the user back their settings.
sub.SendMe()
// Send the WhoList to everybody.
s.SendWhoList()
// Send the initial ChatServer messages to the public channels.
for _, channel := range config.Current.PublicChannels {
for _, msg := range channel.WelcomeMessages {
sub.SendJSON(messages.Message{
Channel: channel.ID,
Action: messages.ActionError,
Username: "ChatServer",
Message: RenderMarkdown(msg),
})
}
}
}
// OnMessage handles a chat message posted by the user.
func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
if !strings.HasPrefix(msg.Channel, "@") {
log.Info("[%s to #%s] %s", sub.Username, msg.Channel, msg.Message)
}
if sub.Username == "" {
sub.ChatServer("You must log in first.")
return
}
// Process commands.
if handled := s.ProcessCommand(sub, msg); handled {
return
}
// Translate their message as Markdown syntax.
markdown := RenderMarkdown(msg.Message)
if markdown == "" {
return
}
// Detect and expand media such as YouTube videos.
markdown = s.ExpandMedia(markdown)
// Assign a message ID and own it to the sender.
sub.midMu.Lock()
var mid = messages.NextMessageID()
sub.messageIDs[mid] = struct{}{}
sub.midMu.Unlock()
// Message to be echoed to the channel.
var message = messages.Message{
Action: messages.ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
Message: markdown,
MessageID: mid,
}
// Run message filters.
if filter, ok := s.filterMessage(sub, msg, &message); ok {
// What do we do with the matched filter?
// If we will not send this message out, do echo it back to
// the sender (possibly with censors applied).
if !filter.ForwardMessage {
s.SendTo(sub.Username, message)
}
// Is ChatServer to say something?
if filter.ChatServerResponse != "" {
sub.ChatServer(filter.ChatServerResponse)
}
// Are we to report the message to the site admin?
if filter.ReportMessage {
// If the user is OP, just tell them we would.
if sub.IsAdmin() {
sub.ChatServer("Your recent chat context would have been reported to your main website.")
return
}
// Send the report to the main website.
if err := s.reportFilteredMessage(sub, msg); err != nil {
log.Error("Reporting filtered message: %s", err)
}
}
// If we are not forwarding this message, stop here.
if !filter.ForwardMessage {
return
}
}
// Is this a DM?
if strings.HasPrefix(msg.Channel, "@") {
// Echo the message only to both parties.
s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username
// Don't deliver it if the receiver has muted us. Note: admin users, even if muted,
// can still deliver a DM to the one who muted them.
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
if err == nil && rcpt.Mutes(sub.Username) && !sub.IsAdmin() {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
} else if err != nil {
// Recipient was no longer online: the message won't be sent.
sub.ChatServer("Could not deliver your message: %s appears not to be online.", msg.Channel)
return
}
// If the sender already mutes the recipient, reply back with the error.
if err == nil && sub.Mutes(rcpt.Username) && !sub.IsAdmin() {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
// If there is blocking happening, do not send.
if sub.Blocks(rcpt) {
return
}
// Log this conversation?
if IsLoggingUsername(sub) && IsLoggingUsername(rcpt) {
// Both sides are logged, copy it to both logs.
LogMessage(sub, rcpt.Username, sub.Username, msg)
LogMessage(rcpt, sub.Username, sub.Username, msg)
} else if IsLoggingUsername(sub) {
// The sender of this message is being logged.
LogMessage(sub, rcpt.Username, sub.Username, msg)
} else if IsLoggingUsername(rcpt) {
// The recipient of this message is being logged.
LogMessage(rcpt, sub.Username, sub.Username, msg)
}
// Add it to the DM history SQLite database.
if err := (models.DirectMessage{}).LogMessage(sub.Username, rcpt.Username, message); err != nil && err != models.ErrNotInitialized {
log.Error("Logging DM history to SQLite: %s", err)
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
return
}
// Are we logging this public channel?
if IsLoggingChannel(msg.Channel) {
LogChannel(s, msg.Channel, sub.Username, msg)
}
// Broadcast a chat message to the room.
s.Broadcast(message)
}
// OnTakeback handles takebacks (delete your message for everybody)
func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
// In case we're in a DM thread, remove this message ID from the history table
// if the username matches.
wasRemovedFromHistory, err := (models.DirectMessage{}).TakebackMessage(sub.Username, msg.MessageID, sub.IsAdmin())
if err != nil && err != models.ErrNotInitialized {
log.Error("Error taking back DM history message (%s, %d): %s", sub.Username, msg.MessageID, err)
}
// Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock()
_, ok := sub.messageIDs[msg.MessageID]
sub.midMu.Unlock()
if !ok {
// The messageID is not found in the current chat session, but did we remove
// it from past DM history for the correct current user?
if !wasRemovedFromHistory {
sub.ChatServer("That is not your message to take back.")
return
}
}
}
// Broadcast to everybody to remove this message.
s.Broadcast(messages.Message{
Action: messages.ActionTakeback,
MessageID: msg.MessageID,
})
}
// OnReact handles emoji reactions for chat messages.
func (s *Server) OnReact(sub *Subscriber, msg messages.Message) {
// Forward the reaction to everybody.
s.Broadcast(messages.Message{
Action: messages.ActionReact,
Username: sub.Username,
Message: msg.Message,
MessageID: msg.MessageID,
})
}
// OnFile handles a picture shared in chat with a channel.
func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
if sub.Username == "" {
sub.ChatServer("You must log in first.")
return
}
// Detect image type and convert it into an <img src="data:"> tag.
var (
filename = msg.Message
ext = filepath.Ext(filename)
filetype string
)
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
filetype = "image/jpeg"
case ".gif":
filetype = "image/gif"
case ".png":
filetype = "image/png"
default:
sub.ChatServer("Unsupported image type, should be a jpeg, GIF or png.")
return
}
// Process the image: scale it down, strip metadata, etc.
img, pvWidth, pvHeight := ProcessImage(filetype, msg.Bytes)
var dataURL = fmt.Sprintf("data:%s;base64,%s", filetype, base64.StdEncoding.EncodeToString(img))
// Assign a message ID and own it to the sender.
sub.midMu.Lock()
var mid = messages.NextMessageID()
sub.messageIDs[mid] = struct{}{}
sub.midMu.Unlock()
// Message to be echoed to the channel.
var message = messages.Message{
Action: messages.ActionMessage,
Channel: msg.Channel,
Username: sub.Username,
MessageID: mid,
// Their image embedded via a data: URI - no server storage needed!
Message: fmt.Sprintf(
`<img src="%s" width="%d" height="%d" onclick="setModalImage(this.src)" style="cursor: pointer">`,
dataURL,
pvWidth, pvHeight,
),
}
// Is this a DM?
if strings.HasPrefix(msg.Channel, "@") {
// Echo the message only to both parties.
s.SendTo(sub.Username, message)
message.Channel = "@" + sub.Username
// Don't deliver it if the receiver has muted us.
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
if err == nil && rcpt.Mutes(sub.Username) {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
}
// If the sender already mutes the recipient, reply back with the error.
if sub.Mutes(rcpt.Username) {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
// If there is blocking happening, do not send.
if sub.Blocks(rcpt) {
return
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
return
}
// Broadcast a chat message to the room.
s.Broadcast(message)
}
// OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Reflect a 'me' message back at them? (e.g. if server forces their camera NSFW)
var reflect bool
if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
log.Debug("User %s turns on their video feed", sub.Username)
// Moderation rules?
if rule := config.Current.GetModerationRule(sub.Username); rule != nil {
// Are they barred from sharing their camera on chat?
if rule.DisableCamera {
sub.SendCut()
sub.ChatServer(
"A chat server moderation rule is currently in place which restricts your ability to share your webcam. Please " +
"contact a chat operator for more information.",
)
msg.VideoStatus = 0
}
// Is their camera forced to always be explicit?
if rule.CameraAlwaysNSFW && !(msg.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW) {
msg.VideoStatus |= messages.VideoFlagNSFW
reflect = true // send them a 'me' echo afterward to inform the front-end page properly of this
sub.ChatServer(
"A chat server moderation rule is currently in place which forces your camera to stay marked as Explicit. Please " +
"contact a chat moderator if you have any questions about this.",
)
}
}
}
// Hidden status: for operators only, + fake a join/exit chat message.
if sub.JWTClaims != nil && sub.JWTClaims.IsAdmin {
if sub.ChatStatus != "hidden" && msg.ChatStatus == "hidden" {
// Going hidden - fake leave message
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
// Leaving hidden - fake join message
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceJoined,
})
}
} else if msg.ChatStatus == "hidden" {
// normal users can not set this status
msg.ChatStatus = "away"
}
sub.VideoStatus = msg.VideoStatus
sub.ChatStatus = msg.ChatStatus
sub.DND = msg.DND
// Sync the WhoList to everybody.
s.SendWhoList()
// Reflect a 'me' message back?
if reflect {
sub.SendMe()
}
}
// 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) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
return
}
// Enforce whether the viewer has permission to see this camera.
if ok, reason := s.IsVideoNotAllowed(sub, other); !ok {
sub.ChatServer(
"Could not open that video: %s", reason,
)
return
}
// Make up a WebRTC shared secret and send it to both of them.
secret := util.RandomString(16)
log.Info("WebRTC: %s opens %s with secret %s", sub.Username, other.Username, secret)
// If the current user is an admin and was booted or muted, inform them.
if sub.IsAdmin() {
if other.Boots(sub.Username) {
sub.ChatServer("Note: %s had booted you off their camera before, and won't be notified of your watch.", other.Username)
} else if other.Mutes(sub.Username) {
sub.ChatServer("Note: %s had muted you before, and won't be notified of your watch.", other.Username)
}
}
// Ring the target of this request and give them the secret.
other.SendJSON(messages.Message{
Action: messages.ActionRing,
Username: sub.Username,
OpenSecret: secret,
})
// To the caller, echo back the Open along with the secret.
sub.SendJSON(messages.Message{
Action: messages.ActionOpen,
Username: other.Username,
OpenSecret: secret,
})
}
// IsVideoNotAllowed verifies whether a viewer can open a broadcaster's camera.
//
// Returns a boolean and an error message to return if false.
func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, string) {
var (
ourVideoActive = (sub.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
theirVideoActive = (other.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
theirMutualRequired = (other.VideoStatus & messages.VideoFlagMutualRequired) == messages.VideoFlagMutualRequired
theirVIPRequired = (other.VideoStatus & messages.VideoFlagOnlyVIP) == messages.VideoFlagOnlyVIP
)
// Conditions in which we can not watch their video.
var conditions = []struct {
If bool
Error string
}{
{
If: !theirVideoActive,
Error: "Their video is not currently enabled.",
},
{
If: theirMutualRequired && !ourVideoActive,
Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username),
},
{
If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
{
If: (other.Mutes(sub.Username) || other.Blocks(sub)) && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
}
for _, c := range conditions {
if c.If {
return false, c.Error
}
}
return true, ""
}
// OnBoot is a user kicking you off their video stream.
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) {
sub.muteMu.Lock()
if boot {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
sub.booted[msg.Username] = struct{}{}
// If the subject of the boot is an admin, inform them they have been booted.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
other.ChatServer(
"%s has booted you off of their camera!",
sub.Username,
)
}
} else {
log.Info("%s unboots %s from their camera", sub.Username, msg.Username)
delete(sub.booted, msg.Username)
}
sub.muteMu.Unlock()
s.SendWhoList()
}
// OnMute is a user kicking setting the mute flag for another user.
func (s *Server) OnMute(sub *Subscriber, msg messages.Message, mute bool) {
log.Info("%s mutes or unmutes %s: %v", sub.Username, msg.Username, mute)
sub.muteMu.Lock()
if mute {
sub.muted[msg.Username] = struct{}{}
} else {
delete(sub.muted, msg.Username)
}
sub.muteMu.Unlock()
// If the subject of the mute is an admin, inform them they have been booted.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
other.ChatServer(
"%s has muted you! Your new mute status is: %v",
sub.Username, mute,
)
}
// Send the Who List in case our cam will show as disabled to the muted party.
s.SendWhoList()
}
// OnBlock is a user placing a hard block (hide from) another user.
func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
log.Info("%s blocks %s: %v", sub.Username, msg.Username)
// If the subject of the block is an admin, return an error.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
sub.ChatServer(
"You are not allowed to block a chat operator.",
)
return
}
sub.muteMu.Lock()
sub.blocked[msg.Username] = struct{}{}
sub.muteMu.Unlock()
// Send the Who List so the blocker/blockee can disappear from each other's list.
s.SendWhoList()
}
// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website.
func (s *Server) OnBlocklist(sub *Subscriber, msg messages.Message) {
log.Info("[%s] syncs their blocklist: %s", sub.Username, msg.Usernames)
sub.muteMu.Lock()
for _, username := range msg.Usernames {
sub.muted[username] = struct{}{}
sub.blocked[username] = struct{}{}
}
sub.muteMu.Unlock()
// Send the Who List in case our cam will show as disabled to the muted party.
s.SendWhoList()
}
// OnReport handles a user's report of a message.
func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
if !WebhookEnabled(WebhookReport) {
sub.ChatServer("Unfortunately, the report webhook is not enabled so your report could not be received!")
return
}
// Attach recent message context to DMs.
if strings.HasPrefix(msg.Channel, "@") {
context := getDirectMessageContext(sub.Username, msg.Username)
msg.Message += "\n\nRecent message context:\n\n" + context
}
// Post to the report webhook.
if _, err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
APIKey: config.Current.AdminAPIKey,
Report: WebhookRequestReport{
FromUsername: sub.Username,
AboutUsername: msg.Username,
Channel: msg.Channel,
Timestamp: msg.Timestamp,
Reason: msg.Reason,
Message: msg.Message,
Comment: msg.Comment,
},
}); err != nil {
sub.ChatServer("Error sending the report to the website: %s", err)
} else {
sub.ChatServer("Your report has been delivered successfully.")
}
}
// OnCandidate handles WebRTC candidate signaling.
func (s *Server) OnCandidate(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
return
}
other.SendJSON(messages.Message{
Action: messages.ActionCandidate,
Username: sub.Username,
Candidate: msg.Candidate,
})
}
// OnSDP handles WebRTC sdp signaling.
func (s *Server) OnSDP(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
return
}
other.SendJSON(messages.Message{
Action: messages.ActionSDP,
Username: sub.Username,
Description: msg.Description,
})
}
// OnWatch communicates video watching status between users.
func (s *Server) OnWatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
return
}
other.SendJSON(messages.Message{
Action: messages.ActionWatch,
Username: sub.Username,
})
}
// OnUnwatch communicates video Unwatching status between users.
func (s *Server) OnUnwatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
return
}
other.SendJSON(messages.Message{
Action: messages.ActionUnwatch,
Username: sub.Username,
})
}