BareRTC/pkg/handlers.go
Noah Petherbridge f75ad32728 Ban command update, join/leave messages
* The /ban command doesn't require the target user to be online at the
  time of the ban.
* Update the presence messages so they will generally only go to the
  primary (first) public channel, and also to another public channel if
  the user is currently looking at one of the others.
2023-12-16 15:10:48 -08:00

668 lines
19 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/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: "has joined the room!",
})
// 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.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)
}
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) {
// Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock()
_, ok := sub.messageIDs[msg.MessageID]
sub.midMu.Unlock()
if !ok {
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) {
if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
log.Debug("User %s turns on their video feed", sub.Username)
}
// 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: "has exited the room!",
})
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
// Leaving hidden - fake join message
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has joined the room!",
})
}
} 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()
}
// 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 {
log.Error(err.Error())
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(),
Error: "You do not have permission to view that camera.",
},
{
If: other.Mutes(sub.Username) || other.Blocks(sub),
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
}
// 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 {
log.Error(err.Error())
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 {
log.Error(err.Error())
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 {
log.Error(err.Error())
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 {
log.Error(err.Error())
return
}
other.SendJSON(messages.Message{
Action: messages.ActionUnwatch,
Username: sub.Username,
})
}