Support for VIP users via JWT Auth

vue-cli
Noah 2023-09-03 12:08:23 -07:00
parent f65f653430
commit 6e2aa517f5
7 changed files with 105 additions and 9 deletions

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 = 6 var currentVersion = 7
// Config for your BareRTC app. // Config for your BareRTC app.
type Config struct { type Config struct {
@ -45,6 +45,8 @@ type Config struct {
PublicChannels []Channel PublicChannels []Channel
WebhookURLs []WebhookURL WebhookURLs []WebhookURL
VIP VIP
} }
type TurnConfig struct { type TurnConfig struct {
@ -53,6 +55,13 @@ type TurnConfig struct {
Credential string Credential string
} }
type VIP struct {
Name string
Branding string
Icon string
MutuallySecret bool
}
// GetChannels returns a JavaScript safe array of the default PublicChannels. // GetChannels returns a JavaScript safe array of the default PublicChannels.
func (c Config) GetChannels() template.JS { func (c Config) GetChannels() template.JS {
data, _ := json.Marshal(c.PublicChannels) data, _ := json.Marshal(c.PublicChannels)
@ -121,6 +130,11 @@ func DefaultConfig() Config {
URL: "https://example.com/barertc/report", URL: "https://example.com/barertc/report",
}, },
}, },
VIP: VIP{
Name: "VIP",
Branding: "<em>VIP Members</em>",
Icon: "fa fa-circle",
},
} }
c.JWT.Strict = true c.JWT.Strict = true
return c return c

View File

