diff --git a/docs/Webhooks.md b/docs/Webhooks.md index 210fa6a..c92ee89 100644 --- a/docs/Webhooks.md +++ b/docs/Webhooks.md @@ -9,6 +9,11 @@ Webhooks are configured in your settings.toml file and look like so: Name = "report" Enabled = true URL = "http://localhost:8080/v1/barertc/report" + +[[WebhookURLs]] + Name = "profile" + Enabled = true + URL = "http://localhost:8080/v1/barertc/profile" ``` All Webhooks will be called as **POST** requests and will contain a JSON payload that will always have the following two keys: @@ -43,3 +48,40 @@ Example JSON payload posted to the webhook: ``` BareRTC expects your webhook URL to return a 200 OK status code or it will surface an error in chat to the reporter. + +## Profile Webhook + +Enabling this webhook will allow your site to deliver more detailed profile information on demand for your users. This is used in chat when somebody opens the Profile Card modal for a user in chat. + +BareRTC will call your webhook URL with the following payload: + +```javascript +{ + "Action": "profile", + "APIKey": "shared secret from settings.toml#AdminAPIKey", + "Username": "soandso" +} +``` + +The expected response from your endpoint should follow this format: + +```javascript +{ + "StatusCode": 200, + "Data": { + "OK": true, + "Error": "any error messaging (omittable if no errors)", + "ProfileFields": [ + { + "Name": "Age", + "Value": "30yo", + }, + { + "Name": "Gender", + "Value": "Man", + }, + // etc. + ] + } +} +``` diff --git a/pkg/api.go b/pkg/api.go index dbc37d8..19c4adc 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -2,6 +2,7 @@ package barertc import ( "encoding/json" + "fmt" "net/http" "os" "strings" @@ -467,6 +468,158 @@ func (s *Server) BlockNow() http.HandlerFunc { }) } +// UserProfile (/api/profile) fetches profile information about a user. +// +// This endpoint will proxy to your WebhookURL for the "profile" endpoint. +// If your webhook is not configured or not reachable, this endpoint returns +// an error to the caller. +// +// Authentication: the caller must send their current chat JWT token when +// hitting this endpoint. +// +// It is a POST request with a json body containing the following schema: +// +// { +// "JWTToken": "the caller's jwt token", +// "Username": [ "soandso" ] +// } +// +// The response JSON will look like the following (this also mirrors the +// response json as sent by your site's webhook URL): +// +// { +// "OK": true, +// "Error": "only on errors", +// "ProfileFields": [ +// { +// "Name": "Age", +// "Value": "30yo", +// }, +// { +// "Name": "Gender", +// "Value": "Man", +// }, +// ... +// ] +// } +func (s *Server) UserProfile() http.HandlerFunc { + type request struct { + JWTToken string + Username string + } + + type profileField struct { + Name string + Value string + } + type result struct { + OK bool + Error string `json:",omitempty"` + ProfileFields []profileField `json:",omitempty"` + } + + type webhookRequest struct { + Action string + APIKey string + Username string + } + + type webhookResponse struct { + StatusCode int + Data result + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // JSON writer for the response. + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + + // Parse the request. + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only POST methods allowed", + }) + return + } else if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "Only application/json content-types allowed", + }) + return + } + + defer r.Body.Close() + + // Parse the request payload. + var ( + params request + dec = json.NewDecoder(r.Body) + ) + if err := dec.Decode(¶ms); err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Are JWT tokens enabled on the server? + if !config.Current.JWT.Enabled || params.JWTToken == "" { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: "JWT authentication is not available.", + }) + return + } + + // Validate the user's JWT token. + _, _, err := jwt.ParseAndValidate(params.JWTToken) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + enc.Encode(result{ + Error: err.Error(), + }) + return + } + + // Fetch the profile data from your website. + data, err := PostWebhook("profile", webhookRequest{ + Action: "profile", + APIKey: config.Current.AdminAPIKey, + Username: params.Username, + }) + if err != nil { + log.Error("Couldn't get profile information: %s", err) + } + + // Success? Try and parse the response into our expected format. + var resp webhookResponse + if err := json.Unmarshal(data, &resp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + + // A nice error message? + if resp.Data.Error != "" { + enc.Encode(result{ + Error: resp.Data.Error, + }) + } else { + enc.Encode(result{ + Error: fmt.Sprintf("Didn't get expected response for profile data: %s", err), + }) + } + return + } + + // At this point the expected resp mirrors our own, so return it. + if resp.StatusCode != http.StatusOK || resp.Data.Error != "" { + w.WriteHeader(http.StatusInternalServerError) + } + enc.Encode(resp.Data) + }) +} + // Blocklist cache sent over from your website. var ( // Map of username to the list of usernames they block. diff --git a/pkg/config/config.go b/pkg/config/config.go index 6493c71..738fc7c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -152,6 +152,10 @@ func DefaultConfig() Config { Name: "report", URL: "https://example.com/barertc/report", }, + { + Name: "profile", + URL: "https://example.com/barertc/user-profile", + }, }, VIP: VIP{ Name: "VIP", diff --git a/pkg/handlers.go b/pkg/handlers.go index 58ab1a0..b74debb 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -418,21 +418,27 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) { } // OnBoot is a user kicking you off their video stream. -func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) { - log.Info("%s boots %s off their camera", sub.Username, msg.Username) - +func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) { sub.muteMu.Lock() - sub.booted[msg.Username] = struct{}{} - sub.muteMu.Unlock() - // 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, - ) + 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() } @@ -506,7 +512,7 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) { } // Post to the report webhook. - if err := PostWebhook(WebhookReport, WebhookRequest{ + if _, err := PostWebhook(WebhookReport, WebhookRequest{ Action: WebhookReport, APIKey: config.Current.AdminAPIKey, Report: WebhookRequestReport{ diff --git a/pkg/message_filters.go b/pkg/message_filters.go index e6e33e3..683eb50 100644 --- a/pkg/message_filters.go +++ b/pkg/message_filters.go @@ -82,7 +82,7 @@ func (s *Server) reportFilteredMessage(sub *Subscriber, msg messages.Message) er context = getMessageContext(msg.Channel) } - if err := PostWebhook(WebhookReport, WebhookRequest{ + if _, err := PostWebhook(WebhookReport, WebhookRequest{ Action: WebhookReport, APIKey: config.Current.AdminAPIKey, Report: WebhookRequestReport{ diff --git a/pkg/messages/messages.go b/pkg/messages/messages.go index b5419a2..ab9a897 100644 --- a/pkg/messages/messages.go +++ b/pkg/messages/messages.go @@ -69,9 +69,10 @@ type Message struct { const ( // Actions sent by the client side only - ActionLogin = "login" // post the username to backend - ActionBoot = "boot" // boot a user off your video feed - ActionMute = "mute" // mute a user's chat messages + ActionLogin = "login" // post the username to backend + ActionBoot = "boot" // boot a user off your video feed + ActionUnboot = "unboot" // unboot a user + ActionMute = "mute" // mute a user's chat messages ActionUnmute = "unmute" ActionBlock = "block" // hard block another user ActionBlocklist = "blocklist" // mute in bulk for usernames diff --git a/pkg/server.go b/pkg/server.go index 99b3bf4..938a7de 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -39,6 +39,7 @@ func (s *Server) Setup() error { mux.Handle("/api/block/now", s.BlockNow()) mux.Handle("/api/authenticate", s.Authenticate()) mux.Handle("/api/shutdown", s.ShutdownAPI()) + mux.Handle("/api/profile", s.UserProfile()) mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets")))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static")))) diff --git a/pkg/webhooks.go b/pkg/webhooks.go index f2f3115..857b66a 100644 --- a/pkg/webhooks.go +++ b/pkg/webhooks.go @@ -39,18 +39,20 @@ func GetWebhook(name string) (config.WebhookURL, bool) { } // PostWebhook submits a JSON body to one of the app's configured webhooks. -func PostWebhook(name string, payload any) error { +// +// Returns the bytes of the response body (hopefully, JSON data) and any errors. +func PostWebhook(name string, payload any) ([]byte, error) { webhook, ok := GetWebhook(name) if !ok { - return errors.New("PostWebhook(%s): webhook name %s is not configured") + return nil, errors.New("PostWebhook(%s): webhook name %s is not configured") } else if !webhook.Enabled { - return errors.New("PostWebhook(%s): webhook is not enabled") + return nil, errors.New("PostWebhook(%s): webhook is not enabled") } // JSON request body. jsonStr, err := json.Marshal(payload) if err != nil { - return err + return nil, err } // Make the API request to BareRTC. @@ -58,7 +60,7 @@ func PostWebhook(name string, payload any) error { 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 + return nil, err } req.Header.Set("Content-Type", "application/json") @@ -67,15 +69,15 @@ func PostWebhook(name string, payload any) error { } resp, err := client.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) 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 body, errors.New("unexpected error from webhook URL") } - return nil + return body, nil } diff --git a/pkg/websocket.go b/pkg/websocket.go index 05bf50f..f1f07f3 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -96,7 +96,9 @@ func (sub *Subscriber) ReadLoop(s *Server) { case messages.ActionOpen: s.OnOpen(sub, msg) case messages.ActionBoot: - s.OnBoot(sub, msg) + 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: diff --git a/src/App.vue b/src/App.vue index b1a8432..86460a8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,7 @@ import ReportModal from './components/ReportModal.vue'; import MessageBox from './components/MessageBox.vue'; import WhoListRow from './components/WhoListRow.vue'; import VideoFeed from './components/VideoFeed.vue'; +import ProfileModal from './components/ProfileModal.vue'; import LocalStorage from './lib/LocalStorage'; import VideoFlag from './lib/VideoFlag'; @@ -50,6 +51,7 @@ export default { MessageBox, WhoListRow, VideoFeed, + ProfileModal, }, data() { return { @@ -288,6 +290,12 @@ export default { message: {}, origMessage: {}, // pointer, so we can set the "reported" flag }, + + profileModal: { + visible: false, + user: {}, + username: "", + }, } }, mounted() { @@ -1088,6 +1096,12 @@ export default { username: username, })); }, + sendUnboot(username) { + this.ws.conn.send(JSON.stringify({ + action: "unboot", + username: username, + })); + }, onOpen(msg) { // Response for the opener to begin WebRTC connection. this.startWebRTC(msg.username, true); @@ -2229,6 +2243,19 @@ export default { // Boot someone off your video. bootUser(username) { + // Un-boot? + if (this.isBooted(username)) { + if (!window.confirm(`Allow ${username} to watch your webcam again?`)) { + return; + } + + this.sendUnboot(username); + delete(this.WebRTC.booted[username]); + + return; + } + + // Boot them off our webcam. if (!window.confirm( `Kick ${username} off your camera? This will also prevent them ` + `from seeing that your camera is active for the remainder of your ` + @@ -2254,6 +2281,9 @@ export default { `in place for the remainder of your current chat session.` ); }, + isBooted(username) { + return this.WebRTC.booted[username] === true; + }, isBootedAdmin(username) { return (this.WebRTC.booted[username] === true || this.muted[username] === true) && this.whoMap[username] != undefined && @@ -2663,6 +2693,15 @@ export default { input.click(); }, + // Invoke the Profile Modal + showProfileModal(username) { + if (this.whoMap[username] != undefined) { + this.profileModal.user = this.whoMap[username]; + this.profileModal.username = username; + this.profileModal.visible = true; + } + }, + /** * Sound effect concerns. */ @@ -3335,6 +3374,22 @@ export default { @accept="doReport" @cancel="reportModal.visible=false"> + + +
@@ -3684,6 +3739,7 @@ export default { :report-enabled="isWebhookEnabled('report')" :is-dm="isDM" :is-op="isOp" + @open-profile="showProfileModal" @send-dm="openDMs" @mute-user="muteUser" @takeback="takeback" @@ -3892,7 +3948,8 @@ export default { :vip-config="config.VIP" @send-dm="openDMs" @mute-user="muteUser" - @open-video="openVideo"> + @open-video="openVideo" + @open-profile="showProfileModal"> @@ -3913,7 +3970,8 @@ export default { @send-dm="openDMs" @mute-user="muteUser" @open-video="openVideo" - @boot-user="bootUser"> + @boot-user="bootUser" + @open-profile="showProfileModal"> diff --git a/src/components/MessageBox.vue b/src/components/MessageBox.vue index c4ab6a3..b5884f1 100644 --- a/src/components/MessageBox.vue +++ b/src/components/MessageBox.vue @@ -84,10 +84,7 @@ export default { }, methods: { openProfile() { - let url = this.profileURL; - if (url) { - window.open(url); - } + this.$emit('open-profile', this.message.username); }, openDMs() { @@ -180,8 +177,7 @@ export default {
+ @click.prevent="openProfile()">
@@ -218,6 +214,7 @@ export default { @{{ message.username }} @@ -229,12 +226,12 @@ export default {
- @{{ message.username }} - @{{ message.username }} internal
@@ -353,7 +350,7 @@ export default {
+ class="p-0"> diff --git a/src/components/ProfileModal.vue b/src/components/ProfileModal.vue new file mode 100644 index 0000000..68d9c6e --- /dev/null +++ b/src/components/ProfileModal.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/src/components/VideoFeed.vue b/src/components/VideoFeed.vue index 0eb1b9e..9146c36 100644 --- a/src/components/VideoFeed.vue +++ b/src/components/VideoFeed.vue @@ -22,12 +22,20 @@ export default { // Volume change debounce volumeDebounce: null, + + // Mouse over status + mouseOver: false, }; }, computed: { videoID() { return this.localVideo ? 'localVideo' : `videofeed-${this.username}`; - } + }, + }, + watch: { + mouseOver() { + console.log("mouse over:", this.mouseOver); + }, }, methods: { closeVideo() { @@ -77,7 +85,7 @@ export default {
+ }" @mouseover="mouseOver=true" @mouseleave="mouseOver=false"> @@ -140,7 +148,7 @@ export default {
-