diff --git a/Protocol.md b/Protocol.md index d862b54..e2119f4 100644 --- a/Protocol.md +++ b/Protocol.md @@ -30,6 +30,22 @@ The video stream can be interrupted and closed via various methods: * Also, a `who` update that says a person's videoActive went false will instruct all clients who had the video open, to close it (in case the PeerConnection closure didn't already do this). * If a user exits the room, e.g. exited their browser abruptly without gracefully closing PeerConnections, any client who had their video open will close it immediately. +# Video Flags + +The various video settings sent on Who List updates are now consolidated +to a bit flag field: + +```javascript +VideoFlag: { + Active: 1 << 0, // or 00000001 in binary + NSFW: 1 << 1, // or 00000010 + Muted: 1 << 2, // or 00000100, etc. + IsTalking: 1 << 3, + MutualRequired: 1 << 4, + MutualOpen: 1 << 5, +} +``` + # WebSocket Message Actions Every message has an "action" and may have other fields depending on the action type. @@ -201,8 +217,7 @@ The client sends "me" messages to send their webcam broadcast status and NSFW fl // Client Me { "action": "me", - "videoActive": true, - "nsfw": false + "video": 1, } ``` @@ -213,7 +228,7 @@ The server may also push "me" messages to the user: for example if there is a co { "action": "me", "username": "soandso 12345", - "videoActive": true + "video": 1, } ``` @@ -233,8 +248,7 @@ The `who` action sends the Who Is Online list to all connected chatters. "op": false, // operator status "avatar": "/picture/soandso.png", "profileURL": "/u/soandso", - "videoActive": true, - "nsfW": false + "video": 0, } ] } diff --git a/pkg/api.go b/pkg/api.go index fd8ef71..9d73cda 100644 --- a/pkg/api.go +++ b/pkg/api.go @@ -49,8 +49,8 @@ func (s *Server) Statistics() http.HandlerFunc { unique[sub.Username] = struct{}{} // Count cameras by color. - if sub.VideoActive { - if sub.VideoNSFW { + if sub.VideoStatus&VideoFlagActive == VideoFlagActive { + if sub.VideoStatus&VideoFlagNSFW == VideoFlagNSFW { result.Cameras.Red++ } else { result.Cameras.Blue++ diff --git a/pkg/commands.go b/pkg/commands.go index 84e0ac5..8cf3b06 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -1,9 +1,12 @@ package barertc import ( + "fmt" + "os" "strconv" "time" + "git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/log" "github.com/mattn/go-shellwords" ) @@ -42,7 +45,7 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { sub.ChatServer("/nsfw: username not found: %s", username) } else { other.ChatServer("Your camera has been marked as NSFW by %s", sub.Username) - other.VideoNSFW = true + other.VideoStatus |= VideoFlagNSFW other.SendMe() s.SendWhoList() sub.ChatServer("%s has their camera marked as NSFW", username) @@ -52,11 +55,23 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" + "* `/kick ` to kick from chat\n" + "* `/nsfw ` to mark their camera NSFW\n" + + "* `/shutdown` to gracefully shut down (reboot) the chat server\n" + + "* `/kickall` to kick EVERYBODY off and force them to log back in\n" + "* `/help` to show this message\n\n" + "Note: shell-style quoting is supported, if a username has a space in it, quote the whole username, e.g.: `/kick \"username 2\"`", )) return true + case "/shutdown": + s.Broadcast(Message{ + Action: ActionError, + Username: "ChatServer", + Message: "The chat server is going down for a reboot NOW!", + }) + os.Exit(1) + case "/kickall": + s.KickAllCommand() } + } // Not handled. @@ -87,6 +102,48 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) { } } +// KickAllCommand kicks everybody out of the room. +func (s *Server) KickAllCommand() { + + // If we have JWT enabled and a landing page, link users to it. + if config.Current.JWT.Enabled && config.Current.JWT.LandingPageURL != "" { + s.Broadcast(Message{ + Action: ActionError, + Username: "ChatServer", + Message: fmt.Sprintf( + "Notice: The chat operator has requested that you log back in to the chat room. "+ + "Probably, this is because a new feature was launched that needs you to reload the page. "+ + "You may refresh the tab or click here to re-enter the room.", + config.Current.JWT.LandingPageURL, + ), + }) + } else { + s.Broadcast(Message{ + Action: ActionError, + Username: "ChatServer", + Message: "Notice: The chat operator has kicked everybody from the room. Usually, this " + + "may mean a new feature of the chat has been launched and you need to reload the page for it " + + "to function correctly.", + }) + } + + // Kick everyone off. + s.Broadcast(Message{ + Action: ActionKick, + }) + + // Disconnect everybody. + s.subscribersMu.RLock() + defer s.subscribersMu.RUnlock() + for _, sub := range s.IterSubscribers(true) { + if !sub.authenticated { + continue + } + + s.DeleteSubscriber(sub) + } +} + // BanCommand handles the `/ban` operator command. func (s *Server) BanCommand(words []string, sub *Subscriber) { if len(words) == 1 { diff --git a/pkg/config/config.go b/pkg/config/config.go index 882c7a4..ac42359 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,16 +12,17 @@ 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 = 3 +var currentVersion = 4 // Config for your BareRTC app. type Config struct { Version int // will re-save your settings.toml on migrations JWT struct { - Enabled bool - Strict bool - SecretKey string + Enabled bool + Strict bool + SecretKey string + LandingPageURL string } Title string diff --git a/pkg/handlers.go b/pkg/handlers.go index 1836dec..f0bafb3 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -252,7 +252,7 @@ func (s *Server) OnFile(sub *Subscriber, msg Message) { // OnMe handles current user state updates. func (s *Server) OnMe(sub *Subscriber, msg Message) { - if msg.VideoActive { + if msg.VideoStatus&VideoFlagActive == VideoFlagActive { log.Debug("User %s turns on their video feed", sub.Username) } @@ -278,10 +278,7 @@ func (s *Server) OnMe(sub *Subscriber, msg Message) { msg.ChatStatus = "away" } - sub.VideoActive = msg.VideoActive - sub.VideoMutual = msg.VideoMutual - sub.VideoMutualOpen = msg.VideoMutualOpen - sub.VideoNSFW = msg.NSFW + sub.VideoStatus = msg.VideoStatus sub.ChatStatus = msg.ChatStatus // Sync the WhoList to everybody. diff --git a/pkg/messages.go b/pkg/messages.go index eba0a12..a84da37 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -23,11 +23,8 @@ type Message struct { WhoList []WhoList `json:"whoList,omitempty"` // Sent on `me` actions along with Username - VideoActive bool `json:"videoActive,omitempty"` // user tells us their cam status - VideoMutual bool `json:"videoMutual,omitempty"` // user wants mutual viewers - VideoMutualOpen bool `json:"videoMutualOpen,omitempty"` - ChatStatus string `json:"status,omitempty"` // online vs. away - NSFW bool `json:"nsfw,omitempty"` // user tags their video NSFW + VideoStatus int `json:"video,omitempty"` // user video flags + ChatStatus string `json:"status,omitempty"` // online vs. away // Message ID to support takebacks/local deletions MessageID int `json:"msgID,omitempty"` @@ -75,16 +72,24 @@ const ( // WhoList is a member entry in the chat room. type WhoList struct { - Username string `json:"username"` - Nickname string `json:"nickname,omitempty"` - VideoActive bool `json:"videoActive,omitempty"` - VideoMutual bool `json:"videoMutual,omitempty"` - VideoMutualOpen bool `json:"videoMutualOpen,omitempty"` - NSFW bool `json:"nsfw,omitempty"` - Status string `json:"status"` + Username string `json:"username"` + Nickname string `json:"nickname,omitempty"` + Status string `json:"status"` + Video int `json:"video"` // JWT auth extra settings. Operator bool `json:"op"` Avatar string `json:"avatar,omitempty"` ProfileURL string `json:"profileURL,omitempty"` } + +// VideoFlags to convey the state and setting of users' cameras concisely. +// Also see the VideoFlag object in BareRTC.js for front-end sync. +const ( + VideoFlagActive int = 1 << iota // user's camera is enabled/broadcasting + VideoFlagNSFW // viewer's camera is marked as NSFW + VideoFlagMuted // user source microphone is muted + VideoFlagIsTalking // broadcaster seems to be talking + VideoFlagMutualRequired // video wants viewers to share their camera too + VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras +) diff --git a/pkg/pages.go b/pkg/pages.go index 492702a..7427179 100644 --- a/pkg/pages.go +++ b/pkg/pages.go @@ -40,9 +40,16 @@ func IndexPage() http.HandlerFunc { // Are we enforcing strict JWT authentication? if config.Current.JWT.Enabled && config.Current.JWT.Strict && !authOK { + // Do we have a landing page to redirect to? + if config.Current.JWT.LandingPageURL != "" { + w.Header().Add("Location", config.Current.JWT.LandingPageURL) + w.WriteHeader(http.StatusFound) + return + } + w.WriteHeader(http.StatusForbidden) w.Write([]byte( - fmt.Sprintf("Authentication denied. Please go back and try again."), + "Authentication denied. Please go back and try again.", )) return } diff --git a/pkg/websocket.go b/pkg/websocket.go index 0f070ed..ff8988a 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -21,20 +21,17 @@ import ( // Subscriber represents a connected WebSocket session. type Subscriber struct { // User properties - ID int // ID assigned by server - Username string - VideoActive bool - VideoMutual bool - VideoMutualOpen bool - VideoNSFW bool - ChatStatus string - JWTClaims *jwt.Claims - authenticated bool // has passed the login step - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - messages chan []byte - closeSlow func() + ID int // ID assigned by server + Username string + ChatStatus string + VideoStatus int + JWTClaims *jwt.Claims + authenticated bool // has passed the login step + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + messages chan []byte + closeSlow func() muteMu sync.RWMutex booted map[string]struct{} // usernames booted off your camera @@ -130,8 +127,7 @@ func (sub *Subscriber) SendMe() { sub.SendJSON(Message{ Action: ActionMe, Username: sub.Username, - VideoActive: sub.VideoActive, - NSFW: sub.VideoNSFW, + VideoStatus: sub.VideoStatus, }) } @@ -384,18 +380,14 @@ func (s *Server) SendWhoList() { } who := WhoList{ - Username: user.Username, - Status: user.ChatStatus, - VideoActive: user.VideoActive, - VideoMutual: user.VideoMutual, - VideoMutualOpen: user.VideoMutualOpen, - NSFW: user.VideoNSFW, + Username: user.Username, + Status: user.ChatStatus, + Video: user.VideoStatus, } // If this person had booted us, force their camera to "off" if user.Boots(sub.Username) || user.Mutes(sub.Username) { - who.VideoActive = false - who.NSFW = false + who.Video = 0 } if user.JWTClaims != nil { diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index f53adb1..f467d25 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -127,6 +127,16 @@ const app = Vue.createApp({ audioDeviceID: null, }, + // Video flag constants (sync with values in messages.go) + VideoFlag: { + Active: 1 << 0, + NSFW: 1 << 1, + Muted: 1 << 2, + IsTalking: 1 << 3, + MutualRequired: 1 << 4, + MutualOpen: 1 << 5, + }, + // WebRTC sessions with other users. WebRTC: { // Streams per username. @@ -315,6 +325,16 @@ const app = Vue.createApp({ // Returns if the current user has operator rights return this.jwt.claims.op; }, + myVideoFlag() { + // Compute the current user's video status flags. + let status = 0; + if (this.webcam.active) status |= this.VideoFlag.Active; + if (this.webcam.muted) status |= this.VideoFlag.Muted; + if (this.webcam.nsfw) status |= this.VideoFlag.NSFW; + if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired; + if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen; + return status; + }, }, methods: { // Load user prefs from localStorage, called on startup @@ -375,11 +395,8 @@ const app = Vue.createApp({ sendMe() { this.ws.conn.send(JSON.stringify({ action: "me", - videoActive: this.webcam.active, - videoMutual: this.webcam.mutual, - videoMutualOpen: this.webcam.mutualOpen, + video: this.myVideoFlag, status: this.status, - nsfw: this.webcam.nsfw, })); }, onMe(msg) { @@ -391,11 +408,11 @@ const app = Vue.createApp({ } // The server can set our webcam NSFW flag. - if (this.webcam.nsfw != msg.nsfw) { - this.webcam.nsfw = msg.nsfw; + let myNSFW = this.webcam.nsfw; + let theirNSFW = (msg.video & this.VideoFlag.NSFW) > 0; + if (myNSFW != theirNSFW) { + this.webcam.nsfw = theirNSFW; } - - // this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`); }, // WhoList updates. @@ -412,14 +429,14 @@ const app = Vue.createApp({ for (let row of this.whoList) { this.whoMap[row.username] = row; if (this.WebRTC.streams[row.username] != undefined && - row.videoActive !== true) { + !(row.video & this.VideoFlag.Active)) { this.closeVideo(row.username, "offerer"); } } // Has the back-end server forgotten we are on video? This can // happen if we disconnect/reconnect while we were streaming. - if (this.webcam.active && !this.whoMap[this.username]?.videoActive) { + if (this.webcam.active && !(this.whoMap[this.username]?.video & this.VideoFlag.Active)) { this.sendMe(); } }, @@ -473,18 +490,10 @@ const app = Vue.createApp({ }, onOpen(msg) { // Response for the opener to begin WebRTC connection. - const secret = msg.openSecret; - // console.log("OPEN: connect to %s with secret %s", msg.username, secret); - // this.ChatClient(`onOpen called for ${msg.username}.`); - this.startWebRTC(msg.username, true); }, onRing(msg) { - // Message for the receiver to begin WebRTC connection. - const secret = msg.openSecret; - // console.log("RING: connection from %s with secret %s", msg.username, secret); this.ChatServer(`${msg.username} has opened your camera.`); - this.startWebRTC(msg.username, false); }, onUserExited(msg) { @@ -768,7 +777,7 @@ const app = Vue.createApp({ // If we are the offerer, and this member wants to auto-open our camera // then add our own stream to the connection. - if (isOfferer && this.whoMap[username].videoMutualOpen && this.webcam.active) { + if (isOfferer && (this.whoMap[username].video & this.VideoFlag.MutualOpen) && this.webcam.active) { let stream = this.webcam.stream; stream.getTracks().forEach(track => { pc.addTrack(track, stream) @@ -1113,7 +1122,7 @@ const app = Vue.createApp({ // Is the target user NSFW? Go thru the modal. let dontShowAgain = localStorage["skip-nsfw-modal"] == "true"; - if (user.nsfw && !dontShowAgain && !force) { + if ((user.video & this.VideoFlag.NSFW) && !dontShowAgain && !force) { this.nsfwModalView.user = user; this.nsfwModalView.visible = true; return; @@ -1130,7 +1139,7 @@ const app = Vue.createApp({ } // If this user requests mutual viewership... - if (user.videoMutual && !this.webcam.active) { + if ((user.video & this.VideoFlag.MutualRequired) && !this.webcam.active) { this.ChatClient( `${user.username} has requested that you should share your own camera too before opening theirs.` ); @@ -1155,6 +1164,8 @@ const app = Vue.createApp({ if (name === "offerer") { // We are closing another user's video stream. delete (this.WebRTC.streams[username]); + delete (this.WebRTC.muted[username]); + delete (this.WebRTC.poppedOut[username]); if (this.WebRTC.pc[username] != undefined && this.WebRTC.pc[username].offerer != undefined) { this.WebRTC.pc[username].offerer.close(); delete (this.WebRTC.pc[username]); @@ -1183,6 +1194,8 @@ const app = Vue.createApp({ this.WebRTC.pc[username].answerer.close(); } delete (this.WebRTC.pc[username]); + delete (this.WebRTC.muted[username]); + delete (this.WebRTC.poppedOut[username]); } // Inform backend we have closed it. @@ -1193,16 +1206,33 @@ const app = Vue.createApp({ // and then we turn ours off: we should unfollow the ones with mutual video. for (let row of this.whoList) { let username = row.username; - if (row.videoMutual && this.WebRTC.pc[username] != undefined) { + if ((row.video & this.VideoFlag.MutualRequired) && this.WebRTC.pc[username] != undefined) { this.closeVideo(username); } } }, + webcamIconClass(user) { + // Return the icon to show on a video button. + // - Usually a video icon + // - May be a crossed-out video if isVideoNotAllowed + // - Or an eyeball for cameras already opened + if (user.username === this.username && this.webcam.active) { + return 'fa-eye'; // user sees their own self camera always + } + + // Already opened? + if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.streams[user.username] != undefined) { + return 'fa-eye'; + } + + if (this.isVideoNotAllowed(user)) return 'fa-video-slash'; + return 'fa-video'; + }, isVideoNotAllowed(user) { // Returns whether the video button to open a user's cam will be not allowed (crossed out) // Mutual video sharing is required on this camera, and ours is not active - if (user.videoActive && user.videoMutual && !this.webcam.active) { + if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired) && !this.webcam.active) { return true; } @@ -1263,6 +1293,7 @@ const app = Vue.createApp({ this.webcam.elem.srcObject = null; this.webcam.stream = null; this.webcam.active = false; + this.webcam.muted = false; this.whoTab = "online"; // Close all WebRTC sessions. @@ -1283,6 +1314,16 @@ const app = Vue.createApp({ this.webcam.stream.getAudioTracks().forEach(track => { track.enabled = !this.webcam.muted; }); + + // Communicate our local mute to others. + this.sendMe(); + }, + isSourceMuted(username) { + // See if the webcam broadcaster muted their mic at the source + if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.Muted) { + return true; + } + return false; }, isMuted(username) { return this.WebRTC.muted[username] === true; diff --git a/web/templates/chat.html b/web/templates/chat.html index ed2b2ae..c58a421 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -544,12 +544,26 @@ - [[c.name]] - - [[hasUnread(c.channel)]] - +
+ +
+ + +
+ +
+ [[c.name]] + + + [[hasUnread(c.channel)]] + +
+
+
@@ -621,6 +635,8 @@
+ [[username]]
@@ -659,6 +675,8 @@ autoplay>
+ [[username]]
@@ -1022,19 +1040,19 @@