@ -14,6 +14,7 @@ import (
type Claims struct { type Claims struct {
// Custom claims. // Custom claims.
IsAdmin bool `json:"op,omitempty"` IsAdmin bool `json:"op,omitempty"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"img,omitempty"` Avatar string `json:"img,omitempty"`
ProfileURL string `json:"url,omitempty"` ProfileURL string `json:"url,omitempty"`
Nick string `json:"nick,omitempty"` Nick string `json:"nick,omitempty"`

View File

@ -107,6 +107,7 @@ type WhoList struct {
// JWT auth extra settings. // JWT auth extra settings.
Operator bool `json:"op"` Operator bool `json:"op"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
ProfileURL string `json:"profileURL,omitempty"` ProfileURL string `json:"profileURL,omitempty"`
Emoji string `json:"emoji,omitempty"` Emoji string `json:"emoji,omitempty"`
@ -122,4 +123,5 @@ const (
VideoFlagIsTalking // broadcaster seems to be talking VideoFlagIsTalking // broadcaster seems to be talking
VideoFlagMutualRequired // video wants viewers to share their camera too VideoFlagMutualRequired // video wants viewers to share their camera too
VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras
VideoFlagOnlyVIP // can only shows as active to VIP members
) )

View File

@ -126,6 +126,11 @@ func (sub *Subscriber) IsAdmin() bool {
return sub.JWTClaims != nil && sub.JWTClaims.IsAdmin 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. // SendJSON sends a JSON message to the websocket client.
func (sub *Subscriber) SendJSON(v interface{}) error { func (sub *Subscriber) SendJSON(v interface{}) error {
data, err := json.Marshal(v) data, err := json.Marshal(v)
@ -400,9 +405,19 @@ func (s *Server) SendWhoList() {
LoginAt: user.loginAt.Unix(), LoginAt: user.loginAt.Unix(),
} }
// If this person had booted us, force their camera to "off" // Hide video flags of other users (never for the current user).
if (user.Boots(sub.Username) || user.Mutes(sub.Username)) && !sub.IsAdmin() { if user.Username != sub.Username {
who.Video = 0
// 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 { if user.JWTClaims != nil {
@ -412,6 +427,16 @@ func (s *Server) SendWhoList() {
who.Nickname = user.JWTClaims.Nick who.Nickname = user.JWTClaims.Nick
who.Emoji = user.JWTClaims.Emoji who.Emoji = user.JWTClaims.Emoji
who.Gender = user.JWTClaims.Gender 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) users = append(users, who)
} }

View File

@ -336,3 +336,8 @@ div.feed.popped-out {
.has-text-gender-other { .has-text-gender-other {
color: #cc00cc !important; color: #cc00cc !important;
} }
/* VIP colors for profile icon */
.has-background-vip {
background-image: linear-gradient(141deg, #d1e1ff 0, #ffddff 100%)
}

View File

@ -53,6 +53,7 @@ const app = Vue.createApp({
website: WebsiteURL, website: WebsiteURL,
permitNSFW: PermitNSFW, permitNSFW: PermitNSFW,
webhookURLs: WebhookURLs, webhookURLs: WebhookURLs,
VIP: VIP,
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" ],
@ -147,6 +148,7 @@ const app = Vue.createApp({
nsfw: false, // user has flagged their camera to be NSFW nsfw: false, // user has flagged their camera to be NSFW
mutual: false, // user wants viewers to share their own videos mutual: false, // user wants viewers to share their own videos
mutualOpen: false, // user wants to open video mutually 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. // Who all is watching me? map of users.
watching: {}, watching: {},
@ -186,6 +188,7 @@ const app = Vue.createApp({
IsTalking: 1 << 3, IsTalking: 1 << 3,
MutualRequired: 1 << 4, MutualRequired: 1 << 4,
MutualOpen: 1 << 5, MutualOpen: 1 << 5,
VipOnly: 1 << 6,
}, },
// WebRTC sessions with other users. // WebRTC sessions with other users.
@ -386,6 +389,11 @@ const app = Vue.createApp({
this.sendMe(); this.sendMe();
} }
}, },
"webcam.vipOnly": function() {
if (this.webcam.active) {
this.sendMe();
}
},
// Misc preference watches // Misc preference watches
"prefs.joinMessages": function() { "prefs.joinMessages": function() {
@ -495,6 +503,10 @@ const app = Vue.createApp({
// Returns if the current user has operator rights // Returns if the current user has operator rights
return this.jwt.claims.op; return this.jwt.claims.op;
}, },
isVIP() {
// Returns if the current user has VIP rights.
return this.jwt.claims.vip;
},
myVideoFlag() { myVideoFlag() {
// Compute the current user's video status flags. // Compute the current user's video status flags.
let status = 0; let status = 0;
@ -504,6 +516,7 @@ const app = Vue.createApp({
if (this.webcam.nsfw) status |= this.VideoFlag.NSFW; if (this.webcam.nsfw) status |= this.VideoFlag.NSFW;
if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired; if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired;
if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen; if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen;
if (this.webcam.vipOnly && this.isVIP) status |= this.VideoFlag.VipOnly;
return status; return status;
}, },
sortedWhoList() { sortedWhoList() {
@ -596,6 +609,9 @@ const app = Vue.createApp({
if (localStorage.videoAutoMute === "true") { if (localStorage.videoAutoMute === "true") {
this.webcam.autoMute = true; this.webcam.autoMute = true;
} }
if (localStorage.videoVipOnly === "true") {
this.webcam.vipOnly = true;
}
// Misc preferences // Misc preferences
if (localStorage.joinMessages != undefined) { if (localStorage.joinMessages != undefined) {
@ -1613,6 +1629,7 @@ const app = Vue.createApp({
localStorage.videoMutual = this.webcam.mutual; localStorage.videoMutual = this.webcam.mutual;
localStorage.videoMutualOpen = this.webcam.mutualOpen; localStorage.videoMutualOpen = this.webcam.mutualOpen;
localStorage.videoAutoMute = this.webcam.autoMute; localStorage.videoAutoMute = this.webcam.autoMute;
localStorage.videoVipOnly = this.webcam.vipOnly;
// Auto-mute our camera? Two use cases: // Auto-mute our camera? Two use cases:
// 1. The user marked their cam as muted but then changed video device, // 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) // CSS classes for the profile button (color coded genders)
profileButtonClass(user) { profileButtonClass(user) {
// VIP background.
let result = "";
if (user.vip) {
result = "has-background-vip ";
}
let gender = (user.gender || "").toLowerCase(); let gender = (user.gender || "").toLowerCase();
if (gender.indexOf("m") === 0) { if (gender.indexOf("m") === 0) {
return "has-text-gender-male"; return result+"has-text-gender-male";
} else if (gender.indexOf("f") === 0) { } else if (gender.indexOf("f") === 0) {
return "has-text-gender-female"; return result+"has-text-gender-female";
} else if (gender.length > 0) { } else if (gender.length > 0) {
return "has-text-gender-other"; return result+"has-text-gender-other";
} }
return ""; return "";
}, },

View File

@ -330,7 +330,7 @@
</label> </label>
</div> </div>
<div class="field"> <div class="field mb-1">
<label class="checkbox" <label class="checkbox"
:class="{'cursor-notallowed': !webcam.active}"> :class="{'cursor-notallowed': !webcam.active}">
<input type="checkbox" <input type="checkbox"
@ -340,6 +340,17 @@
</label> </label>
</div> </div>
<div class="field" v-if="isVIP">
<label class="checkbox"
:class="{'cursor-notallowed': !webcam.active}">
<input type="checkbox"
v-model="webcam.vipOnly"
:disabled="!webcam.active">
Only <span v-html="config.VIP.Branding"></span> <sup class="is-size-7" :class="config.VIP.Icon"></sup>
members can see that my camera is broadcasting
</label>
</div>
<h3 class="subtitle mb-2" v-if="webcam.videoDevices.length > 0 || webcam.audioDevices.length > 0"> <h3 class="subtitle mb-2" v-if="webcam.videoDevices.length > 0 || webcam.audioDevices.length > 0">
Webcam Devices Webcam Devices
<button type="button" class="button is-primary is-small is-outlined ml-2" <button type="button" class="button is-primary is-small is-outlined ml-2"
@ -501,6 +512,16 @@
</label> </label>
</div> </div>
<div class="field" v-if="isVIP">
<label class="checkbox"
:class="{'cursor-notallowed': !webcam.active}">
<input type="checkbox"
v-model="webcam.vipOnly">
Only <span v-html="config.VIP.Branding"></span> <sup class="is-size-7" :class="config.VIP.Icon"></sup>
members can see that my camera is broadcasting
</label>
</div>
<!-- Device Pickers: just in case the user had granted video permission in the past, <!-- Device Pickers: just in case the user had granted video permission in the past,
and we are able to enumerate their device names, we can show them here before they and we are able to enumerate their device names, we can show them here before they
go on this time.--> go on this time.-->
@ -1413,6 +1434,10 @@
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" <sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1"
v-if="u.op" v-if="u.op"
title="Operator"></sup> title="Operator"></sup>
<sup class="is-size-7 ml-1"
:class="config.VIP.Icon"
v-else-if="u.vip"
:title="config.VIP.Name"></sup>
</div> </div>
<div class="column is-narrow pl-0"> <div class="column is-narrow pl-0">
<!-- Emoji icon --> <!-- Emoji icon -->
@ -1427,7 +1452,7 @@
class="button is-small px-2 py-1" class="button is-small px-2 py-1"
:class="profileButtonClass(u)" :class="profileButtonClass(u)"
@click="openProfile(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})` : '')">
<i class="fa fa-user"></i> <i class="fa fa-user"></i>
</button> </button>
@ -1507,6 +1532,7 @@ 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 WebhookURLs = {{.Config.WebhookURLs}};
const VIP = {{.Config.VIP}};
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}};