NSFW Cameras and Moderator Commands

This commit is contained in:
Noah 2023-02-10 22:46:39 -08:00
parent b966f85ecc
commit 15ebc42bd3
10 changed files with 286 additions and 43 deletions

View File

@ -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 <username>` to disconnect a user's chat session.
* `/nsfw <username>` 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:

65
pkg/commands.go Normal file
View File

@ -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 <username>` to kick from chat\n" +
"* `/nsfw <username>` to mark their camera NSFW\n" +
"* `/help` to show this message",
))
return true
}
}
// Not handled.
return false
}

View File

@ -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

View File

@ -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()

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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,
})
}
}

View File

@ -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;
}

View File

@ -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, // <video id="localVideo"> element
stream: null, // MediaStream object
muted: false, // our outgoing mic is muted, not by default
nsfw: false, // user has flagged their camera to be NSFW
// Who all is watching me? map of users.
watching: {},
@ -121,6 +124,16 @@ const app = Vue.createApp({
settingsModal: {
visible: false,
},
nsfwModalCast: {
visible: false,
},
nsfwModalView: {
visible: false,
dontShowAgain: false,
user: null, // staged User we wanted to open
},
}
},
mounted() {
@ -258,6 +271,7 @@ const app = Vue.createApp({
this.ws.conn.send(JSON.stringify({
action: "me",
videoActive: this.webcam.active,
nsfw: this.webcam.nsfw,
}));
},
onMe(msg) {
@ -268,6 +282,11 @@ const app = Vue.createApp({
this.username = msg.username;
}
// The server can set our webcam NSFW flag.
if (this.webcam.nsfw != msg.nsfw) {
this.webcam.nsfw = msg.nsfw;
}
// this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`);
},
@ -386,9 +405,11 @@ const app = Vue.createApp({
this.ws.connected = false;
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
if (ev.code !== 1001) {
this.ChatClient("Reconnecting in 5s");
setTimeout(this.dial, 5000);
if (!this.disconnect) {
if (ev.code !== 1001) {
this.ChatClient("Reconnecting in 5s");
setTimeout(this.dial, 5000);
}
}
});
@ -457,6 +478,9 @@ const app = Vue.createApp({
isChatServer: true,
});
break;
case "disconnect":
this.disconnect = true;
break;
case "ping":
break;
default:
@ -755,10 +779,16 @@ const app = Vue.createApp({
},
// Start broadcasting my webcam.
startVideo() {
startVideo(force) {
if (this.webcam.busy) return;
this.webcam.busy = true;
// If we are running in PermitNSFW mode, show the user the modal.
if (this.config.permitNSFW && !force) {
this.nsfwModalCast.visible = true;
return;
}
this.webcam.busy = true;
navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
@ -777,12 +807,24 @@ const app = Vue.createApp({
},
// Begin connecting to someone else's webcam.
openVideo(user) {
openVideo(user, force) {
if (user.username === this.username) {
this.ChatClient("You can already see your own webcam.");
return;
}
// Is the target user NSFW? Go thru the modal.
let dontShowAgain = localStorage["skip-nsfw-modal"] == "true";
if (user.nsfw && !dontShowAgain && !force) {
this.nsfwModalView.user = user;
this.nsfwModalView.visible = true;
return;
}
if (this.nsfwModalView.dontShowAgain) {
// user doesn't want to see the modal again.
localStorage["skip-nsfw-modal"] = "true";
}
// Camera is already open? Then disconnect the connection.
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.pc[user.username].offerer != undefined) {
this.closeVideo(user.username, "offerer");

View File

@ -176,6 +176,89 @@
</div>
</div>
<!-- NSFW Modal: before user activates their webcam -->
<div class="modal" :class="{'is-active': nsfwModalCast.visible}">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">Broadcast my webcam</p>
</header>
<div class="card-content">
<p class="block">
You can turn on your webcam and enable others in the room to connect to yours.
The controls to stop, <i class="fa fa-microphone-slash"></i> mute audio, and
<i class="fa fa-eye"></i> see who is watching will be at the top of the page.
</p>
<p class="block">
If your camera will be featuring "<abbr title="Not Safe For Work">NSFW</abbr>" or sexual content, please mark it as such by
clicking on the <i class="fa fa-fire has-text-danger"></i> button or checking the box below to start with it enabled.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox"
v-model="webcam.nsfw">
Check this box if your webcam will <em>definitely</em> be NSFW. 😈
</label>
</div>
<div class="field">
<div class="control has-text-centered">
<button type="button"
class="button is-link mr-4"
@click="startVideo(true); nsfwModalCast.visible=false">Start webcam</button>
<button type="button"
class="button"
@click="nsfwModalCast.visible=false">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- NSFW Modal: before user views a NSFW camera the first time -->
<div class="modal" :class="{'is-active': nsfwModalView.visible}">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">This camera may contain NSFW content</p>
</header>
<div class="card-content">
<p class="block">
This camera has been marked as "<abbr title="Not Safe For Work">NSFW</abbr>" and may
contain displays of sexuality. If you do not want to see this, look for cameras with
a <span class="button is-small is-info is-outlined px-1"><i class="fa fa-video"></i></span>
blue icon rather than the <span class="button is-small is-danger is-outlined px-1"><i class="fa fa-video"></i></span>
red ones.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox"
v-model="nsfwModalView.dontShowAgain">
Don't show this message again
</label>
</div>
<div class="field">
<div class="control has-text-centered">
<button type="button"
class="button is-link mr-4"
@click="openVideo(nsfwModalView.user, true); nsfwModalView.visible=false">Open webcam</button>
<button type="button"
class="button"
@click="nsfwModalView.visible=false">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="chat-container">
<!-- Top header panel -->
@ -188,18 +271,18 @@
<!-- Stop/Start video buttons -->
<button type="button"
v-if="webcam.active"
class="button is-small is-danger"
class="button is-small is-danger px-1"
@click="stopVideo()">
<i class="fa fa-camera mr-2"></i>
<i class="fa fa-stop mr-2"></i>
Stop
</button>
<button type="button"
v-else
class="button is-small is-success"
class="button is-small is-success px-1"
@click="startVideo()"
:disabled="webcam.busy">
<i class="fa fa-camera mr-2"></i>
Start
<i class="fa fa-video mr-2"></i>
Share webcam
</button>
<!-- Mute/Unmute my mic buttons (if streaming)-->
@ -226,6 +309,17 @@
<i class="fa fa-eye mr-2"></i>
[[Object.keys(webcam.watching).length]]
</button>
<!-- NSFW toggle button -->
<button type="button"
v-if="webcam.active && config.permitNSFW"
class="button is-small px-1 ml-1"
:class="{'is-outlined is-dark': !webcam.nsfw,
'is-danger': webcam.nsfw}"
@click.prevent="webcam.nsfw = !webcam.nsfw; sendMe()"
title="Toggle the NSFW setting for your camera broadcast">
<i class="fa fa-fire"></i>
</button>
</div>
<div class="column is-narrow pl-1">
<a href="/about" target="_blank" class="button is-small is-link px-2">
@ -354,8 +448,8 @@
<!-- My video -->
<video class="feed"
v-show="webcam.active"
id="localVideo"
v-show="webcam.active"
autoplay muted>
</video>
@ -536,6 +630,10 @@
<!-- Video button -->
<button type="button" class="button is-small px-2 py-1"
:disabled="!u.videoActive"
:class="{
'is-danger is-outlined': u.videoActive && u.nsfw,
'is-info is-outlined': u.videoActive && !u.nsfw,
}"
title="Open video stream"
@click="openVideo(u)">
<i class="fa fa-video"></i>
@ -555,6 +653,7 @@
<script type="text/javascript">
const PublicChannels = {{.Config.GetChannels}};
const WebsiteURL = "{{.Config.WebsiteURL}}";
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
const UserJWTToken = {{.JWTTokenString}};
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
const UserJWTClaims = {{.JWTClaims.ToJSON}};