diff --git a/cmd/BareRTC/main.go b/cmd/BareRTC/main.go index 4813b62..1e8119e 100644 --- a/cmd/BareRTC/main.go +++ b/cmd/BareRTC/main.go @@ -2,6 +2,7 @@ package main import ( "flag" + "fmt" "math/rand" "time" @@ -29,7 +30,9 @@ func main() { } // Load configuration. - config.LoadSettings() + if err := config.LoadSettings(); err != nil { + panic(fmt.Sprintf("Error loading settings.toml: %s", err)) + } app := barertc.NewServer() app.Setup() diff --git a/pkg/commands.go b/pkg/commands.go index 41301b9..7468016 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -72,6 +72,9 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { case "/kickall": s.KickAllCommand() return true + case "/reconfigure": + s.ReconfigureCommand(sub) + return true case "/op": s.OpCommand(words, sub) return true @@ -259,6 +262,17 @@ func (s *Server) BansCommand(words []string, sub *Subscriber) { ) } +// ReconfigureCommand handles the `/reconfigure` operator command. +func (s *Server) ReconfigureCommand(sub *Subscriber) { + // Reload the settings. + if err := config.LoadSettings(); err != nil { + sub.ChatServer("Error reloading the server config: %s", err) + return + } + + sub.ChatServer("The server config file has been reloaded successfully!") +} + // OpCommand handles the `/op` operator command. func (s *Server) OpCommand(words []string, sub *Subscriber) { if len(words) == 1 { diff --git a/pkg/config/config.go b/pkg/config/config.go index cbe08f5..67b62f4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,7 +13,7 @@ import ( // Version of the config format - when new fields are added, it will attempt // to write the settings.toml to disk so new defaults populate. -var currentVersion = 5 +var currentVersion = 6 // Config for your BareRTC app. type Config struct { @@ -43,6 +43,8 @@ type Config struct { TURN TurnConfig PublicChannels []Channel + + WebhookURLs []WebhookURL } type TurnConfig struct { @@ -67,6 +69,13 @@ type Channel struct { WelcomeMessages []string } +// WebhookURL allows tighter integration with your website. +type WebhookURL struct { + Name string + Enabled bool + URL string +} + // Current loaded configuration. var Current = DefaultConfig() @@ -107,6 +116,12 @@ func DefaultConfig() Config { "stun:stun.l.google.com:19302", }, }, + WebhookURLs: []WebhookURL{ + { + Name: "report", + URL: "https://example.com/barertc/report", + }, + }, } c.JWT.Strict = true return c @@ -126,6 +141,9 @@ func LoadSettings() error { } _, err = toml.Decode(string(data), &Current) + if err != nil { + return err + } // Have we added new config fields? Save the settings.toml. if Current.Version != currentVersion { diff --git a/pkg/handlers.go b/pkg/handlers.go index ecec39f..979bd74 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -429,6 +429,33 @@ func (s *Server) OnBlocklist(sub *Subscriber, msg Message) { s.SendWhoList() } +// OnReport handles a user's report of a message. +func (s *Server) OnReport(sub *Subscriber, msg 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 Message) { // Look up the other subscriber. diff --git a/pkg/messages.go b/pkg/messages.go index bdfdfe5..316afc0 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -38,6 +38,11 @@ type Message struct { // Send on `blocklist` actions, for doing a `mute` on a list of users Usernames []string `json:"usernames,omitempty"` + // Sent on `report` actions. + Timestamp string `json:"timestamp,omitempty"` + Reason string `json:"reason,omitempty"` + Comment string `json:"comment,omitempty"` + // WebRTC negotiation messages: proxy their signaling messages // between the two users to negotiate peer connection. Candidate string `json:"candidate,omitempty"` // candidate @@ -51,6 +56,7 @@ const ( ActionMute = "mute" // mute a user's chat messages ActionUnmute = "unmute" ActionBlocklist = "blocklist" // mute in bulk for usernames + ActionReport = "report" // user reports a message // Actions sent by server or client ActionMessage = "message" // post a message to the room diff --git a/pkg/webhook_messages.go b/pkg/webhook_messages.go new file mode 100644 index 0000000..561c2fc --- /dev/null +++ b/pkg/webhook_messages.go @@ -0,0 +1,21 @@ +package barertc + +// WebhookRequest is a JSON request wrapper around all webhook messages. +type WebhookRequest struct { + Action string + APIKey string + + // Relevant body per request. + Report WebhookRequestReport `json:",omitempty"` +} + +// WebhookRequestReport is the body for 'report' webhook messages. +type WebhookRequestReport struct { + FromUsername string + AboutUsername string + Channel string + Timestamp string + Reason string + Message string + Comment string +} diff --git a/pkg/webhooks.go b/pkg/webhooks.go new file mode 100644 index 0000000..f2f3115 --- /dev/null +++ b/pkg/webhooks.go @@ -0,0 +1,81 @@ +package barertc + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "git.kirsle.net/apps/barertc/pkg/config" + "git.kirsle.net/apps/barertc/pkg/log" +) + +// The available and supported webhook event names. +const ( + WebhookReport = "report" +) + +// WebhookEnabled checks if the named webhook is enabled. +func WebhookEnabled(name string) bool { + for _, webhook := range config.Current.WebhookURLs { + if webhook.Name == name && webhook.Enabled { + return true + } + } + return false +} + +// GetWebhook gets a configured webhook. +func GetWebhook(name string) (config.WebhookURL, bool) { + for _, webhook := range config.Current.WebhookURLs { + if webhook.Name == name { + return webhook, true + } + } + + return config.WebhookURL{}, false +} + +// PostWebhook submits a JSON body to one of the app's configured webhooks. +func PostWebhook(name string, payload any) error { + webhook, ok := GetWebhook(name) + if !ok { + return errors.New("PostWebhook(%s): webhook name %s is not configured") + } else if !webhook.Enabled { + return errors.New("PostWebhook(%s): webhook is not enabled") + } + + // JSON request body. + jsonStr, err := json.Marshal(payload) + if err != nil { + return err + } + + // Make the API request to BareRTC. + var url = webhook.URL + log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Error("PostWebhook(%s): unexpected response from webhook URL %s (code %d): %s", name, url, resp.StatusCode, body) + return errors.New("unexpected error from webhook URL") + } + + return nil +} diff --git a/pkg/websocket.go b/pkg/websocket.go index 0ec34ca..be461de 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -110,6 +110,8 @@ func (sub *Subscriber) ReadLoop(s *Server) { s.OnTakeback(sub, msg) case ActionReact: s.OnReact(sub, msg) + case ActionReport: + s.OnReport(sub, msg) default: sub.ChatServer("Unsupported message type.") } diff --git a/web/static/css/chat.css b/web/static/css/chat.css index e296459..bdc7fc9 100644 --- a/web/static/css/chat.css +++ b/web/static/css/chat.css @@ -36,6 +36,12 @@ body { color: #ff9999 !important; } +/* Max height for message in report modal */ +.report-modal-message { + max-height: 150px; + overflow: auto; +} + /************************ * Main CSS Grid Layout * ************************/ diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index e2bd878..b619267 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -49,6 +49,7 @@ const app = Vue.createApp({ channels: PublicChannels, website: WebsiteURL, permitNSFW: PermitNSFW, + webhookURLs: WebhookURLs, fontSizeClasses: [ [ "x-2", "Very small chat room text" ], [ "x-1", "50% smaller chat room text" ], @@ -58,6 +59,14 @@ const app = Vue.createApp({ [ "x3", "3x larger chat room text" ], [ "x4", "4x larger chat room text" ], ], + reportClassifications: [ + "It's spam", + "It's abusive (racist, homophobic, etc.)", + "It's malicious (e.g. link to a malware website)", + "It's illegal (e.g. controlled substances)", + "It's child porn (CP, CSAM, pedophilia, etc.)", + "Other (please describe)", + ], sounds: { available: SoundEffects, settings: DefaultSounds, @@ -230,6 +239,15 @@ const app = Vue.createApp({ dontShowAgain: false, user: null, // staged User we wanted to open }, + + reportModal: { + visible: false, + busy: false, + message: {}, + origMessage: {}, // pointer, so we can set the "reported" flag + classification: "It's spam", + comment: "", + }, } }, mounted() { @@ -1884,6 +1902,7 @@ const app = Vue.createApp({ this.channels[channel].updated = new Date().getTime(); this.channels[channel].history.push({ action: action, + channel: channel, username: username, message: message, msgID: messageID, @@ -1984,6 +2003,7 @@ const app = Vue.createApp({ // Format a datetime nicely for chat timestamp. prettyDate(date) { + if (date == undefined) return ''; let hours = date.getHours(), minutes = String(date.getMinutes()).padStart(2, '0'), ampm = hours >= 11 ? "pm" : "am"; @@ -2157,7 +2177,61 @@ const app = Vue.createApp({ if (this.status === "online") { this.status = "idle"; } - } + }, + + /* + * Webhook methods + */ + isWebhookEnabled(name) { + for (let webhook of this.config.webhookURLs) { + if (webhook.Name === name && webhook.Enabled) { + return true; + } + } + return false; + }, + + reportMessage(message) { + // User is reporting a message on chat. + if (message.reported) { + if (!window.confirm("You have already reported this message. Do you want to report it again?")) return; + } + + // Clone the message. + let clone = Object.assign({}, message); + + // Sub out attached images. + clone.message = clone.message.replace(//g, "[inline image]"); + + this.reportModal.message = clone; + this.reportModal.origMessage = message; + this.reportModal.classification = this.config.reportClassifications[0]; + this.reportModal.comment = ""; + this.reportModal.visible = true; + }, + doReport() { + // Submit the queued up report. + if (this.reportModal.busy) return; + this.reportModal.busy = true; + + let msg = this.reportModal.message; + + this.ws.conn.send(JSON.stringify({ + action: "report", + channel: msg.channel, + username: msg.username, + timestamp: ""+msg.at, + reason: this.reportModal.classification, + message: msg.message, + comment: this.reportModal.comment, + })); + + this.reportModal.busy = false; + this.reportModal.visible = false; + + // Set the "reported" flag. + this.reportModal.origMessage.reported = true; + }, } }); diff --git a/web/templates/chat.html b/web/templates/chat.html index 96d4748..e6ffd92 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -458,6 +458,87 @@ + +