Webhooks and Report Users

* Added support for Webhooks and you can configure a Report Message hook
  to let users report messages on chat.
* Add /reconfigure command to dynamically reload the server
  settings.toml
* TODO: documentation for the webhooks.
This commit is contained in:
Noah 2023-08-12 21:35:41 -07:00
parent e7e1fc3d5b
commit 05eb852bb9
11 changed files with 376 additions and 27 deletions

View File

@ -2,6 +2,7 @@ package main
import ( import (
"flag" "flag"
"fmt"
"math/rand" "math/rand"
"time" "time"
@ -29,7 +30,9 @@ func main() {
} }
// Load configuration. // Load configuration.
config.LoadSettings() if err := config.LoadSettings(); err != nil {
panic(fmt.Sprintf("Error loading settings.toml: %s", err))
}
app := barertc.NewServer() app := barertc.NewServer()
app.Setup() app.Setup()

View File

@ -72,6 +72,9 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool {
case "/kickall": case "/kickall":
s.KickAllCommand() s.KickAllCommand()
return true return true
case "/reconfigure":
s.ReconfigureCommand(sub)
return true
case "/op": case "/op":
s.OpCommand(words, sub) s.OpCommand(words, sub)
return true 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. // OpCommand handles the `/op` operator command.
func (s *Server) OpCommand(words []string, sub *Subscriber) { func (s *Server) OpCommand(words []string, sub *Subscriber) {
if len(words) == 1 { if len(words) == 1 {

View File

@ -13,7 +13,7 @@ import (
// Version of the config format - when new fields are added, it will attempt // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 5 var currentVersion = 6
// Config for your BareRTC app. // Config for your BareRTC app.
type Config struct { type Config struct {
@ -43,6 +43,8 @@ type Config struct {
TURN TurnConfig TURN TurnConfig
PublicChannels []Channel PublicChannels []Channel
WebhookURLs []WebhookURL
} }
type TurnConfig struct { type TurnConfig struct {
@ -67,6 +69,13 @@ type Channel struct {
WelcomeMessages []string WelcomeMessages []string
} }
// WebhookURL allows tighter integration with your website.
type WebhookURL struct {
Name string
Enabled bool
URL string
}
// Current loaded configuration. // Current loaded configuration.
var Current = DefaultConfig() var Current = DefaultConfig()
@ -107,6 +116,12 @@ func DefaultConfig() Config {
"stun:stun.l.google.com:19302", "stun:stun.l.google.com:19302",
}, },
}, },
WebhookURLs: []WebhookURL{
{
Name: "report",
URL: "https://example.com/barertc/report",
},
},
} }
c.JWT.Strict = true c.JWT.Strict = true
return c return c
@ -126,6 +141,9 @@ func LoadSettings() error {
} }
_, err = toml.Decode(string(data), &Current) _, err = toml.Decode(string(data), &Current)
if err != nil {
return err
}
// Have we added new config fields? Save the settings.toml. // Have we added new config fields? Save the settings.toml.
if Current.Version != currentVersion { if Current.Version != currentVersion {

View File

@ -429,6 +429,33 @@ func (s *Server) OnBlocklist(sub *Subscriber, msg Message) {
s.SendWhoList() 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. // OnCandidate handles WebRTC candidate signaling.
func (s *Server) OnCandidate(sub *Subscriber, msg Message) { func (s *Server) OnCandidate(sub *Subscriber, msg Message) {
// Look up the other subscriber. // Look up the other subscriber.

View File

@ -38,6 +38,11 @@ type Message struct {
// Send on `blocklist` actions, for doing a `mute` on a list of users // Send on `blocklist` actions, for doing a `mute` on a list of users
Usernames []string `json:"usernames,omitempty"` 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 // WebRTC negotiation messages: proxy their signaling messages
// between the two users to negotiate peer connection. // between the two users to negotiate peer connection.
Candidate string `json:"candidate,omitempty"` // candidate Candidate string `json:"candidate,omitempty"` // candidate
@ -51,6 +56,7 @@ const (
ActionMute = "mute" // mute a user's chat messages ActionMute = "mute" // mute a user's chat messages
ActionUnmute = "unmute" ActionUnmute = "unmute"
ActionBlocklist = "blocklist" // mute in bulk for usernames ActionBlocklist = "blocklist" // mute in bulk for usernames
ActionReport = "report" // user reports a message
// Actions sent by server or client // Actions sent by server or client
ActionMessage = "message" // post a message to the room ActionMessage = "message" // post a message to the room

21
pkg/webhook_messages.go Normal file
View File

@ -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
}

81
pkg/webhooks.go Normal file
View File

@ -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
}

View File

@ -110,6 +110,8 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.OnTakeback(sub, msg) s.OnTakeback(sub, msg)
case ActionReact: case ActionReact:
s.OnReact(sub, msg) s.OnReact(sub, msg)
case ActionReport:
s.OnReport(sub, msg)
default: default:
sub.ChatServer("Unsupported message type.") sub.ChatServer("Unsupported message type.")
} }

View File

@ -36,6 +36,12 @@ body {
color: #ff9999 !important; color: #ff9999 !important;
} }
/* Max height for message in report modal */
.report-modal-message {
max-height: 150px;
overflow: auto;
}
/************************ /************************
* Main CSS Grid Layout * * Main CSS Grid Layout *
************************/ ************************/

View File

@ -49,6 +49,7 @@ const app = Vue.createApp({
channels: PublicChannels, channels: PublicChannels,
website: WebsiteURL, website: WebsiteURL,
permitNSFW: PermitNSFW, permitNSFW: PermitNSFW,
webhookURLs: WebhookURLs,
fontSizeClasses: [ fontSizeClasses: [
[ "x-2", "Very small chat room text" ], [ "x-2", "Very small chat room text" ],
[ "x-1", "50% smaller chat room text" ], [ "x-1", "50% smaller chat room text" ],
@ -58,6 +59,14 @@ const app = Vue.createApp({
[ "x3", "3x larger chat room text" ], [ "x3", "3x larger chat room text" ],
[ "x4", "4x 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: { sounds: {
available: SoundEffects, available: SoundEffects,
settings: DefaultSounds, settings: DefaultSounds,
@ -230,6 +239,15 @@ const app = Vue.createApp({
dontShowAgain: false, dontShowAgain: false,
user: null, // staged User we wanted to open 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() { mounted() {
@ -1884,6 +1902,7 @@ const app = Vue.createApp({
this.channels[channel].updated = new Date().getTime(); this.channels[channel].updated = new Date().getTime();
this.channels[channel].history.push({ this.channels[channel].history.push({
action: action, action: action,
channel: channel,
username: username, username: username,
message: message, message: message,
msgID: messageID, msgID: messageID,
@ -1984,6 +2003,7 @@ const app = Vue.createApp({
// Format a datetime nicely for chat timestamp. // Format a datetime nicely for chat timestamp.
prettyDate(date) { prettyDate(date) {
if (date == undefined) return '';
let hours = date.getHours(), let hours = date.getHours(),
minutes = String(date.getMinutes()).padStart(2, '0'), minutes = String(date.getMinutes()).padStart(2, '0'),
ampm = hours >= 11 ? "pm" : "am"; ampm = hours >= 11 ? "pm" : "am";
@ -2157,7 +2177,61 @@ const app = Vue.createApp({
if (this.status === "online") { if (this.status === "online") {
this.status = "idle"; 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(/<img .+?>/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;
},
} }
}); });

View File

@ -458,6 +458,87 @@
</div> </div>
</div> </div>
<!-- Report Modal -->
<div class="modal" :class="{'is-active': reportModal.visible}">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-warning">
<p class="card-header-title has-text-dark">Report a message</p>
</header>
<div class="card-content">
<!-- Message preview we are reporting on
TODO: make it DRY: style copied/referenced from chat history cards -->
<div class="box mb-2 px-4 pt-3 pb-1 position-relative">
<div class="media mb-0">
<div class="media-left">
<figure class="image is-48x48">
<img v-if="avatarForUsername(reportModal.message.username)"
:src="avatarForUsername(reportModal.message.username)">
<img v-else src="/static/img/shy.png">
</figure>
</div>
<div class="media-content">
<div>
<strong>
<!-- User nickname/display name -->
[[nicknameForUsername(reportModal.message.username)]]
</strong>
</div>
<!-- User @username below it which may link to a profile URL if JWT -->
<div>
<small class="has-text-grey">
@[[reportModal.message.username]]
</small>
</div>
</div>
</div>
<!-- Message copy -->
<div class="content pl-5 py-3 mb-5 report-modal-message"
v-html="reportModal.message.message">
</div>
</div>
<div class="field mb-1">
<label class="label" for="classification">Report classification:</label>
<div class="select is-fullwidth">
<select id="classification"
v-model="reportModal.classification"
:disabled="busy">
<option v-for="i in config.reportClassifications"
:value="i">[[i]]</option>
</select>
</div>
</div>
<div class="field">
<label class="label" for="reportComment">Comment:</label>
<textarea class="textarea"
v-model="reportModal.comment"
:disabled="busy"
cols="80" rows="2"
placeholder="Optional: describe the issue"></textarea>
</div>
<div class="field">
<div class="control has-text-centered">
<button type="button"
class="button is-link mr-4"
:disabled="busy"
@click="doReport()">Submit report</button>
<button type="button"
class="button"
@click="reportModal.visible=false">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Photo Detail Modal --> <!-- Photo Detail Modal -->
<div class="modal" id="photo-modal"> <div class="modal" id="photo-modal">
<div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div> <div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div>
@ -947,32 +1028,47 @@
</div> </div>
</div> </div>
<!-- Emoji reactions menu --> <!-- Report & Emoji buttons -->
<div v-if="msg.msgID" class="dropdown is-right emoji-button" <div v-if="msg.msgID" class="emoji-button columns is-mobile is-gapless mb-0">
:class="{'is-up': i >= 2}" <!-- Report message button -->
onclick="this.classList.toggle('is-active')"> <div class="column" v-if="isWebhookEnabled('report')">
<div class="dropdown-trigger"> <button class="button is-small is-outlined mr-1"
<button class="button is-small px-2" aria-haspopup="true" :aria-controls="`react-menu-${msg.msgID}`"> :class="{'is-danger': !msg.reported,
<span> 'has-text-grey': msg.reported}"
<i class="fa fa-heart has-text-grey"></i> title="Report this message"
<i class="fa fa-plus has-text-grey pl-1"></i> @click="reportMessage(msg)">
</span> <i class="fa fa-flag"></i>
<i class="fa fa-check ml-1" v-if="msg.reported"></i>
</button> </button>
</div> </div>
<div class="dropdown-menu" :id="`react-menu-${msg.msgID}`" role="menu">
<div class="dropdown-content p-0">
<!-- Iterate over reactions in rows of emojis-->
<div class="columns is-mobile ml-0 mb-2 mr-1"
v-for="row in config.reactions">
<!-- Loop over the icons --> <!-- Emoji reactions menu -->
<div class="column p-0 is-narrow" <div class="column dropdown"
v-for="i in row"> :class="{'is-up': i >= 2}"
<button type="button" onclick="this.classList.toggle('is-active')">
class="button px-2 mt-1 ml-1 mr-0 mb-1" <div class="dropdown-trigger">
@click="sendReact(msg, i)"> <button class="button is-small px-2" aria-haspopup="true" :aria-controls="`react-menu-${msg.msgID}`">
[[i]] <span>
</button> <i class="fa fa-heart has-text-grey"></i>
<i class="fa fa-plus has-text-grey pl-1"></i>
</span>
</button>
</div>
<div class="dropdown-menu" :id="`react-menu-${msg.msgID}`" role="menu">
<div class="dropdown-content p-0">
<!-- Iterate over reactions in rows of emojis-->
<div class="columns is-mobile ml-0 mb-2 mr-1"
v-for="row in config.reactions">
<!-- Loop over the icons -->
<div class="column p-0 is-narrow"
v-for="i in row">
<button type="button"
class="button px-2 mt-1 ml-1 mr-0 mb-1"
@click="sendReact(msg, i)">
[[i]]
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -980,7 +1076,7 @@
</div> </div>
<!-- Message box --> <!-- Message box -->
<div class="content pl-5 py-3 mb-0"> <div class="content pl-5 py-3 mb-5">
<em v-if="msg.action === 'presence'">[[msg.message]]</em> <em v-if="msg.action === 'presence'">[[msg.message]]</em>
<div v-else v-html="msg.message"></div> <div v-else v-html="msg.message"></div>
@ -1252,6 +1348,7 @@ const PublicChannels = {{.Config.GetChannels}};
const WebsiteURL = "{{.Config.WebsiteURL}}"; const WebsiteURL = "{{.Config.WebsiteURL}}";
const PermitNSFW = {{AsJS .Config.PermitNSFW}}; const PermitNSFW = {{AsJS .Config.PermitNSFW}};
const TURN = {{.Config.TURN}}; const TURN = {{.Config.TURN}};
const WebhookURLs = {{.Config.WebhookURLs}};
const UserJWTToken = {{.JWTTokenString}}; const UserJWTToken = {{.JWTTokenString}};
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}}; const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
const UserJWTClaims = {{.JWTClaims.ToJSON}}; const UserJWTClaims = {{.JWTClaims.ToJSON}};