From 6e2aa517f598529169c5885e9b2c818cd2903079 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 3 Sep 2023 12:08:23 -0700 Subject: [PATCH] Support for VIP users via JWT Auth --- pkg/config/config.go | 16 +++++++++++++++- pkg/jwt/jwt.go | 1 + pkg/messages/messages.go | 2 ++ pkg/websocket.go | 31 ++++++++++++++++++++++++++++--- web/static/css/chat.css | 5 +++++ web/static/js/BareRTC.js | 29 ++++++++++++++++++++++++++--- web/templates/chat.html | 30 ++++++++++++++++++++++++++++-- 7 files changed, 105 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 9ba6368..de84ef3 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 = 6 +var currentVersion = 7 // Config for your BareRTC app. type Config struct { @@ -45,6 +45,8 @@ type Config struct { PublicChannels []Channel WebhookURLs []WebhookURL + + VIP VIP } type TurnConfig struct { @@ -53,6 +55,13 @@ type TurnConfig struct { Credential string } +type VIP struct { + Name string + Branding string + Icon string + MutuallySecret bool +} + // GetChannels returns a JavaScript safe array of the default PublicChannels. func (c Config) GetChannels() template.JS { data, _ := json.Marshal(c.PublicChannels) @@ -121,6 +130,11 @@ func DefaultConfig() Config { URL: "https://example.com/barertc/report", }, }, + VIP: VIP{ + Name: "VIP", + Branding: "VIP Members", + Icon: "fa fa-circle", + }, } c.JWT.Strict = true return c diff --git a/pkg/jwt/jwt.go b/pkg/jwt/jwt.go index 18ca6bf..377ccae 100644 --- a/pkg/jwt/jwt.go +++ b/pkg/jwt/jwt.go @@ -14,6 +14,7 @@ import ( type Claims struct { // Custom claims. IsAdmin bool `json:"op,omitempty"` + VIP bool `json:"vip,omitempty"` Avatar string `json:"img,omitempty"` ProfileURL string `json:"url,omitempty"` Nick string `json:"nick,omitempty"` diff --git a/pkg/messages/messages.go b/pkg/messages/messages.go index f16eea6..aed4526 100644 --- a/pkg/messages/messages.go +++ b/pkg/messages/messages.go @@ -107,6 +107,7 @@ type WhoList struct { // JWT auth extra settings. Operator bool `json:"op"` + VIP bool `json:"vip,omitempty"` Avatar string `json:"avatar,omitempty"` ProfileURL string `json:"profileURL,omitempty"` Emoji string `json:"emoji,omitempty"` @@ -122,4 +123,5 @@ const ( VideoFlagIsTalking // broadcaster seems to be talking VideoFlagMutualRequired // video wants viewers to share their camera too VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras + VideoFlagOnlyVIP // can only shows as active to VIP members ) diff --git a/pkg/websocket.go b/pkg/websocket.go index 09a173f..5b5210d 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -126,6 +126,11 @@ func (sub *Subscriber) IsAdmin() bool { return sub.JWTClaims != nil && sub.JWTClaims.IsAdmin } +// IsVIP safely checks if the subscriber has VIP status. +func (sub *Subscriber) IsVIP() bool { + return sub.JWTClaims != nil && sub.JWTClaims.VIP +} + // SendJSON sends a JSON message to the websocket client. func (sub *Subscriber) SendJSON(v interface{}) error { data, err := json.Marshal(v) @@ -400,9 +405,19 @@ func (s *Server) SendWhoList() { LoginAt: user.loginAt.Unix(), } - // If this person had booted us, force their camera to "off" - if (user.Boots(sub.Username) || user.Mutes(sub.Username)) && !sub.IsAdmin() { - who.Video = 0 + // Hide video flags of other users (never for the current user). + if user.Username != sub.Username { + + // If this person had booted us, force their camera to "off" + if (user.Boots(sub.Username) || user.Mutes(sub.Username)) && !sub.IsAdmin() { + who.Video = 0 + } + + // If this person's VideoFlag is set to VIP Only, force their camera to "off" + // except when the person looking has the VIP status. + if (user.VideoStatus&messages.VideoFlagOnlyVIP == messages.VideoFlagOnlyVIP) && (!sub.IsVIP() && !sub.IsAdmin()) { + who.Video = 0 + } } if user.JWTClaims != nil { @@ -412,6 +427,16 @@ func (s *Server) SendWhoList() { who.Nickname = user.JWTClaims.Nick who.Emoji = user.JWTClaims.Emoji who.Gender = user.JWTClaims.Gender + + // VIP flags: if we are in MutuallySecret mode, only VIPs can see + // other VIP flags on the Who List. + if config.Current.VIP.MutuallySecret { + if sub.IsVIP() || sub.IsAdmin() { + who.VIP = user.JWTClaims.VIP + } + } else { + who.VIP = user.JWTClaims.VIP + } } users = append(users, who) } diff --git a/web/static/css/chat.css b/web/static/css/chat.css index 67e3e8e..6462e6d 100644 --- a/web/static/css/chat.css +++ b/web/static/css/chat.css @@ -336,3 +336,8 @@ div.feed.popped-out { .has-text-gender-other { color: #cc00cc !important; } + +/* VIP colors for profile icon */ +.has-background-vip { + background-image: linear-gradient(141deg, #d1e1ff 0, #ffddff 100%) +} diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index dca023b..ca1a882 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -53,6 +53,7 @@ const app = Vue.createApp({ website: WebsiteURL, permitNSFW: PermitNSFW, webhookURLs: WebhookURLs, + VIP: VIP, fontSizeClasses: [ [ "x-2", "Very small chat room text" ], [ "x-1", "50% smaller chat room text" ], @@ -147,6 +148,7 @@ const app = Vue.createApp({ nsfw: false, // user has flagged their camera to be NSFW mutual: false, // user wants viewers to share their own videos mutualOpen: false, // user wants to open video mutually + vipOnly: false, // only show camera to fellow VIP users // Who all is watching me? map of users. watching: {}, @@ -186,6 +188,7 @@ const app = Vue.createApp({ IsTalking: 1 << 3, MutualRequired: 1 << 4, MutualOpen: 1 << 5, + VipOnly: 1 << 6, }, // WebRTC sessions with other users. @@ -386,6 +389,11 @@ const app = Vue.createApp({ this.sendMe(); } }, + "webcam.vipOnly": function() { + if (this.webcam.active) { + this.sendMe(); + } + }, // Misc preference watches "prefs.joinMessages": function() { @@ -495,6 +503,10 @@ const app = Vue.createApp({ // Returns if the current user has operator rights return this.jwt.claims.op; }, + isVIP() { + // Returns if the current user has VIP rights. + return this.jwt.claims.vip; + }, myVideoFlag() { // Compute the current user's video status flags. let status = 0; @@ -504,6 +516,7 @@ const app = Vue.createApp({ if (this.webcam.nsfw) status |= this.VideoFlag.NSFW; if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired; if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen; + if (this.webcam.vipOnly && this.isVIP) status |= this.VideoFlag.VipOnly; return status; }, sortedWhoList() { @@ -596,6 +609,9 @@ const app = Vue.createApp({ if (localStorage.videoAutoMute === "true") { this.webcam.autoMute = true; } + if (localStorage.videoVipOnly === "true") { + this.webcam.vipOnly = true; + } // Misc preferences if (localStorage.joinMessages != undefined) { @@ -1613,6 +1629,7 @@ const app = Vue.createApp({ localStorage.videoMutual = this.webcam.mutual; localStorage.videoMutualOpen = this.webcam.mutualOpen; localStorage.videoAutoMute = this.webcam.autoMute; + localStorage.videoVipOnly = this.webcam.vipOnly; // Auto-mute our camera? Two use cases: // 1. The user marked their cam as muted but then changed video device, @@ -2349,13 +2366,19 @@ const app = Vue.createApp({ // CSS classes for the profile button (color coded genders) profileButtonClass(user) { + // VIP background. + let result = ""; + if (user.vip) { + result = "has-background-vip "; + } + let gender = (user.gender || "").toLowerCase(); if (gender.indexOf("m") === 0) { - return "has-text-gender-male"; + return result+"has-text-gender-male"; } else if (gender.indexOf("f") === 0) { - return "has-text-gender-female"; + return result+"has-text-gender-female"; } else if (gender.length > 0) { - return "has-text-gender-other"; + return result+"has-text-gender-other"; } return ""; }, diff --git a/web/templates/chat.html b/web/templates/chat.html index 178deda..a24c7be 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -330,7 +330,7 @@ -
+
+
+ +
+

Webcam Devices

+
+ +
+ @@ -1413,6 +1434,10 @@ +
@@ -1427,7 +1452,7 @@ class="button is-small px-2 py-1" :class="profileButtonClass(u)" @click="openProfile(u)" - :title="'Open profile page' + (u.gender ? ` (gender: ${u.gender})` : '')"> + :title="'Open profile page' + (u.gender ? ` (gender: ${u.gender})` : '') + (u.vip ? ` (${config.VIP.Name})` : '')"> @@ -1507,6 +1532,7 @@ const WebsiteURL = "{{.Config.WebsiteURL}}"; const PermitNSFW = {{AsJS .Config.PermitNSFW}}; const TURN = {{.Config.TURN}}; const WebhookURLs = {{.Config.WebhookURLs}}; +const VIP = {{.Config.VIP}}; const UserJWTToken = {{.JWTTokenString}}; const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}}; const UserJWTClaims = {{.JWTClaims.ToJSON}};