diff --git a/README.md b/README.md index 945a1ac..9d880a1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Title = "BareRTC" Branding = "BareRTC" WebsiteURL = "https://www.example.com" UseXForwardedFor = true +CORSHosts = ["https://www.example.com"] +PermitNSFW = true [JWT] Enabled = false @@ -59,6 +61,8 @@ A description of the config directives includes: * The About page will link to your website. * If using [JWT authentication](#authentication), avatar and profile URLs may be relative (beginning with a "/") and will append to your website URL to safe space on the JWT token size! * **UseXForwardedFor**: set it to true and (for logging) the user's remote IP will use the X-Real-IP header or the first address in X-Forwarded-For. Set this if you run the app behind a proxy like nginx if you want IPs not to be all localhost. + * **CORSHosts**: your website's domain names that will be allowed to access [JSON APIs](#JSON APIs), like `/api/statistics`. + * **PermitNSFW**: for user webcam streams, expressly permit "NSFW" content if the user opts in to mark their feed as such. Setting this will enable pop-up modals regarding NSFW video and give broadcasters an opt-in button, which will warn other users before they click in to watch. * **JWT**: settings for JWT [Authentication](#authentication). * Enabled (bool): activate the JWT token authentication feature. * Strict (bool): if true, **only** valid signed JWT tokens may log in. If false, users with no/invalid token can enter their own username without authentication. @@ -175,6 +179,30 @@ This app is not designed to run without JWT authentication for users enabled. In Note that they would not get past history of those DMs as this server only pushes _new_ messages to users after they connect. +# Moderator Commands + +If you authenticate an Op user via JWT they can enter IRC-style chat commands to moderate the server. Current commands include: + +* `/kick ` to disconnect a user's chat session. +* `/nsfw ` to tag a user's video feed as NSFW (if your settings.toml has PermitNSFW enabled). + +# JSON APIs + +For better integration with your website, the chat server exposes some data via JSON APIs ready for cross-origin ajax requests. In your settings.toml set the `CORSHosts` to your list of website domains, such as "https://www.example.com", "http://localhost:8080" or so on. + +Current API endpoints include: + +* `GET /api/statistics` + +Returns basic info about the count and usernames of connected chatters: + +```json +{ + "UserCount": 1, + "Usernames": ["admin"] +} +``` + # Deploying This App It is recommended to use a reverse proxy such as nginx in front of this app. You will need to configure nginx to forward WebSocket related headers: diff --git a/pkg/commands.go b/pkg/commands.go new file mode 100644 index 0000000..ac868e8 --- /dev/null +++ b/pkg/commands.go @@ -0,0 +1,65 @@ +package barertc + +import "strings" + +// ProcessCommand parses a chat message for "/commands" +func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { + if len(msg.Message) == 0 || msg.Message[0] != '/' { + return false + } + + // Line begins with a slash, parse it apart. + words := strings.Fields(msg.Message) + if len(words) == 0 { + return false + } + + // Moderator commands. + if sub.JWTClaims != nil && sub.JWTClaims.IsAdmin { + switch words[0] { + case "/kick": + if len(words) == 1 { + sub.ChatServer("Usage: `/kick username` to remove the user from the chat room.") + } + username := words[1] + other, err := s.GetSubscriber(username) + if err != nil { + sub.ChatServer("/kick: username not found: %s", username) + } else { + other.ChatServer("You have been kicked from the chat room by %s", sub.Username) + other.SendJSON(Message{ + Action: ActionKick, + }) + s.DeleteSubscriber(other) + sub.ChatServer("%s has been kicked from the room", username) + } + return true + case "/nsfw": + if len(words) == 1 { + sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.") + } + username := words[1] + other, err := s.GetSubscriber(username) + if err != nil { + 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.SendMe() + s.SendWhoList() + sub.ChatServer("%s has their camera marked as NSFW", username) + } + return true + case "/help": + sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" + + "* `/kick ` to kick from chat\n" + + "* `/nsfw ` to mark their camera NSFW\n" + + "* `/help` to show this message", + )) + return true + } + } + + // Not handled. + return false +} diff --git a/pkg/config/config.go b/pkg/config/config.go index f1280af..d8a0e6f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,7 +12,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 = 2 +var currentVersion = 3 // Config for your BareRTC app. type Config struct { @@ -27,7 +27,9 @@ type Config struct { Title string Branding string WebsiteURL string + CORSHosts []string + PermitNSFW bool UseXForwardedFor bool diff --git a/pkg/handlers.go b/pkg/handlers.go index eb49afc..5f195a5 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -107,6 +107,11 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) { return } + // Process commands. + if handled := s.ProcessCommand(sub, msg); handled { + return + } + // Translate their message as Markdown syntax. markdown := RenderMarkdown(msg.Message) if markdown == "" { @@ -115,11 +120,10 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) { // Message to be echoed to the channel. var message = Message{ - Action: ActionMessage, - Channel: msg.Channel, - Username: sub.Username, - Message: markdown, - Timestamp: time.Now(), + Action: ActionMessage, + Channel: msg.Channel, + Username: sub.Username, + Message: markdown, } // Is this a DM? @@ -144,6 +148,7 @@ func (s *Server) OnMe(sub *Subscriber, msg Message) { } sub.VideoActive = msg.VideoActive + sub.VideoNSFW = msg.NSFW // Sync the WhoList to everybody. s.SendWhoList() diff --git a/pkg/messages.go b/pkg/messages.go index fb792f8..147a270 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -1,13 +1,10 @@ package barertc -import "time" - type Message struct { - Action string `json:"action,omitempty"` - Channel string `json:"channel,omitempty"` - Username string `json:"username,omitempty"` - Message string `json:"message,omitempty"` - Timestamp time.Time `json:"at,omitempty"` + Action string `json:"action,omitempty"` + Channel string `json:"channel,omitempty"` + Username string `json:"username,omitempty"` + Message string `json:"message,omitempty"` // JWT token for `login` actions. JWTToken string `json:"jwt,omitempty"` @@ -17,6 +14,7 @@ type Message struct { // Sent on `me` actions along with Username VideoActive bool `json:"videoActive,omitempty"` // user tells us their cam status + NSFW bool `json:"nsfw,omitempty"` // user tags their video NSFW // Sent on `open` actions along with the (other) Username. OpenSecret string `json:"openSecret,omitempty"` @@ -40,9 +38,10 @@ const ( // Actions sent by server only ActionPing = "ping" - ActionWhoList = "who" // server pushes the Who List - ActionPresence = "presence" // a user joined or left the room - ActionError = "error" // ChatServer errors + ActionWhoList = "who" // server pushes the Who List + ActionPresence = "presence" // a user joined or left the room + ActionError = "error" // ChatServer errors + ActionKick = "disconnect" // client should disconnect (e.g. have been kicked). // WebRTC signaling messages. ActionCandidate = "candidate" @@ -52,10 +51,11 @@ const ( // WhoList is a member entry in the chat room. type WhoList struct { Username string `json:"username"` - VideoActive bool `json:"videoActive"` + VideoActive bool `json:"videoActive,omitempty"` + NSFW bool `json:"nsfw,omitempty"` // JWT auth extra settings. Operator bool `json:"op"` - Avatar string `json:"avatar"` - ProfileURL string `json:"profileURL"` + Avatar string `json:"avatar,omitempty"` + ProfileURL string `json:"profileURL,omitempty"` } diff --git a/pkg/pages.go b/pkg/pages.go index e4247b3..492702a 100644 --- a/pkg/pages.go +++ b/pkg/pages.go @@ -65,6 +65,9 @@ func IndexPage() http.HandlerFunc { "AsHTML": func(v string) template.HTML { return template.HTML(v) }, + "AsJS": func(v interface{}) template.JS { + return template.JS(fmt.Sprintf("%v", v)) + }, }) tmpl, err := tmpl.ParseFiles("web/templates/chat.html") if err != nil { diff --git a/pkg/websocket.go b/pkg/websocket.go index 945167e..6306c0f 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -21,6 +21,7 @@ type Subscriber struct { ID int // ID assigned by server Username string VideoActive bool + VideoNSFW bool JWTClaims *jwt.Claims authenticated bool // has passed the login step conn *websocket.Conn @@ -160,10 +161,9 @@ func (s *Server) WebSocket() http.HandlerFunc { if err != nil { return } - case timestamp := <-pinger.C: + case <-pinger.C: sub.SendJSON(Message{ - Action: ActionPing, - Message: timestamp.Format(time.RFC3339), + Action: ActionPing, }) case <-ctx.Done(): pinger.Stop() @@ -254,11 +254,6 @@ func (s *Server) SendTo(username string, msg Message) error { s.subscribersMu.RLock() defer s.subscribersMu.RUnlock() - // If no timestamp, add it. - if msg.Timestamp.IsZero() { - msg.Timestamp = time.Now() - } - var found bool for _, sub := range s.IterSubscribers(true) { if sub.Username == username { @@ -293,6 +288,7 @@ func (s *Server) SendWhoList() { who := WhoList{ Username: sub.Username, VideoActive: sub.VideoActive, + NSFW: sub.VideoNSFW, } if sub.JWTClaims != nil { who.Operator = sub.JWTClaims.IsAdmin @@ -304,9 +300,8 @@ func (s *Server) SendWhoList() { for _, sub := range subscribers { sub.SendJSON(Message{ - Action: ActionWhoList, - WhoList: users, - Timestamp: time.Now(), + Action: ActionWhoList, + WhoList: users, }) } } diff --git a/web/static/css/chat.css b/web/static/css/chat.css index 8536c02..93b1d21 100644 --- a/web/static/css/chat.css +++ b/web/static/css/chat.css @@ -148,7 +148,7 @@ body { .video-feeds > .feed { position: relative; - flex: 0 0 144px; + flex: 0 0 168px; width: 168px; height: 112px; background-color: black; @@ -156,21 +156,25 @@ body { } .video-feeds.x1 > .feed { + flex: 0 0 252px; width: 252px; height: 168px; } .video-feeds.x2 > .feed { + flex: 0 0 336px; width: 336px; height: 224px; } .video-feeds.x3 > .feed { + flex: 0 0 504px; width: 504px; height: 336px; } .video-feeds.x4 > .feed { + flex: 0 0 672px; width: 672px; height: 448px; } diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 502f5b0..1fec4fb 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -13,6 +13,7 @@ const app = Vue.createApp({ data() { return { // busy: false, // TODO: not used + disconnect: false, // don't try to reconnect (e.g. kicked) windowFocused: true, // browser tab is active windowFocusedAt: new Date(), @@ -20,6 +21,7 @@ const app = Vue.createApp({ config: { channels: PublicChannels, website: WebsiteURL, + permitNSFW: PermitNSFW, sounds: { available: SoundEffects, settings: DefaultSounds, @@ -58,6 +60,7 @@ const app = Vue.createApp({ elem: null, //