Compare commits

...

61 Commits

Author SHA1 Message Date
98bf0d9e84 Fix bug in mute user modal 2024-10-27 12:29:01 -07:00
f2629ecb06 Diagnostic feature for the dark video detector 2024-10-19 13:49:29 -07:00
134f9218a8 Add webcam troubleshooting tips to the About page 2024-10-17 20:13:08 -07:00
358e8d5aec CSS fix 2024-10-04 23:32:28 -07:00
9cd6ee98a4 Tweak CSS for upvoted emoji reactions 2024-10-04 23:23:45 -07:00
671857952d CSS fix 2024-10-04 22:56:20 -07:00
96e1a6efa4 Button to show who reacted (for mobile) 2024-10-04 22:51:16 -07:00
89ae43b78a Disable right-click on video 2024-10-04 22:23:22 -07:00
9e7466f967 Disable dark video detector 2024-10-04 21:41:48 -07:00
095cf1d4ea Animate the video watermark 2024-10-04 21:39:19 -07:00
a3d0cc95f9 Cleanup debug message 2024-10-02 20:35:52 -07:00
f802de88ce Watermark QR code over webcam feeds to deter screen recording 2024-10-02 20:33:57 -07:00
a70d6d54b3 Fullscreen: bring video control buttons along 2024-10-01 21:32:54 -07:00
3a7204178c Various fixes regarding red cameras
* When an admin has marked your camera 'red' for you, the Explicit button at the
  top of the page now will require *two* clicks if the user wants to set their
  camera back to blue:

  On first click, they will get a red ChatClient message explaining that their
  cam was most-recently marked red due to a server action (such as a moderator
  flagging their cam for them). If they really mean to mark their camera blue,
  they are instructed to click it a second time to confirm.
* This behavior only occurs when the most recent NSFW setting was dictated by
  the server (e.g. a 'me' event disagreed with the page's local NSFW setting).
  The flag is cleared any time the user themself toggles the flag, or when the
  first ChatClient warning after a server event is shown.
* The explicit camera settings in the broadcast modal have been rearranged and
  reworded.
* Add an 'advanced' webcam feature to automatically broadcast in the future on
  page load. The option is only available in the Chat Settings 'Camera' tab
  after you are already broadcasting (or rather: when a list of video devices
  have become available to the page, indicating the user has possibly granted
  permission already).
2024-09-21 15:48:47 -07:00
971a6d800d Customizable error strings for moderation rules 2024-09-20 20:33:42 -07:00
16b148fc92 JWT Token Chat Moderation Rules
Add support for your website to apply chat moderation rules to users
as they log onto the chat room.
2024-09-19 17:29:08 -07:00
bbd6836c68 Enter/Escape keys to control AlertModal 2024-09-18 22:31:58 -07:00
7f88439c84 Semitransparent video icons until mouseover 2024-09-12 18:55:45 -07:00
9b8e7dc440 Vue modals to replace window.alert/window.confirm
Apparently some iPad browsers were having their local webcam freeze
after a window.confirm prompt was shown. This replaces all uses of
window.confirm/window.alert with an in-app modal.
2024-09-10 21:24:52 -07:00
d4b69311ae Blockable admin user support 2024-09-10 14:50:47 -07:00
49712ee966 Add install instructions 2024-06-21 22:36:43 -07:00
72b6c45583 Add playsinline to video elements for iPhone 2024-06-21 22:03:02 -07:00
4c9d207b62 Sound effect for public channel @mentions 2024-06-19 13:20:19 -07:00
147315fee2 Video z-index workaround for Safari
When the Safari browser puts a webcam video full-screen and then
returns, the z-index of the video was higher than the buttons and
controls normally overlaid on top of it.

Add a z-index:1 to the video controls to keep them on top after
returning from full screen. Similar: for popped-out draggable videos,
adding a z-index:1 allows the video to correctly sit on top of docked
videos without the docked video controls (zi:1) rendering above the
popped-out video when you overlap them.

Note: the z-index:1 is applied to popped-out and video controls, any
other combination (e.g. 1 for popped-out and 2 for controls) caused
controls of docked videos to render on top of popped-out ones when they
overlapped.
2024-06-13 14:28:29 -07:00
b011e36ddf Adjust dark webcam threshold 2024-05-27 20:08:32 +00:00
a536862a91 Dark video detection
* Add local detection for users who are broadcasting dark (e.g. mostly
  or completely black) video feeds from their local device.
* Every 5 seconds while the webcam is active, the average RGB color is
  sampled. If the average color value remains below 60 (out of 255) for
  two consecutive samples, the camera is stopped automatically.
2024-05-18 19:09:11 -07:00
fd36d09727 Update README for moderator commands 2024-05-17 20:47:19 -07:00
9c77bdb62e New Op commands and fixes with blocking admin users
Add moderation rules:

* You can apply rules in the settings.toml to enforce moderator restrictions on
  certain users, e.g. to force their camera to always be NSFW or bar them from
  sharing their webcam at all anymore.

Chat UI improvements around users blocking admin accounts:

* When a main website block is in place, the DMs button in the Who List shows
  as greyed out with a cross through, as if that user had closed their DMs.
* Admin users are always able to watch the camera of people who have blocked
  them. The broadcaster is not notified about the watch.

New operator commands:

* /cut username: to tell a user to turn off their webcam.
* /unmute-all: to lift all mutes on your side, e.g. so your moderator chatbot
  can still see public messages from users who have blocked it.
* /help-advanced: moved the more dangerous admin command documentation here.

Miscellaneous fixes:

* The admin commands now tolerate an @ prefix in front of usernames.
* The /nsfw command won't fire unless the user's camera is actually active and
  not marked as explicit.
2024-05-17 17:15:48 -07:00
b74edd1512 A background graphic for videos to detect broken connections 2024-05-15 19:42:51 -07:00
b82e8f651b Don't count unread messages for DebugChannel 2024-05-13 22:28:38 -07:00
747f4fd5d4 Let channels configure whether to permit photos 2024-05-13 18:51:54 -07:00
745c282650 Bugfix in isOp function 2024-05-10 22:24:07 -07:00
e70b439cdd Admin command buttons in the profile modal 2024-05-10 21:32:32 -07:00
b5bbbde784 Update the About page to remove Safari/iPad notes 2024-05-09 21:24:03 -07:00
f36c83dbcc Add receive-only transceivers to remove Apple compat mode
* WebRTC functionality is now 100% working as intended for Safari and
  iPad browsers!
* The legacy WebRTC API had properties like offerToReceiveVideo
  available on createOffer(), to set up a receive-only channel, but the
  modern WebRTC API had removed these and Safari only supports the
  modern API.
* The modern solution for the same feature is to add a recvonly
  transceiver to the connection in place of offering a local video/audio
  stream to share.
2024-05-08 12:44:15 -07:00
f094213a34 Improve WebRTC connection for Safari browsers 2024-05-07 20:54:13 -07:00
b8b53c65f3 Color improvement in dark theme 2024-04-13 14:55:03 -07:00
3424be2f4d Clear DMs history button 2024-04-11 23:28:35 -07:00
d510ac791f Click names in video feeds to open their profile card 2024-04-09 17:57:37 -07:00
9932cb5a2c Fix DM buttons on light theme 2024-04-07 11:56:29 -07:00
93c4e12680 Theme fix for emoji picker popup 2024-04-06 16:59:23 -07:00
a0786b2fa9 At-mention background on dark theme 2024-04-06 16:03:30 -07:00
bef135fbd6 Background color fix on DMs 2024-04-06 16:01:32 -07:00
ed82920de9 Dark mode color tweaks 2024-04-06 15:54:37 -07:00
92a376786d Update to Bulma CSS 1.0
* Update the CSS and add a theme selector to the Chat Settings to force a
  light or dark mode theme (default is automatic).
2024-04-06 14:35:52 -07:00
f0dd1d952c Direct Message History
* Add support for storing DM history between users in a SQLite3 database.
* Opt-in by editing your settings.toml to set DirectMessageHistory/Enabled=true
* Retention days (default 90) will flush old DMs on app startup.
* On the front-end, DM history is checked when a DM thread is opened.
2024-03-29 17:48:01 -07:00
96d61614f4 Add DisconnectNow API endpoint 2024-03-15 15:59:42 -07:00
c7ef254361 Disable right-click in image modal 2024-02-29 19:30:51 -08:00
2a0f8b0cdf Disable right-click on user shared images 2024-02-29 09:29:06 -08:00
5e68c99514 Update page title with unread DM count 2024-02-10 16:35:32 -08:00
206784e0b9 Make the X button on cameras more touchable 2024-01-27 13:15:58 -08:00
e74f7297e6 Include DM context in reported messages 2024-01-20 15:17:02 -08:00
8e87c377e8 Apple compatibility mode for WebRTC
* Try a new strategy to get Apple (iPad/iPhone) webcams to connect.
* "Apple compatibility mode" setting: on by default if iPad/iPhone is
  detected or can be opted into in the chat settings Misc tab.
* In Apple compat mode: when you open someone else's webcam, you always
  attach your local video on the WebRTC offer. This would normally make
  your video auto-open on the remote side, but the previous commit
  updates the chat page to ignore offered video if you did not opt-in to
  auto-open your viewer's camera.
* This should satisfy the two-way video call limitation in Safari: the
  iPad always shares its video and gets video from the person they are
  watching.
* If the person they are watching did not auto-open your video: they
  ignore the attached video on your offer and don't display it.
2024-01-11 20:33:57 -08:00
bf59a7b6c9 Auto-mute other users' webcam sound channels 2024-01-11 19:45:32 -08:00
27380ec558 Status Message overhaul 2023-12-30 14:50:52 -08:00
ebf5b3f47e Fix image click handler and emoji popup 2023-12-22 21:59:23 -08:00
21797788a2 Disable cursor events on images (interferes with drag/drop) 2023-12-21 20:23:57 -08:00
dffd432221 Share images by drag/drop onto page 2023-12-21 17:47:17 -08:00
449929b8d1 Fix mouse cursor over VIP webcam checkbox on broadcast modal 2023-12-21 14:13:52 -08:00
aa162a5b7a Resync Mutes and Boots on reconnect to server 2023-12-21 14:11:37 -08:00
139f9ece70 Bugfix in WebRTC video stream handler 2023-12-18 17:59:35 -08:00
58 changed files with 21881 additions and 16756 deletions

128
Install.md Normal file
View File

@ -0,0 +1,128 @@
# Installing BareRTC
This document will explain how to download and install BareRTC on your own web server.
At this time, BareRTC is not released as a versioned pre-built archive, but as source code. This may change in the future but for now you'll need to git clone or download the source code and compile it, all of which should be easy to do on a Linux or macOS server.
- [Installing BareRTC](#installing-barertc)
- [Requirements \& Dependencies](#requirements--dependencies)
- [Installation](#installation)
- [Deploying to Production](#deploying-to-production)
- [Developing This App](#developing-this-app)
- [License](#license)
## Requirements & Dependencies
To run BareRTC on your own website, you will generally need:
* A dedicated server or <abbr title="Virtual Private Server">VPS</abbr> for your web hosting, e.g. with root access to the console to be able to install and configure software.
* Any Linux distribution or a macOS server will work. You may be able to use a Windows server but this is out of scope for this document and you're on your own there.
* The BareRTC server is written in pure Go so any platform that the Go language can compile for should work.
* Note: if you don't have access to manage your server (e.g. you are on a shared web hosting plan with only FTP upload access), you **will not** be able to run BareRTC.
* Recommended: a reverse proxy server like NGINX.
Your server may need programming languages for Go and JavaScript (node.js) in order to compile BareRTC and build its front-end javascript app.
```bash
# Debian or Ubuntu
sudo apt update
sudo apt install golang nodejs npm
# Fedora
sudo dnf install golang nodejs npm
# Mac OS (with homebrew, https://brew.sh)
brew install golang nodejs npm
```
## Installation
The recommended method is to use **git** to download a clone of the source code repository. This way you can update the app by running a `git pull` command to get the latest source.
```bash
# Clone the git repository and change
git clone https://git.kirsle.net/apps/BareRTC
cd BareRTC/
# Compile the front-end javascript single page app
npm install
npm run build
# Compile the back-end Go app to ./BareRTC
make build
# Or immediately run the app from Go source code now
# Listens on http://localhost:9000
make run
# Command line interface to run the binary:
./BareRTC -address :9000 -debug
```
You can also download the repository as a ZIP file or tarball, though updating the code for future versions of BareRTC is a more manual process then.
* ZIP download: https://git.kirsle.net/apps/BareRTC/archive/master.zip
* Tarball: https://git.kirsle.net/apps/BareRTC/archive/master.tar.gz
## Deploying to Production
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:
```nginx
server {
server_name chat.example.com;
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
# Proxy pass to BareRTC.
location / {
proxy_pass http://127.0.0.1:9000;
# WebSocket headers to forward along.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}
```
You can run the BareRTC app itself using any service supervisor you like. I use [Supervisor](http://supervisord.org/introduction.html) and you can configure BareRTC like so:
```ini
# /etc/supervisor/conf.d/barertc.conf
[program:barertc]
command = /home/user/git/BareRTC/BareRTC -address 127.0.0.1:9000
directory = /home/user/git/BareRTC
user = user
```
Then `sudo supervisorctl reread && sudo supervisorctl add barertc` to start the app.
## Developing This App
In local development you'll probably run two processes in your terminal: one to `npm run watch` the Vue.js app and the other to run the Go server.
Building and running the front-end app:
```bash
# Install dependencies
npm install
# Build the front-end
npm run build
# Run the front-end in watch mode for local dev
npm run watch
```
And `make run` to run the Go server.
# License
GPLv3.

View File

@ -313,6 +313,19 @@ The server passes the watch/unwatch message to the broadcaster.
}
```
## Cut
Sent by: Server.
The server tells the client to turn off their camera. This is done in response to a `/cut` command being sent by an admin user: to remotely cause another user on chat to turn off their camera and stop broadcasting.
```javascript
// Server Cut
{
"action": "cut"
}
```
## Mute, Unmute
Sent by: Client.

View File

@ -8,17 +8,25 @@ BareRTC is a simple WebRTC-based chat room application. It is especially designe
It is very much in the style of the old-school Flash based webcam chat rooms of the early 2000's: a multi-user chat room with DMs and _some_ users may broadcast video and others may watch multiple video feeds in an asynchronous manner. I thought that this should be such an obvious free and open source app that should exist, but it did not and so I had to write it myself.
* [Features](#features)
* [Configuration](#configuration)
* [Authentication](#authentication)
* [JWT Strict Mode](#jwt-strict-mode)
* [Running Without Authentication](#running-without-authentication)
* [Known Bugs Running Without Authentication](#known-bugs-running-without-authentication)
* [Moderator Commands](#moderator-commands)
* [JSON APIs](#json-apis)
* [Tour of the Codebase](#tour-of-the-codebase)
* [Deploying This App](#deploying-this-app)
* [License](#license)
- [BareRTC](#barertc)
- [Installation](#installation)
- [Features](#features)
- [Configuration](#configuration)
- [Authentication](#authentication)
- [Moderator Commands](#moderator-commands)
- [JSON APIs](#json-apis)
- [Webhook URLs](#webhook-urls)
- [Chatbot](#chatbot)
- [Tour of the Codebase](#tour-of-the-codebase)
- [Backend files](#backend-files)
- [Frontend files](#frontend-files)
- [Deploying This App](#deploying-this-app)
- [Developing This App](#developing-this-app)
- [License](#license)
# Installation
See the [Install.md](./Install.md) for installation help.
# Features
@ -34,14 +42,7 @@ It is very much in the style of the old-school Flash based webcam chat rooms of
* WebRTC means peer-to-peer video streaming so cheap on hosting costs!
* Simple integration with your existing userbase via signed JWT tokens.
* User configurable sound effects to be notified of DMs or users entering/exiting the room.
* Operator commands
* [x] /kick users
* [x] /ban users (and /unban, /bans to list)
* [x] /nsfw to tag a user's camera as explicit
* [x] /shutdown to gracefully reboot the server
* [x] /kickall to kick EVERYBODY off the server (e.g., for mandatory front-end reload for new features)
* [x] /op and /deop users (give temporary mod control)
* [x] /help to get in-chat help for moderator commands
* Operator commands to kick, ban users, mark cameras NSFW, etc.
The BareRTC project also includes a [Chatbot implementation](docs/Chatbot.md) so you can provide an official chatbot for fun & games & to auto moderate your chat room!
@ -91,7 +92,27 @@ See [Authentication](docs/Authentication.md) for more information.
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.
* `/ban <username> [hours]` to ban a user from chat (temporary - time-based or until the next server reboot, default 24 hours)
* `/nsfw <username>` to tag a user's video feed as NSFW (if your settings.toml has PermitNSFW enabled).
* `/cut <username>` to 'cut' their webcam feed (instruct their web page to turn off their camera automatically)
There are easy buttons for the above commonly used actions in a user's pop-up "profile card" on the chat room.
Additional operator commands include:
* `/unban <username>` to lift the ban on a user.
* `/bans` to list all of the currently banned users.
* `/op <username>` to grant operator controls to a user (temporary, until they log off)
* `/deop <username>` to remove operator controls
* `/unmute-all` removes the mute flag on all users for the current operator (intended especially for the [Chatbot](docs/Chatbot.md) so it can still moderate public chat messages from users who have blocked it from your main website).
And there are some advanced commands intended for the server system administrator (these can be 'dangerous' and disruptive to users in the chat room):
* `/shutdown` will shut down the chat server (and hopefully, reboot it if your process supervisor is configured as such)
* `/reconfigure` will reload the server config file without needing to reboot.
* `/kickall` will kick ALL users from the room, with a message asking them to refresh the page (useful to deploy backwards-incompatible server updates where the new front-end is required to be loaded).
In case your operators forget, the `/help` command will list the common moderator commands and `/help-advanced` will list the more advanced/dangerous ones. **Note:** there is only one level of admin rights currently, so it will be a matter of policy to instruct your moderators not to play with the advanced commands.
# JSON APIs

View File

@ -36,6 +36,7 @@ type Client struct {
OnOpen HandlerFunc
OnWatch HandlerFunc
OnUnwatch HandlerFunc
OnCut HandlerFunc
OnError HandlerFunc
OnDisconnect HandlerFunc
OnPing HandlerFunc
@ -129,6 +130,8 @@ func (c *Client) Run() error {
c.Handle(msg, c.OnWatch)
case messages.ActionUnwatch:
c.Handle(msg, c.OnUnwatch)
case messages.ActionCut:
c.Handle(msg, c.OnCut)
case messages.ActionError:
c.Handle(msg, c.OnError)
case messages.ActionKick:

View File

@ -109,6 +109,7 @@ func (c *Client) SetupChatbot() error {
c.OnOpen = handler.OnOpen
c.OnWatch = handler.OnWatch
c.OnUnwatch = handler.OnUnwatch
c.OnCut = handler.OnCut
c.OnError = handler.OnError
c.OnDisconnect = handler.OnDisconnect
c.OnPing = handler.OnPing
@ -134,6 +135,12 @@ func (h *BotHandlers) OnMe(msg messages.Message) {
log.Error("OnMe: the server has renamed us to '%s'", msg.Username)
h.client.claims.Subject = msg.Username
}
// Send the /unmute-all command to lift any mutes imposed by users blocking the chatbot.
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: "/unmute-all",
})
}
// Buffer a message seen on chat for a while.
@ -233,7 +240,6 @@ func (h *BotHandlers) OnMessage(msg messages.Message) {
// Set their user variables.
h.SetUserVariables(msg)
reply, err := h.rs.Reply(msg.Username, msg.Message)
log.Error("REPLY: %s", reply)
if NoReply(reply) {
return
}
@ -384,6 +390,11 @@ func (h *BotHandlers) OnUnwatch(msg messages.Message) {
}
// OnCut handles an admin telling us to cut our camera.
func (h *BotHandlers) OnCut(msg messages.Message) {
}
// OnError handles ChatServer messages from the backend.
func (h *BotHandlers) OnError(msg messages.Message) {
log.Error("[%s] %s", msg.Username, msg.Message)
@ -396,5 +407,9 @@ func (h *BotHandlers) OnDisconnect(msg messages.Message) {
// OnPing handles server keepalive pings.
func (h *BotHandlers) OnPing(msg messages.Message) {
// Send the /unmute-all command to lift any mutes imposed by users blocking the chatbot.
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: "/unmute-all",
})
}

View File

@ -27,16 +27,16 @@ Post your desired JWT claims to the endpoint to customize your user and it will
```json
{
"APIKey": "from settings.toml",
"Claims": {
"sub": "username",
"nick": "Display Name",
"op": false,
"img": "/static/photos/avatar.png",
"url": "/users/username",
"emoji": "🤖",
"gender": "m"
}
"APIKey": "from settings.toml",
"Claims": {
"sub": "username",
"nick": "Display Name",
"op": false,
"img": "/static/photos/avatar.png",
"url": "/users/username",
"emoji": "🤖",
"gender": "m"
}
}
```
@ -44,9 +44,9 @@ The return schema looks like:
```json
{
"OK": true,
"Error": "error string, omitted if none",
"JWT": "jwt token string"
"OK": true,
"Error": "error string, omitted if none",
"JWT": "jwt token string"
}
```
@ -58,7 +58,7 @@ It requires the AdminAPIKey to post:
```json
{
"APIKey": "from settings.toml"
"APIKey": "from settings.toml"
}
```
@ -66,8 +66,8 @@ The return schema looks like:
```json
{
"OK": true,
"Error": "error string, omitted if none"
"OK": true,
"Error": "error string, omitted if none"
}
```
@ -110,3 +110,202 @@ The JSON response to this endpoint may look like:
"Error": "if error, or this key is omitted if OK"
}
```
## POST /api/block/now
Your website can tell BareRTC to put a block between users "now." For
example, if a user on your main website adds a block on another user,
and one or both of them are presently logged into the chat room, BareRTC
can begin enforcing the block immediately so both users will disappear
from each other's view and no longer get one another's messages.
The request body payload looks like:
```json
{
"APIKey": "from your settings.toml",
"Usernames": [ "alice", "bob" ]
}
```
The pair of usernames should be the two who are blocking each other, in
any order. This will put in a two-way block between those chatters.
If you provide more than two usernames, the block is put between every
combination of usernames given.
The JSON response to this endpoint may look like:
```json
{
"OK": true,
"Error": "if error, or this key is omitted if OK"
}
```
## POST /api/disconnect/now
Your website can tell BareRTC to remove a user from the chat room "now"
in case that user is presently online in the chat.
The request body payload looks like:
```json
{
"APIKey": "from your settings.toml",
"Usernames": [ "alice" ],
"Message": "a custom ChatServer message to send them, optional",
"Kick": false,
}
```
The `Message` parameter, if provided, will be sent to that user as a
ChatServer error before they are removed from the room. You can use this
to provide them context as to why they are being kicked. For example:
"You have been logged out of chat because you deactivated your profile on
the main website."
The `Kick` boolean is whether the removal should manifest to other users
in chat as a "kick" (sending a presence message of "has been kicked from
the room!"). By default (false), BareRTC will tell the user to disconnect
and it will manifest as a regular "has left the room" event to other online
chatters.
The JSON response to this endpoint may look like:
```json
{
"OK": true,
"Removed": 1,
"Error": "if error, or this key is omitted if OK"
}
```
The "Removed" field is the count of users actually removed from chat; a zero
means the user was not presently online.
# Ajax Endpoints (User API)
## POST /api/profile
Fetch profile information from your main website about a user in the
chat room.
Note: this API request is done by the BareRTC chat front-end page, as an
ajax request for a current logged-in user. It backs the profile card pop-up
widget in the chat room when a user clicks on another user's profile.
The request body payload looks like:
```json
{
"JWTToken": "the caller's chat jwt token",
"Username": "soandso"
}
```
The JWT token is the current chat user's token. This API only works when
your BareRTC config requires the use of JWT tokens for authorization.
BareRTC will translate the request into the
["Profile Webhook"](Webhooks.md#Profile%20Webhook) to fetch the target
user's profile from your website.
The response JSON given to the chat page from /api/profile looks like:
```json
{
"OK": true,
"Error": "only on error messages",
"ProfileFields": [
{
"Name": "Age",
"Value": "30yo"
},
{
"Name": "Gender",
"Value": "Man"
},
...
]
}
```
## POST /api/message/history
Load prior history in a Direct Message conversation with another party.
Note: this API request is done by the BareRTC chat front-end page, as an
ajax request for a current logged-in user.
The request body payload looks like:
```json
{
"JWTToken": "the caller's chat jwt token",
"Username": "soandso",
"BeforeID": 1234
}
```
The JWT token is the current chat user's token. This API only works when
your BareRTC config requires the use of JWT tokens for authorization.
The "BeforeID" parameter is for pagination, and is optional. By default,
the first page of recent messages are returned. To get the next page, provide
the "BeforeID" which matches the MessageID of the oldest message from that
page. The endpoint will return messages having an ID before this ID.
The response JSON given to the chat page from /api/profile looks like:
```javascript
{
"OK": true,
"Error": "only on error messages",
"Messages": [
{
// Standard BareRTC Messages.
"username": "soandso",
"message": "hello!",
"msgID": 1234,
"timestamp": "2024-01-01 11:22:33"
}
],
"Remaining": 12
}
```
The "Remaining" integer in the result shows how many older messages still
remain to be retrieved, and tells the front-end page that it can request
another page.
## POST /api/message/clear
Clear stored direct message history for a user.
This endpoint can be called by the user themself (using JWT token authorization),
or by your website (using your admin APIKey) so your site can also clear chat
history remotely (e.g., for when your user deleted their account).
The request body payload looks like:
```javascript
{
// when called from the BareRTC frontend for the current user
"JWTToken": "the caller's chat jwt token",
// when called from your website
"APIKey": "your AdminAPIKey from settings.toml",
"Username": "soandso"
}
```
The response JSON given to the chat page looks like:
```javascript
{
"OK": true,
"Error": "only on error messages",
"MessagesErased": 42
}
```

View File

@ -25,6 +25,7 @@ Configure a shared secret key (random text string) in both the BareRTC settings
"url": "/u/username", // user profile URL
"gender": "m", // gender (m, f, o)
"emoji": "🤖", // emoji icon
"rules": ["redcam", "noimage"], // moderation rules (optional)
// Standard JWT claims that we support:
"iss": "my own app", // Issuer name
@ -112,6 +113,8 @@ Here is in-depth documentation on what custom claims are supported by BareRTC an
* Country flag emojis, to indicate where your users are connecting from.
* Robot emojis, to indicate bot users.
* Any emoji you want! Mark your special guests or VIP users, etc.
* **Rules** (`rules`): a string array of moderation rules to apply to the joining user, dictated by your website.
* See [JWT Moderation Rules](./Configuration.md#jwt-moderation-rules) for available values.
## JWT Strict Mode

View File

@ -37,6 +37,16 @@ PreviewImageWidth = 360
Name = "Off Topic"
WelcomeMessages = ["Welcome to the Off Topic channel!"]
[[WebhookURLs]]
Name = "report"
Enabled = true
URL = "https://www.example.com/v1/barertc/report"
[[WebhookURLs]]
Name = "profile"
Enabled = true
URL = "https://www.example.com/v1/barertc/profile"
[VIP]
Name = "VIP"
Branding = "<em>VIP Members</em>"
@ -56,6 +66,25 @@ PreviewImageWidth = 360
ForwardMessage = false
ReportMessage = false
ChatServerResponse = "Watch your language."
[[ModerationRule]]
Username = "example"
CameraAlwaysNSFW = true
NoBroadcast = false
NoVideo = false
NoImage = false
[DirectMessageHistory]
Enabled = true
SQLiteDatabase = "database.sqlite"
RetentionDays = 90
DisclaimerMessage = "Reminder: please conduct yourself honorable in DMs."
[Logging]
Enabled = true
Directory = "./logs"
Channels = ["lobby"]
Usernames = []
```
A description of the config directives includes:
@ -119,3 +148,62 @@ Options for the `[[MessageFilters]]` section include:
* **ForwardMessage** (bool): whether to repeat the message to the other chatters. If false, the sender will see their own message echo (possibly censored) but other chatters will not get their message at all.
* **ReportMessage** (bool): if true, report the message along with the recent context (previous 10 messages in that conversation) to your website's report webhook (if configured).
* **ChatServerResponse** (str): optional - you can have ChatServer send a message to the sender (in the same channel) after the filter has been run. An empty string will not send a ChatServer message.
## Moderation Rules
This section of the config file allows you to place certain moderation rules on specific users of your chat room. For example: if somebody perpetually needs to be reminded to label their camera as NSFW, you can enforce a moderation rule on that user which _always_ forces their camera to be NSFW.
Settings in the `[[ModerationRule]]` array include:
* **Username** (string): the username on chat to apply the rule to.
* **CameraAlwaysNSFW** (bool): if true, the user's camera is forced to NSFW and they will receive a ChatServer message when they try and remove the flag themselves.
* **NoBroadcast** (bool): if true, the user is not allowed to share their webcam and the server will send them a 'cut' message any time they go live, along with a ChatServer message informing them of this.
* **NoVideo** (bool): if true, the user is not allowed to broadcast their camera OR watch any camera on chat.
* **NoImage** (bool): if true, the user is not allowed to share images or see images shared by others on chat.
### JWT Moderation Rules
Rather than in the server-side settings.toml, you can enable these moderation rules from your website's side as well by including them in the "rules" custom key of your JWT token.
The "rules" key is a string array with short labels representing each of the rules:
| Moderation Rule | JWT "Rules" Value |
|------------------|-------------------|
| CameraAlwaysNSFW | redcam |
| NoBroadcast | nobroadcast |
| NoVideo | novideo |
| NoImage | noimage |
An example JWT token claims object may look like:
```javascript
{
"sub": "username", // Username for chat
"nick": "Display name", // Friendly name
"img": "/static/photos/username.jpg", // user picture URL
"url": "/u/username", // user profile URL
"rules": ["redcam", "noimage"], // moderation rules
}
```
## Direct Message History
You can allow BareRTC to retain temporary DM history for your users so they can remember where they left off with people.
Settings for this include:
* **Enabled** (bool): set to true to log chat DMs history.
* **SQLiteDatabase** (string): the name of the .sqlite DB file to store their DMs in.
* **RetentionDays** (int): how many days of history to record before old chats are erased. Set to zero for no limit.
* **DisclaimerMessage** (string): a custom banner message to show at the top of DM threads. HTML is supported. A good use is to remind your users of your local site rules.
## Logging
This feature can enable logging of public channels and user DMs to text files on disk. It is useful to keep a log of your public channels so you can look back at the context of a reported public chat if you weren't available when it happened, or to selectively log the DMs of specific users to investigate a problematic user.
Settings include:
* **Enabled** (bool): to enable or disable the logging feature.
* **Directory** (string): a folder on disk to save logs into. Public channels will save directly as text files here (e.g. "lobby.txt"), while DMs will create a subfolder for the monitored user.
* **Channels** ([]string): array of public channel IDs to monitor.
* **Usernames** ([]string): array of chat usernames to monitor.

12
go.mod
View File

@ -7,8 +7,9 @@ require (
github.com/BurntSushi/toml v1.3.2
github.com/aichaos/rivescript-go v0.4.0
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/uuid v1.3.1
github.com/google/uuid v1.5.0
github.com/mattn/go-shellwords v1.0.12
github.com/microcosm-cc/bluemonday v1.0.25
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
@ -23,10 +24,13 @@ require (
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
@ -43,7 +47,11 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
)

28
go.sum
View File

@ -29,6 +29,8 @@ github.com/dop251/goja v0.0.0-20230919151941-fc55792775de h1:lA38Xtzr1Wo+iQdkN2E
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -37,6 +39,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@ -76,8 +80,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
@ -99,8 +103,9 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
@ -119,6 +124,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@ -216,8 +223,9 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -243,8 +251,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -273,5 +281,13 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -5,8 +5,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css?{{.CacheHash}}">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css?{{.CacheHash}}">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
<title>{{.Config.Title}}</title>
@ -18,7 +17,7 @@
<div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div>
<div class="modal-content photo-modal">
<div class="image is-fullwidth">
<img id="modalImage">
<img id="modalImage" oncontextmenu="return false">
</div>
</div>
<button class="modal-close is-large" aria-label="close" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></button>
@ -29,7 +28,9 @@
<!-- BareRTC constants injected by IndexPage route -->
<script type="text/javascript">
const Branding = {{.Config.Branding}};
const BareRTCStrings = {{.Config.Strings}};
const PublicChannels = {{.Config.GetChannels}};
const DMDisclaimer = {{.Config.DirectMessageHistory.DisclaimerMessage}};
const WebsiteURL = "{{.Config.WebsiteURL}}";
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
const TURN = {{.Config.TURN}};
@ -38,7 +39,9 @@
const UserJWTToken = {{.JWTTokenString}};
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
const UserJWTClaims = {{.JWTClaims.ToJSON}};
const UserJWTRules = {{.JWTClaims.Rules.ToDict}};
const CachedBlocklist = {{.CachedBlocklist}};
const CacheHash = {{.CacheHash}};
// Show the photo detail modal.
function setModalImage(url) {

6
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"floating-vue": "^2.0.0-beta.24",
"interactjs": "^1.10.18",
"qrcodejs": "github:danielgjackson/qrcodejs",
"vue": "^3.3.4",
"vue-mention": "^2.0.0-alpha.3",
"vue3-emoji-picker": "^1.1.7",
@ -1680,6 +1681,11 @@
"node": ">=6"
}
},
"node_modules/qrcodejs": {
"version": "0.0.0",
"resolved": "git+ssh://git@github.com/danielgjackson/qrcodejs.git#86770ec12f0f9abee8728fc9018ab7bd0949f4bc",
"license": "BSD-2-Clause"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -12,6 +12,7 @@
"dependencies": {
"floating-vue": "^2.0.0-beta.24",
"interactjs": "^1.10.18",
"qrcodejs": "github:danielgjackson/qrcodejs",
"vue": "^3.3.4",
"vue-mention": "^2.0.0-alpha.3",
"vue3-emoji-picker": "^1.1.7",

View File

@ -13,6 +13,7 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Statistics (/api/statistics) returns info about the users currently logged onto the chat,
@ -468,6 +469,134 @@ func (s *Server) BlockNow() http.HandlerFunc {
})
}
// DisconnectNow (/api/disconnect/now) allows your website to remove a user from
// the chat room if they are currently online.
//
// For example: a user on your website has deactivated their account, and so
// should not be allowed to remain in the chat room.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// "Usernames": [ "alice", "bob" ],
// "Message": "An optional ChatServer message to send them first.",
// "Kick": false,
// }
//
// The `Message` parameter, if provided, will be sent to that user as a
// ChatServer error before they are removed from the room. You can use this
// to provide them context as to why they are being kicked. For example:
// "You have been logged out of chat because you deactivated your profile on
// the main website."
//
// The `Kick` boolean is whether the removal should manifest to other users
// in chat as a "kick" (sending a presence message of "has been kicked from
// the room!"). By default (false), BareRTC will tell the user to disconnect
// and it will manifest as a regular "has left the room" event to other online
// chatters.
func (s *Server) DisconnectNow() http.HandlerFunc {
type request struct {
APIKey string
Usernames []string
Message string
Kick bool
}
type result struct {
OK bool
Removed int
Error string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Check if any of these users are online, and disconnect them from the chat.
var removed int
for _, username := range params.Usernames {
if sub, err := s.GetSubscriber(username); err == nil {
// Broadcast to everybody that the user left the chat.
message := messages.PresenceExited
if params.Kick {
message = messages.PresenceKicked
}
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: message,
})
// Custom message to send to them?
if params.Message != "" {
sub.ChatServer(params.Message)
}
// Disconnect them.
sub.SendJSON(messages.Message{
Action: messages.ActionKick,
})
sub.authenticated = false
sub.Username = ""
removed++
}
}
// If any changes to blocklists were made: send the Who List.
if removed > 0 {
s.SendWhoList()
}
enc.Encode(result{
OK: true,
Removed: removed,
})
})
}
// UserProfile (/api/profile) fetches profile information about a user.
//
// This endpoint will proxy to your WebhookURL for the "profile" endpoint.
@ -620,6 +749,260 @@ func (s *Server) UserProfile() http.HandlerFunc {
})
}
// MessageHistory (/api/message/history) fetches past direct messages for a user.
//
// This endpoint looks up earlier chat messages between the current user and a target.
// It will only run with a valid JWT auth token, to protect users' privacy.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "JWTToken": "the caller's jwt token",
// "Username": "other party",
// "BeforeID": 1234,
// }
//
// The "BeforeID" parameter is for pagination and is optional: by default the most
// recent page of messages are returned. To retrieve an older page, the BeforeID will
// contain the MessageID of the oldest message you received so far, so that the message
// before that will be the first returned on the next page.
//
// The response JSON will look like the following:
//
// {
// "OK": true,
// "Error": "only on error responses",
// "Messages": [
// {
// // Standard BareRTC Message objects...
// "MessageID": 1234,
// "Username": "other party",
// "Message": "hello!",
// }
// ],
// "Remaining": 42,
// }
//
// The Remaining value is how many older messages still exist to be loaded.
func (s *Server) MessageHistory() http.HandlerFunc {
type request struct {
JWTToken string
Username string
BeforeID int64
}
type result struct {
OK bool
Error string `json:",omitempty"`
Messages []messages.Message
Remaining int
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
claims, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Get the user from the chat roster.
sub, err := s.GetSubscriber(claims.Subject)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "You are not logged into the chat room.",
})
return
}
// Fetch a page of message history.
messages, remaining, err := models.PaginateDirectMessages(sub.Username, params.Username, params.BeforeID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
enc.Encode(result{
Error: err.Error(),
})
return
}
enc.Encode(result{
OK: true,
Messages: messages,
Remaining: remaining,
})
})
}
// ClearMessages (/api/message/clear) deletes all the stored direct messages for a user.
//
// It can be called by the authenticated user themself (with JWTToken), or from your website
// (with APIKey) in which case you can remotely clear history for a user.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "JWTToken": "the caller's jwt token",
// "APIKey": "your website's admin API key"
// "Username": "if using your APIKey to specify a user to delete",
// }
//
// The response JSON will look like the following:
//
// {
// "OK": true,
// "Error": "only on error responses",
// "MessagesErased": 123,
// }
//
// The Remaining value is how many older messages still exist to be loaded.
func (s *Server) ClearMessages() http.HandlerFunc {
type request struct {
JWTToken string
APIKey string
Username string
}
type result struct {
OK bool
Error string `json:",omitempty"`
MessagesErased int `json:""`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Authenticate this request.
if params.APIKey != "" {
// By admin API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
} else {
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
claims, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Set the username to clear.
params.Username = claims.Subject
}
// Erase their message history.
count, err := (models.DirectMessage{}).ClearMessages(params.Username)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
enc.Encode(result{
Error: err.Error(),
})
return
}
enc.Encode(result{
OK: true,
MessagesErased: count,
})
})
}
// Blocklist cache sent over from your website.
var (
// Map of username to the list of usernames they block.

View File

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
@ -47,20 +48,33 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg messages.Message) bool {
case "/nsfw":
s.NSFWCommand(words, sub)
return true
case "/cut":
s.CutCommand(words, sub)
return true
case "/unmute-all":
s.UnmuteAllCommand(words, sub)
return true
case "/help":
sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" +
sub.ChatServer(RenderMarkdown("The most common moderator commands on chat are:\n\n" +
"* `/kick <username>` to kick from chat\n" +
"* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" +
"* `/unban <username>` to list the ban on a user\n" +
"* `/bans` to list current banned users and their expiration date\n" +
"* `/nsfw <username>` to mark their camera NSFW\n" +
"* `/cut <username>` to make them turn off their camera\n" +
"* `/help` to show this message\n" +
"* `/help-advanced` to show advanced admin commands\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 "/help-advanced":
sub.ChatServer(RenderMarkdown("The following are **dangerous** commands that you should not use unless you know what you're doing:\n\n" +
"* `/op <username>` to grant operator rights to a user\n" +
"* `/deop <username>` to remove operator rights from a user\n" +
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
"* `/reconfigure` to dynamically reload the chat server settings file\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\"`",
"* `/help-advanced` to show this message",
))
return true
case "/shutdown":
@ -109,14 +123,23 @@ func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.")
}
username := words[1]
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/nsfw: username not found: %s", username)
} else {
// Sanity check that the target user is presently on a blue camera.
if !(other.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive) {
sub.ChatServer("/nsfw: %s's camera was not currently enabled.", username)
return
} else if other.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW {
sub.ChatServer("/nsfw: %s's camera was already marked as explicit.", username)
return
}
// The message to deliver to the target.
var message = "Just a friendly reminder to mark your camera as 'Explicit' by using the button at the top " +
"of the page if you are going to be sexual on webcam. "
"of the page if you are going to be sexual on webcam.<br><br>"
// If the admin who marked it was previously booted
if other.Boots(sub.Username) {
@ -133,6 +156,43 @@ func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
}
}
// CutCommand handles the `/cut` operator command (force a user's camera to turn off).
func (s *Server) CutCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer("Usage: `/cut username` to turn their camera off.")
}
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/cut: username not found: %s", username)
} else {
// Sanity check that the target user is presently on a blue camera.
if !(other.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive) {
sub.ChatServer("/cut: %s's camera was not currently enabled.", username)
return
}
other.SendCut()
sub.ChatServer("%s has been told to turn off their camera.", username)
}
}
// UnmuteAllCommand handles the `/unmute-all` operator command (remove all mutes for the current user).
//
// It enables an operator to see public messages from any user who muted/blocked them. Note: from the
// other side of the mute, the operator's public messages may still be hidden from those users.
//
// It is useful for an operator chatbot if you want users to be able to block it but still retain the
// bot's ability to moderate public channel messages, and send warnings in DMs to misbehaving users
// even despite a mute being in place.
func (s *Server) UnmuteAllCommand(words []string, sub *Subscriber) {
count := len(sub.muted)
sub.muted = map[string]struct{}{}
sub.unblockable = true
sub.ChatServer("Your mute on %d users has been lifted.", count)
s.SendWhoList()
}
// KickCommand handles the `/kick` operator command.
func (s *Server) KickCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
@ -141,7 +201,7 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
))
return
}
username := words[1]
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/kick: username not found: %s", username)
@ -160,7 +220,7 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: "has been kicked from the room!",
Message: messages.PresenceKicked,
})
}
}
@ -218,7 +278,7 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
// Parse the command.
var (
username = words[1]
username = strings.TrimPrefix(words[1], "@")
duration = 24 * time.Hour
)
if len(words) >= 3 {
@ -237,7 +297,7 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: "has been banned!",
Message: messages.PresenceBanned,
})
other.ChatServer("You have been banned from the chat room by %s. You may come back after %d hours.", sub.Username, duration/time.Hour)
@ -261,7 +321,7 @@ func (s *Server) UnbanCommand(words []string, sub *Subscriber) {
}
// Parse the command.
var username = words[1]
var username = strings.TrimPrefix(words[1], "@")
if UnbanUser(username) {
sub.ChatServer("The ban on %s has been lifted.", username)
@ -299,7 +359,7 @@ func (s *Server) OpCommand(words []string, sub *Subscriber) {
}
// Parse the command.
var username = words[1]
var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/op: user %s was not found.", username)
} else {
@ -329,7 +389,7 @@ func (s *Server) DeopCommand(words []string, sub *Subscriber) {
}
// Parse the command.
var username = words[1]
var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/deop: user %s was not found.", username)
} else {

View File

@ -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 = 10
var currentVersion = 15
// Config for your BareRTC app.
type Config struct {
@ -30,9 +30,10 @@ type Config struct {
Branding string
WebsiteURL string
CORSHosts []string
AdminAPIKey string
PermitNSFW bool
CORSHosts []string
AdminAPIKey string
PermitNSFW bool
BlockableAdmins bool
UseXForwardedFor bool
@ -50,6 +51,11 @@ type Config struct {
VIP VIP
MessageFilters []*MessageFilter
ModerationRule []*ModerationRule
DirectMessageHistory DirectMessageHistory
Strings Strings
Logging Logging
}
@ -67,6 +73,13 @@ type VIP struct {
MutuallySecret bool
}
type DirectMessageHistory struct {
Enabled bool
SQLiteDatabase string
RetentionDays int
DisclaimerMessage string
}
// GetChannels returns a JavaScript safe array of the default PublicChannels.
func (c Config) GetChannels() template.JS {
data, _ := json.Marshal(c.PublicChannels)
@ -85,10 +98,11 @@ func (c Config) GetChannel(id string) (Channel, bool) {
// Channel config for a default public room.
type Channel struct {
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
VIP bool // For VIP users only
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
VIP bool // For VIP users only
PermitPhotos bool // photos are allowed to be shared
// ChatServer messages to send to the user immediately upon connecting.
WelcomeMessages []string
@ -101,6 +115,14 @@ type WebhookURL struct {
URL string
}
// Strings config for customizing certain user-facing messaging around the app.
type Strings struct {
ModRuleErrorCameraAlwaysNSFW string
ModRuleErrorNoBroadcast string
ModRuleErrorNoVideo string
ModRuleErrorNoImage string
}
// Logging configs to monitor channels or usernames.
type Logging struct {
Enabled bool
@ -109,6 +131,15 @@ type Logging struct {
Usernames []string
}
// ModerationRule applies certain rules to moderate specific users.
type ModerationRule struct {
Username string
CameraAlwaysNSFW bool
NoBroadcast bool
NoVideo bool
NoImage bool
}
// Current loaded configuration.
var Current = DefaultConfig()
@ -142,11 +173,13 @@ func DefaultConfig() Config {
WelcomeMessages: []string{
"Welcome to the Off Topic channel!",
},
PermitPhotos: true,
},
{
ID: "vip",
Name: "VIPs Only",
VIP: true,
ID: "vip",
Name: "VIPs Only",
VIP: true,
PermitPhotos: true,
WelcomeMessages: []string{
"This channel is only for operators and VIPs.",
},
@ -185,6 +218,23 @@ func DefaultConfig() Config {
ChatServerResponse: "Watch your language.",
},
},
ModerationRule: []*ModerationRule{
{
Username: "example",
},
},
Strings: Strings{
ModRuleErrorCameraAlwaysNSFW: "A chat server moderation rule is currently in place which forces your camera to stay marked as Explicit. Please contact a chat moderator if you have any questions about this.",
ModRuleErrorNoBroadcast: "A chat server moderation rule is currently in place which restricts your ability to share your webcam. Please contact a chat operator for more information.",
ModRuleErrorNoVideo: "A chat server moderation rule is currently in place which restricts your ability to watch webcams. Please contact a chat operator for more information.",
ModRuleErrorNoImage: "A chat server moderation rule is currently in place which restricts your ability to share images. Please contact a chat operator for more information.",
},
DirectMessageHistory: DirectMessageHistory{
Enabled: false,
SQLiteDatabase: "database.sqlite",
RetentionDays: 90,
DisclaimerMessage: `<i class="fa fa-info-circle mr-1"></i> <strong>Reminder:</strong> please conduct yourself honorably in Direct Messages.`,
},
Logging: Logging{
Directory: "./logs",
Channels: []string{"lobby", "offtopic"},
@ -235,3 +285,13 @@ func WriteSettings() error {
}
return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
}
// GetModerationRule returns a matching ModerationRule for the given user, or nil if no rule is found.
func (c Config) GetModerationRule(username string) *ModerationRule {
for _, rule := range c.ModerationRule {
if rule.Username == username {
return rule
}
}
return nil
}

View File

@ -11,6 +11,7 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
"git.kirsle.net/apps/barertc/pkg/util"
)
@ -97,7 +98,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: msg.Username,
Message: "has joined the room!",
Message: messages.PresenceJoined,
})
// Send the user back their settings.
@ -125,7 +126,7 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
log.Info("[%s to #%s] %s", sub.Username, msg.Channel, msg.Message)
}
if sub.Username == "" {
if sub.Username == "" || !sub.authenticated {
sub.ChatServer("You must log in first.")
return
}
@ -179,11 +180,8 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
// If the user is OP, just tell them we would.
if sub.IsAdmin() {
sub.ChatServer("Your recent chat context would have been reported to your main website.")
return
}
// Send the report to the main website.
if err := s.reportFilteredMessage(sub, msg); err != nil {
} else if err := s.reportFilteredMessage(sub, msg); err != nil {
// Send the report to the main website.
log.Error("Reporting filtered message: %s", err)
}
}
@ -203,17 +201,17 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
// Don't deliver it if the receiver has muted us. Note: admin users, even if muted,
// can still deliver a DM to the one who muted them.
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
if err == nil && rcpt.Mutes(sub.Username) && !sub.IsAdmin() {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
} else if err != nil {
if err != nil {
// Recipient was no longer online: the message won't be sent.
sub.ChatServer("Could not deliver your message: %s appears not to be online.", msg.Channel)
return
} else if rcpt.Mutes(sub.Username) && !sub.IsAdmin() {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
}
// If the sender already mutes the recipient, reply back with the error.
if err == nil && sub.Mutes(rcpt.Username) {
if sub.Mutes(rcpt.Username) && !sub.IsAdmin() {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
@ -236,6 +234,11 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
LogMessage(rcpt, sub.Username, sub.Username, msg)
}
// Add it to the DM history SQLite database.
if err := (models.DirectMessage{}).LogMessage(sub.Username, rcpt.Username, message); err != nil && err != models.ErrNotInitialized {
log.Error("Logging DM history to SQLite: %s", err)
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
@ -253,14 +256,26 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
// OnTakeback handles takebacks (delete your message for everybody)
func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
// In case we're in a DM thread, remove this message ID from the history table
// if the username matches.
wasRemovedFromHistory, err := (models.DirectMessage{}).TakebackMessage(sub.Username, msg.MessageID, sub.IsAdmin())
if err != nil && err != models.ErrNotInitialized {
log.Error("Error taking back DM history message (%s, %d): %s", sub.Username, msg.MessageID, err)
}
// Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock()
_, ok := sub.messageIDs[msg.MessageID]
sub.midMu.Unlock()
if !ok {
sub.ChatServer("That is not your message to take back.")
return
// The messageID is not found in the current chat session, but did we remove
// it from past DM history for the correct current user?
if !wasRemovedFromHistory {
sub.ChatServer("That is not your message to take back.")
return
}
}
}
@ -289,6 +304,17 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
return
}
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from watching cameras on chat?
if rule.NoImage {
sub.ChatServer(config.Current.Strings.ModRuleErrorNoImage)
return
}
}
// Detect image type and convert it into an <img src="data:"> tag.
var (
filename = msg.Message
@ -368,8 +394,30 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
// OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Reflect a 'me' message back at them? (e.g. if server forces their camera NSFW)
var reflect bool
if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
log.Debug("User %s turns on their video feed", sub.Username)
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from sharing their camera on chat?
if rule.NoBroadcast || rule.NoVideo {
sub.SendCut()
sub.ChatServer(config.Current.Strings.ModRuleErrorNoBroadcast)
msg.VideoStatus = 0
}
// Is their camera forced to always be explicit?
if rule.CameraAlwaysNSFW && !(msg.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW) {
msg.VideoStatus |= messages.VideoFlagNSFW
reflect = true // send them a 'me' echo afterward to inform the front-end page properly of this
sub.ChatServer(config.Current.Strings.ModRuleErrorCameraAlwaysNSFW)
}
}
}
// Hidden status: for operators only, + fake a join/exit chat message.
@ -379,14 +427,14 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
Message: messages.PresenceExited,
})
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
// Leaving hidden - fake join message
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has joined the room!",
Message: messages.PresenceJoined,
})
}
} else if msg.ChatStatus == "hidden" {
@ -400,14 +448,29 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Sync the WhoList to everybody.
s.SendWhoList()
// Reflect a 'me' message back?
if reflect {
sub.SendMe()
}
}
// OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera.
func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from watching cameras on chat?
if rule.NoVideo {
sub.ChatServer(config.Current.Strings.ModRuleErrorNoVideo)
return
}
}
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -472,11 +535,11 @@ func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, st
Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username),
},
{
If: theirVIPRequired && !sub.IsVIP(),
If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
{
If: other.Mutes(sub.Username) || other.Blocks(sub),
If: (other.Mutes(sub.Username) || other.Blocks(sub)) && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
}
@ -543,7 +606,7 @@ func (s *Server) OnMute(sub *Subscriber, msg messages.Message, mute bool) {
// OnBlock is a user placing a hard block (hide from) another user.
func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
log.Info("%s blocks %s: %v", sub.Username, msg.Username)
log.Info("%s blocks %s", sub.Username, msg.Username)
// If the subject of the block is an admin, return an error.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
@ -584,6 +647,12 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
return
}
// Attach recent message context to DMs.
if strings.HasPrefix(msg.Channel, "@") {
context := getDirectMessageContext(sub.Username, msg.Username)
msg.Message += "\n\nRecent message context:\n\n" + context
}
// Post to the report webhook.
if _, err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
@ -609,7 +678,6 @@ func (s *Server) OnCandidate(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -625,7 +693,6 @@ func (s *Server) OnSDP(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -641,7 +708,6 @@ func (s *Server) OnWatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -656,7 +722,6 @@ func (s *Server) OnUnwatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}

View File

@ -20,6 +20,7 @@ type Claims struct {
Nick string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"`
Rules Rules `json:"rules,omitempty"`
// Standard claims. Notes:
// subject = username

65
pkg/jwt/rules.go Normal file
View File

@ -0,0 +1,65 @@
package jwt
// Rule options for the JWT custom key.
//
// Safely check its settings with the Is() functions which handle superset rules
// which imply other rules, for example novideo > nobroadcast.
type Rule string
// Available Rules your site can include in the JWT token: to enforce moderator
// rules on the user logging into chat.
const (
// Webcam restrictions.
NoVideoRule = Rule("novideo") // Can not use video features at all
NoBroadcastRule = Rule("nobroadcast") // They can not share their webcam
NoImageRule = Rule("noimage") // Can not upload or see images
RedCamRule = Rule("redcam") // Their camera is force marked NSFW
)
func (r Rule) IsNoVideoRule() bool {
return r == NoVideoRule
}
func (r Rule) IsNoImageRule() bool {
return r == NoImageRule
}
func (r Rule) IsNoBroadcastRule() bool {
return r == NoVideoRule || r == NoBroadcastRule
}
func (r Rule) IsRedCamRule() bool {
return r == RedCamRule
}
// Rules are the plural set of rules as shown on a JWT token (string array),
// with some extra functionality attached such as an easy serializer to JSON.
type Rules []Rule
// ToDict serializes a Rules string-array into a map of the Is* functions, for easy
// front-end access to the currently enabled rules.
func (r Rules) ToDict() map[string]bool {
var result = map[string]bool{
"IsNoVideoRule": false,
"IsNoImageRule": false,
"IsNoBroadcastRule": false,
"IsRedCamRule": false,
}
for _, rule := range r {
if v := rule.IsNoVideoRule(); v {
result["IsNoVideoRule"] = true
}
if v := rule.IsNoImageRule(); v {
result["IsNoImageRule"] = true
}
if v := rule.IsNoBroadcastRule(); v {
result["IsNoBroadcastRule"] = true
}
if v := rule.IsRedCamRule(); v {
result["IsRedCamRule"] = true
}
}
return result
}

View File

@ -113,7 +113,7 @@ func (s *Server) reportFilteredMessage(sub *Subscriber, msg messages.Message) er
var (
messageContexts = map[string][]string{}
messageContextMu sync.RWMutex
messageContextSize = 10
messageContextSize = 30
)
// Push a message onto the recent messages context.

View File

@ -94,6 +94,7 @@ const (
ActionPing = "ping"
ActionWhoList = "who" // server pushes the Who List
ActionPresence = "presence" // a user joined or left the room
ActionCut = "cut" // tell the client to turn off their webcam
ActionError = "error" // ChatServer errors
ActionKick = "disconnect" // client should disconnect (e.g. have been kicked).
@ -131,3 +132,12 @@ const (
VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras
VideoFlagOnlyVIP // can only shows as active to VIP members
)
// Presence message templates.
const (
PresenceJoined = "has joined the room!"
PresenceExited = "has exited the room!"
PresenceKicked = "has been kicked from the room!"
PresenceBanned = "has been banned!"
PresenceTimedOut = "has timed out!"
)

View File

@ -38,12 +38,6 @@ func (f flags) Check(video int) error {
return errors.New("Muted expected NOT to be set")
}
if video&messages.VideoFlagIsTalking == messages.VideoFlagIsTalking && !f.IsTalking {
return errors.New("IsTalking expected to be set")
} else if video&messages.VideoFlagIsTalking != messages.VideoFlagIsTalking && f.IsTalking {
return errors.New("IsTalking expected NOT to be set")
}
if video&messages.VideoFlagMutualRequired == messages.VideoFlagMutualRequired && !f.MutualRequired {
return errors.New("MutualRequired expected to be set")
} else if video&messages.VideoFlagMutualRequired != messages.VideoFlagMutualRequired && f.MutualRequired {
@ -217,14 +211,13 @@ func TestVideoFlagMutation(t *testing.T) {
},
{
Mutate: func(v int) int {
return v | messages.VideoFlagOnlyVIP | messages.VideoFlagNSFW | messages.VideoFlagIsTalking
return v | messages.VideoFlagOnlyVIP | messages.VideoFlagNSFW
},
Expect: flags{
Active: true,
Muted: true,
OnlyVIP: true,
NSFW: true,
IsTalking: true,
Active: true,
Muted: true,
OnlyVIP: true,
NSFW: true,
},
},
}

29
pkg/models/database.go Normal file
View File

@ -0,0 +1,29 @@
package models
import (
"database/sql"
"errors"
_ "github.com/glebarez/go-sqlite"
)
var (
DB *sql.DB
ErrNotInitialized = errors.New("database is not initialized")
)
func Initialize(connString string) error {
db, err := sql.Open("sqlite", connString)
if err != nil {
return err
}
DB = db
// Run table migrations
if err := (DirectMessage{}).CreateTable(); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,235 @@
package models
import (
"errors"
"fmt"
"math"
"sort"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
)
type DirectMessage struct {
MessageID int64
ChannelID string
Username string
Message string
Timestamp int64
}
const DirectMessagePerPage = 20
func (dm DirectMessage) CreateTable() error {
if DB == nil {
return ErrNotInitialized
}
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS direct_messages (
message_id INTEGER PRIMARY KEY,
channel_id TEXT,
username TEXT,
message TEXT,
timestamp INTEGER
);
CREATE INDEX IF NOT EXISTS idx_direct_messages_channel_id ON direct_messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_direct_messages_timestamp ON direct_messages(timestamp);
`)
if err != nil {
return err
}
// Delete old messages past the retention period.
if days := config.Current.DirectMessageHistory.RetentionDays; days > 0 {
cutoff := time.Now().Add(time.Duration(-days) * 24 * time.Hour)
log.Info("Deleting old DM history past %d days (cutoff: %s)", days, cutoff.Format(time.RFC3339))
_, err := DB.Exec(
"DELETE FROM direct_messages WHERE timestamp < ?",
cutoff.Unix(),
)
if err != nil {
log.Error("Error removing old DMs: %s", err)
}
}
return nil
}
// LogMessage adds a message to the DM history between two users.
func (dm DirectMessage) LogMessage(fromUsername, toUsername string, msg messages.Message) error {
if DB == nil {
return ErrNotInitialized
}
if msg.MessageID == 0 {
return errors.New("message did not have a MessageID")
}
var (
channelID = CreateChannelID(fromUsername, toUsername)
timestamp = time.Now().Unix()
)
_, err := DB.Exec(`
INSERT INTO direct_messages (message_id, channel_id, username, message, timestamp)
VALUES (?, ?, ?, ?, ?)
`, msg.MessageID, channelID, fromUsername, msg.Message, timestamp)
return err
}
// ClearMessages clears all stored DMs that the username as a participant in.
func (dm DirectMessage) ClearMessages(username string) (int, error) {
if DB == nil {
return 0, ErrNotInitialized
}
var placeholders = []interface{}{
fmt.Sprintf("@%s:%%", username), // `@alice:%`
fmt.Sprintf("%%:@%s", username), // `%:@alice`
username,
}
// Count all the messages we'll delete.
var (
count int
row = DB.QueryRow(`
SELECT COUNT(message_id)
FROM direct_messages
WHERE (channel_id LIKE ? OR channel_id LIKE ?)
OR username = ?
`, placeholders...)
)
if err := row.Scan(&count); err != nil {
return 0, err
}
// Delete them all.
_, err := DB.Exec(`
DELETE FROM direct_messages
WHERE (channel_id LIKE ? OR channel_id LIKE ?)
OR username = ?
`, placeholders...)
return count, err
}
// TakebackMessage removes a message by its MID from the DM history.
//
// Because the MessageID may have been from a previous chat session, the server can't immediately
// verify the current user had permission to take it back. This function instead will check whether
// a DM history exists sent by this username for that messageID, and if so, returns a
// boolean true that the username/messageID matched which will satisfy the permission check
// in the OnTakeback handler.
func (dm DirectMessage) TakebackMessage(username string, messageID int64, isAdmin bool) (bool, error) {
if DB == nil {
return false, ErrNotInitialized
}
// Does this messageID exist as sent by the user?
if !isAdmin {
var (
row = DB.QueryRow(
"SELECT message_id FROM direct_messages WHERE username = ? AND message_id = ?",
username, messageID,
)
foundMsgID int64
err = row.Scan(&foundMsgID)
)
if err != nil {
return false, errors.New("no such message ID found as owned by that user")
}
}
// Delete it.
_, err := DB.Exec(
"DELETE FROM direct_messages WHERE message_id = ?",
messageID,
)
// Return that it was successfully validated and deleted.
return err == nil, err
}
// PaginateDirectMessages returns a page of messages, the count of remaining, and an error.
func PaginateDirectMessages(fromUsername, toUsername string, beforeID int64) ([]messages.Message, int, error) {
if DB == nil {
return nil, 0, ErrNotInitialized
}
var (
result = []messages.Message{}
channelID = CreateChannelID(fromUsername, toUsername)
// Compute the remaining messages after finding the final messageID this page.
lastMessageID int64
remaining int
)
if beforeID == 0 {
beforeID = math.MaxInt64
}
rows, err := DB.Query(`
SELECT message_id, username, message, timestamp
FROM direct_messages
WHERE channel_id = ?
AND message_id < ?
ORDER BY message_id DESC
LIMIT ?
`, channelID, beforeID, DirectMessagePerPage)
if err != nil {
return nil, 0, err
}
for rows.Next() {
var row DirectMessage
if err := rows.Scan(
&row.MessageID,
&row.Username,
&row.Message,
&row.Timestamp,
); err != nil {
return nil, 0, err
}
msg := messages.Message{
MessageID: row.MessageID,
Username: row.Username,
Message: row.Message,
Timestamp: time.Unix(row.Timestamp, 0).Format(time.RFC3339),
}
result = append(result, msg)
lastMessageID = msg.MessageID
}
// Get a count of the remaining messages.
row := DB.QueryRow(`
SELECT COUNT(message_id)
FROM direct_messages
WHERE channel_id = ?
AND message_id < ?
`, channelID, lastMessageID)
if err := row.Scan(&remaining); err != nil {
return nil, 0, err
}
return result, remaining, nil
}
// CreateChannelID returns a deterministic channel ID for a direct message conversation.
//
// The usernames (passed in any order) are sorted alphabetically and composed into the channel ID.
func CreateChannelID(fromUsername, toUsername string) string {
var parts = []string{fromUsername, toUsername}
sort.Strings(parts)
return fmt.Sprintf(
"@%s:@%s",
parts[0],
parts[1],
)
}

39
pkg/moderation_rules.go Normal file
View File

@ -0,0 +1,39 @@
package barertc
import (
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
)
/*
GetModerationRule loads any moderation rules applied to the user.
Moderation rules can be applied by your chat server (in settings.toml) or provided
by your website (in the custom JWT claims "rules" key).
*/
func (sub *Subscriber) GetModerationRule() *config.ModerationRule {
// Get server side mod rules to start.
rules := config.Current.GetModerationRule(sub.Username)
if rules == nil {
rules = &config.ModerationRule{}
}
// Add in client side (JWT) rules.
if sub.JWTClaims != nil {
for _, rule := range sub.JWTClaims.Rules {
if rule.IsRedCamRule() {
rules.CameraAlwaysNSFW = true
}
if rule.IsNoVideoRule() {
rules.NoVideo = true
}
if rule.IsNoBroadcastRule() {
rules.NoBroadcast = true
}
}
}
log.Error("GetModerationRule(%s): %+v", sub.Username, rules)
return rules
}

View File

@ -109,7 +109,8 @@ func AboutPage() http.HandlerFunc {
"CacheHash": util.RandomString(8),
// The current website settings.
"Config": config.Current,
"Config": config.Current,
"Hostname": r.Host,
}
tmpl.Funcs(template.FuncMap{

View File

@ -65,7 +65,7 @@ func (s *Server) KickIdlePollUsers() {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has timed out!",
Message: messages.PresenceTimedOut,
})
s.SendWhoList()
}

View File

@ -4,6 +4,10 @@ import (
"io"
"net/http"
"sync"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Server is the primary back-end server struct for BareRTC, see main.go
@ -32,6 +36,13 @@ func NewServer() *Server {
// Setup the server: configure HTTP routes, etc.
func (s *Server) Setup() error {
// Enable the SQLite database for DM history?
if config.Current.DirectMessageHistory.Enabled {
if err := models.Initialize(config.Current.DirectMessageHistory.SQLiteDatabase); err != nil {
log.Error("Error initializing SQLite database: %s", err)
}
}
var mux = http.NewServeMux()
mux.Handle("/", IndexPage())
@ -42,9 +53,12 @@ func (s *Server) Setup() error {
mux.Handle("/api/statistics", s.Statistics())
mux.Handle("/api/blocklist", s.BlockList())
mux.Handle("/api/block/now", s.BlockNow())
mux.Handle("/api/disconnect/now", s.DisconnectNow())
mux.Handle("/api/authenticate", s.Authenticate())
mux.Handle("/api/shutdown", s.ShutdownAPI())
mux.Handle("/api/profile", s.UserProfile())
mux.Handle("/api/message/history", s.MessageHistory())
mux.Handle("/api/message/clear", s.ClearMessages())
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))

565
pkg/subscriber.go Normal file
View File

@ -0,0 +1,565 @@
package barertc
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"nhooyr.io/websocket"
)
// Auto incrementing Subscriber ID, assigned in AddSubscriber.
var SubscriberID int
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
ChatStatus string
VideoStatus int
DND bool // Do Not Disturb status (DMs are closed)
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
loginAt time.Time
// Connection details (WebSocket).
conn *websocket.Conn // WebSocket user
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
// Polling API users.
usePolling bool
sessionID string
lastPollAt time.Time
lastPollJWT time.Time // give a new JWT once in a while
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
blocked map[string]struct{} // usernames you have blocked
muted map[string]struct{} // usernames you muted
// Admin "unblockable" override command, e.g. especially for your chatbot so it can
// still moderate the chat even if users had blocked it. The /unmute-all admin command
// will toggle this setting: then the admin chatbot will appear in the Who's Online list
// as normal and it can see user messages in chat.
unblockable bool
// Record which message IDs belong to this user.
midMu sync.Mutex
messageIDs map[int64]struct{}
// Logging.
log bool
logfh map[string]io.WriteCloser
}
// NewSubscriber initializes a connected chat user.
func (s *Server) NewSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
return &Subscriber{
ctx: ctx,
cancel: cancelFunc,
messages: make(chan []byte, s.subscriberMessageBuffer),
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
blocked: make(map[string]struct{}),
messageIDs: make(map[int64]struct{}),
ChatStatus: "online",
}
}
// NewWebSocketSubscriber returns a new subscriber with a WebSocket connection.
func (s *Server) NewWebSocketSubscriber(ctx context.Context, conn *websocket.Conn, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.conn = conn
sub.closeSlow = func() {
conn.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
}
return sub
}
// NewPollingSubscriber returns a new subscriber using the polling API.
func (s *Server) NewPollingSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.usePolling = true
sub.lastPollAt = time.Now()
sub.lastPollJWT = time.Now()
sub.closeSlow = func() {
// Their outbox is filled up, disconnect them.
log.Error("Polling subscriber %s#%d: inbox is filled up!", sub.Username, sub.ID)
// Send an exit message.
if sub.authenticated && sub.ChatStatus != "hidden" {
sub.authenticated = false
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
s.SendWhoList()
}
s.DeleteSubscriber(sub)
}
return sub
}
// OnClientMessage handles a chat protocol message from the user's WebSocket or polling API.
func (s *Server) OnClientMessage(sub *Subscriber, msg messages.Message) {
// What action are they performing?
switch msg.Action {
case messages.ActionLogin:
s.OnLogin(sub, msg)
case messages.ActionMessage:
s.OnMessage(sub, msg)
case messages.ActionFile:
s.OnFile(sub, msg)
case messages.ActionMe:
s.OnMe(sub, msg)
case messages.ActionOpen:
s.OnOpen(sub, msg)
case messages.ActionBoot:
s.OnBoot(sub, msg, true)
case messages.ActionUnboot:
s.OnBoot(sub, msg, false)
case messages.ActionMute, messages.ActionUnmute:
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
case messages.ActionBlock:
s.OnBlock(sub, msg)
case messages.ActionBlocklist:
s.OnBlocklist(sub, msg)
case messages.ActionCandidate:
s.OnCandidate(sub, msg)
case messages.ActionSDP:
s.OnSDP(sub, msg)
case messages.ActionWatch:
s.OnWatch(sub, msg)
case messages.ActionUnwatch:
s.OnUnwatch(sub, msg)
case messages.ActionTakeback:
s.OnTakeback(sub, msg)
case messages.ActionReact:
s.OnReact(sub, msg)
case messages.ActionReport:
s.OnReport(sub, msg)
case messages.ActionPing:
default:
sub.ChatServer("Unsupported message type: %s", msg.Action)
}
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
func (sub *Subscriber) ReadLoop(s *Server) {
go func() {
for {
msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil {
log.Error("ReadLoop error(%d=%s): %+v", sub.ID, sub.Username, err)
s.DeleteSubscriber(sub)
// Notify if this user was auth'd and not hidden
if sub.authenticated && sub.ChatStatus != "hidden" {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
})
s.SendWhoList()
}
return
}
if msgType != websocket.MessageText {
log.Error("Unexpected MessageType")
continue
}
// Read the user's posted message.
var msg messages.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Read(%d=%s) Message error: %s", sub.ID, sub.Username, err)
continue
}
if msg.Action != messages.ActionFile {
log.Debug("Read(%d=%s): %s", sub.ID, sub.Username, data)
}
// Handle their message.
s.OnClientMessage(sub, msg)
}
}()
}
// IsAdmin safely checks if the subscriber is an admin.
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)
if err != nil {
return err
}
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
// Add the message to the recipient's queue. If the queue is too full,
// disconnect the client as they can't keep up.
select {
case sub.messages <- data:
default:
go sub.closeSlow()
}
return nil
}
// SendMe sends the current user state to the client.
func (sub *Subscriber) SendMe() {
sub.SendJSON(messages.Message{
Action: messages.ActionMe,
Username: sub.Username,
VideoStatus: sub.VideoStatus,
})
}
// SendCut sends the client a 'cut' message to deactivate their camera.
func (sub *Subscriber) SendCut() {
sub.SendJSON(messages.Message{
Action: messages.ActionCut,
})
}
// ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(message, v...),
})
}
// AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: ID #%d", sub.ID)
s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock()
}
// GetSubscriber by username.
func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
for _, sub := range s.IterSubscribers() {
if sub.Username == username {
return sub, nil
}
}
return nil, errors.New("not found")
}
// DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) {
if sub == nil {
return
}
log.Error("DeleteSubscriber: %s", sub.Username)
// Cancel its context to clean up the for-loop goroutine.
if sub.cancel != nil {
log.Info("Calling sub.cancel() on subscriber: %s#%d", sub.Username, sub.ID)
sub.cancel()
}
// Clean up any log files.
sub.teardownLogs()
s.subscribersMu.Lock()
delete(s.subscribers, sub)
s.subscribersMu.Unlock()
}
// IterSubscribers loops over the subscriber list with a read lock.
func (s *Server) IterSubscribers() []*Subscriber {
var result = []*Subscriber{}
// Lock for reads.
s.subscribersMu.RLock()
for sub := range s.subscribers {
result = append(result, sub)
}
s.subscribersMu.RUnlock()
return result
}
// UniqueUsername ensures a username will be unique or renames it. If the name is already unique, the error result is nil.
func (s *Server) UniqueUsername(username string) (string, error) {
var (
subs = s.IterSubscribers()
usernames = map[string]interface{}{}
origUsername = username
counter = 2
)
for _, sub := range subs {
usernames[sub.Username] = nil
}
// Check until unique.
for {
if _, ok := usernames[username]; ok {
username = fmt.Sprintf("%s %d", origUsername, counter)
counter++
} else {
break
}
}
if username != origUsername {
return username, errors.New("username was not unique and a unique name has been returned")
}
return username, nil
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg messages.Message) {
if len(msg.Message) < 1024 {
log.Debug("Broadcast: %+v", msg)
}
// Get the sender of this message.
sender, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error("Broadcast: sender name %s not found as a current subscriber!", msg.Username)
sender = nil
}
// Get the list of users who are online NOW, so we don't hold the mutex lock too long.
// Example: sending a fat GIF to a large audience could hang up the server for a long
// time until every copy of the GIF has been sent.
var subs = s.IterSubscribers()
for _, sub := range subs {
if !sub.authenticated {
continue
}
// Don't deliver it if the receiver has muted us.
if sub.Mutes(msg.Username) {
log.Debug("Do not broadcast message to %s: they have muted or booted %s", sub.Username, msg.Username)
continue
}
// Don't deliver it if there is any blocking between sender and receiver.
if sender != nil && sender.Blocks(sub) {
log.Debug("Do not broadcast message to %s: blocking between them and %s", msg.Username, sub.Username)
continue
}
// VIP channels: only deliver to subscribed VIP users.
if ch, ok := config.Current.GetChannel(msg.Channel); ok && ch.VIP && !sub.IsVIP() && !sub.IsAdmin() {
log.Debug("Do not broadcast message to %s: VIP channel and they are not VIP", sub.Username)
continue
}
sub.SendJSON(msg)
}
}
// SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg messages.Message) error {
log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@")
var found bool
var subs = s.IterSubscribers()
for _, sub := range subs {
if sub.Username == username {
found = true
sub.SendJSON(messages.Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
MessageID: msg.MessageID,
})
}
}
if !found {
return fmt.Errorf("%s is not online", username)
}
return nil
}
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var (
subscribers = s.IterSubscribers()
usernames = []string{} // distinct and sorted usernames
userSub = map[string]*Subscriber{}
)
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
usernames = append(usernames, sub.Username)
userSub[sub.Username] = sub
}
sort.Strings(usernames)
// Build the WhoList for each subscriber.
// TODO: it's the only way to fake videoActive for booted user views.
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
var users = []messages.WhoList{}
for _, un := range usernames {
user := userSub[un]
if user.ChatStatus == "hidden" {
continue
}
// Blocking: hide the presence of both people from the Who List.
if user.Blocks(sub) {
log.Debug("WhoList: hide %s from %s (blocking)", user.Username, sub.Username)
continue
}
who := messages.WhoList{
Username: user.Username,
Status: user.ChatStatus,
Video: user.VideoStatus,
DND: user.DND,
LoginAt: user.loginAt.Unix(),
}
// 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) {
if sub.IsAdmin() {
// They kicked the admin off, but admin can reopen the cam if they want.
// But, unset the user's "auto-open your camera" flag, so if the admin
// reopens it, the admin's cam won't open on the recipient's screen.
who.Video ^= messages.VideoFlagMutualOpen
} else {
// Force their video to "off"
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() {
who.Video = 0
}
}
if user.JWTClaims != nil {
who.Operator = user.JWTClaims.IsAdmin
who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = user.JWTClaims.ProfileURL
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() {
who.VIP = user.JWTClaims.VIP
}
} else {
who.VIP = user.JWTClaims.VIP
}
}
users = append(users, who)
}
sub.SendJSON(messages.Message{
Action: messages.ActionWhoList,
WhoList: users,
})
}
}
// Boots checks whether the subscriber has blocked username from their camera.
func (s *Subscriber) Boots(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.booted[username]
return ok
}
// Mutes checks whether the subscriber has muted username.
func (s *Subscriber) Mutes(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.muted[username]
return ok
}
// Blocks checks whether the subscriber blocks the username, or vice versa (blocking goes both directions).
func (s *Subscriber) Blocks(other *Subscriber) bool {
if s == nil || other == nil {
return false
}
// Admin blocking behavior: by default, admins are NOT blockable by users and retain visibility on
// chat, especially to moderate webcams (messages may still be muted between blocked users).
//
// If your chat server allows admins to be blockable:
if !config.Current.BlockableAdmins && (s.IsAdmin() || other.IsAdmin()) {
return false
} else {
// Admins are blockable, unless they have the unblockable flag - e.g. if you have an admin chatbot on
// your server it will send the `/unmute-all` command to still retain visibility into user messages for
// auto-moderation. The `/unmute-all` sets the unblockable flag, so your admin chatbot still appears
// on the Who's Online list as well.
unblockable := (s.IsAdmin() && s.unblockable) || (other.IsAdmin() && other.unblockable)
if unblockable {
return false
}
}
s.muteMu.RLock()
defer s.muteMu.RUnlock()
// Forward block?
if _, ok := s.blocked[other.Username]; ok {
return true
}
// Reverse block?
other.muteMu.RLock()
defer other.muteMu.RUnlock()
_, ok := other.blocked[s.Username]
return ok
}

View File

@ -2,247 +2,17 @@ package barertc
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/util"
"nhooyr.io/websocket"
)
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
ChatStatus string
VideoStatus int
DND bool // Do Not Disturb status (DMs are closed)
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
loginAt time.Time
// Connection details (WebSocket).
conn *websocket.Conn // WebSocket user
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
// Polling API users.
usePolling bool
sessionID string
lastPollAt time.Time
lastPollJWT time.Time // give a new JWT once in a while
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
blocked map[string]struct{} // usernames you have blocked
muted map[string]struct{} // usernames you muted
// Record which message IDs belong to this user.
midMu sync.Mutex
messageIDs map[int64]struct{}
// Logging.
log bool
logfh map[string]io.WriteCloser
}
// NewSubscriber initializes a connected chat user.
func (s *Server) NewSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
return &Subscriber{
ctx: ctx,
cancel: cancelFunc,
messages: make(chan []byte, s.subscriberMessageBuffer),
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
blocked: make(map[string]struct{}),
messageIDs: make(map[int64]struct{}),
ChatStatus: "online",
}
}
// NewWebSocketSubscriber returns a new subscriber with a WebSocket connection.
func (s *Server) NewWebSocketSubscriber(ctx context.Context, conn *websocket.Conn, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.conn = conn
sub.closeSlow = func() {
conn.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
}
return sub
}
// NewPollingSubscriber returns a new subscriber using the polling API.
func (s *Server) NewPollingSubscriber(ctx context.Context, cancelFunc func()) *Subscriber {
sub := s.NewSubscriber(ctx, cancelFunc)
sub.usePolling = true
sub.lastPollAt = time.Now()
sub.lastPollJWT = time.Now()
sub.closeSlow = func() {
// Their outbox is filled up, disconnect them.
log.Error("Polling subscriber %s#%d: inbox is filled up!", sub.Username, sub.ID)
// Send an exit message.
if sub.authenticated && sub.ChatStatus != "hidden" {
sub.authenticated = false
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
})
s.SendWhoList()
}
s.DeleteSubscriber(sub)
}
return sub
}
// OnClientMessage handles a chat protocol message from the user's WebSocket or polling API.
func (s *Server) OnClientMessage(sub *Subscriber, msg messages.Message) {
// What action are they performing?
switch msg.Action {
case messages.ActionLogin:
s.OnLogin(sub, msg)
case messages.ActionMessage:
s.OnMessage(sub, msg)
case messages.ActionFile:
s.OnFile(sub, msg)
case messages.ActionMe:
s.OnMe(sub, msg)
case messages.ActionOpen:
s.OnOpen(sub, msg)
case messages.ActionBoot:
s.OnBoot(sub, msg, true)
case messages.ActionUnboot:
s.OnBoot(sub, msg, false)
case messages.ActionMute, messages.ActionUnmute:
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
case messages.ActionBlock:
s.OnBlock(sub, msg)
case messages.ActionBlocklist:
s.OnBlocklist(sub, msg)
case messages.ActionCandidate:
s.OnCandidate(sub, msg)
case messages.ActionSDP:
s.OnSDP(sub, msg)
case messages.ActionWatch:
s.OnWatch(sub, msg)
case messages.ActionUnwatch:
s.OnUnwatch(sub, msg)
case messages.ActionTakeback:
s.OnTakeback(sub, msg)
case messages.ActionReact:
s.OnReact(sub, msg)
case messages.ActionReport:
s.OnReport(sub, msg)
case messages.ActionPing:
default:
sub.ChatServer("Unsupported message type.")
}
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
func (sub *Subscriber) ReadLoop(s *Server) {
go func() {
for {
msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil {
log.Error("ReadLoop error(%d=%s): %+v", sub.ID, sub.Username, err)
s.DeleteSubscriber(sub)
// Notify if this user was auth'd and not hidden
if sub.authenticated && sub.ChatStatus != "hidden" {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
})
s.SendWhoList()
}
return
}
if msgType != websocket.MessageText {
log.Error("Unexpected MessageType")
continue
}
// Read the user's posted message.
var msg messages.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Read(%d=%s) Message error: %s", sub.ID, sub.Username, err)
continue
}
if msg.Action != messages.ActionFile {
log.Debug("Read(%d=%s): %s", sub.ID, sub.Username, data)
}
// Handle their message.
s.OnClientMessage(sub, msg)
}
}()
}
// IsAdmin safely checks if the subscriber is an admin.
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)
if err != nil {
return err
}
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
// Add the message to the recipient's queue. If the queue is too full,
// disconnect the client as they can't keep up.
select {
case sub.messages <- data:
default:
go sub.closeSlow()
}
return nil
}
// SendMe sends the current user state to the client.
func (sub *Subscriber) SendMe() {
sub.SendJSON(messages.Message{
Action: messages.ActionMe,
Username: sub.Username,
VideoStatus: sub.VideoStatus,
})
}
// ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(message, v...),
})
}
// WebSocket handles the /ws websocket connection endpoint.
func (s *Server) WebSocket() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -305,304 +75,6 @@ func (s *Server) WebSocket() http.HandlerFunc {
})
}
// Auto incrementing Subscriber ID, assigned in AddSubscriber.
var SubscriberID int
// AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: ID #%d", sub.ID)
s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock()
}
// GetSubscriber by username.
func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
for _, sub := range s.IterSubscribers() {
if sub.Username == username {
return sub, nil
}
}
return nil, errors.New("not found")
}
// DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) {
if sub == nil {
return
}
log.Error("DeleteSubscriber: %s", sub.Username)
// Cancel its context to clean up the for-loop goroutine.
if sub.cancel != nil {
log.Info("Calling sub.cancel() on subscriber: %s#%d", sub.Username, sub.ID)
sub.cancel()
}
// Clean up any log files.
sub.teardownLogs()
s.subscribersMu.Lock()
delete(s.subscribers, sub)
s.subscribersMu.Unlock()
}
// IterSubscribers loops over the subscriber list with a read lock.
func (s *Server) IterSubscribers() []*Subscriber {
var result = []*Subscriber{}
// Lock for reads.
s.subscribersMu.RLock()
for sub := range s.subscribers {
result = append(result, sub)
}
s.subscribersMu.RUnlock()
return result
}
// UniqueUsername ensures a username will be unique or renames it. If the name is already unique, the error result is nil.
func (s *Server) UniqueUsername(username string) (string, error) {
var (
subs = s.IterSubscribers()
usernames = map[string]interface{}{}
origUsername = username
counter = 2
)
for _, sub := range subs {
usernames[sub.Username] = nil
}
// Check until unique.
for {
if _, ok := usernames[username]; ok {
username = fmt.Sprintf("%s %d", origUsername, counter)
counter++
} else {
break
}
}
if username != origUsername {
return username, errors.New("username was not unique and a unique name has been returned")
}
return username, nil
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg messages.Message) {
if len(msg.Message) < 1024 {
log.Debug("Broadcast: %+v", msg)
}
// Get the sender of this message.
sender, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error("Broadcast: sender name %s not found as a current subscriber!", msg.Username)
sender = nil
}
// Get the list of users who are online NOW, so we don't hold the mutex lock too long.
// Example: sending a fat GIF to a large audience could hang up the server for a long
// time until every copy of the GIF has been sent.
var subs = s.IterSubscribers()
for _, sub := range subs {
if !sub.authenticated {
continue
}
// Don't deliver it if the receiver has muted us.
if sub.Mutes(msg.Username) {
log.Debug("Do not broadcast message to %s: they have muted or booted %s", sub.Username, msg.Username)
continue
}
// Don't deliver it if there is any blocking between sender and receiver.
if sender != nil && sender.Blocks(sub) {
log.Debug("Do not broadcast message to %s: blocking between them and %s", msg.Username, sub.Username)
continue
}
// VIP channels: only deliver to subscribed VIP users.
if ch, ok := config.Current.GetChannel(msg.Channel); ok && ch.VIP && !sub.IsVIP() && !sub.IsAdmin() {
log.Debug("Do not broadcast message to %s: VIP channel and they are not VIP", sub.Username)
continue
}
sub.SendJSON(msg)
}
}
// SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg messages.Message) error {
log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@")
var found bool
var subs = s.IterSubscribers()
for _, sub := range subs {
if sub.Username == username {
found = true
sub.SendJSON(messages.Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
MessageID: msg.MessageID,
})
}
}
if !found {
return fmt.Errorf("%s is not online", username)
}
return nil
}
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var (
subscribers = s.IterSubscribers()
usernames = []string{} // distinct and sorted usernames
userSub = map[string]*Subscriber{}
)
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
usernames = append(usernames, sub.Username)
userSub[sub.Username] = sub
}
sort.Strings(usernames)
// Build the WhoList for each subscriber.
// TODO: it's the only way to fake videoActive for booted user views.
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
var users = []messages.WhoList{}
for _, un := range usernames {
user := userSub[un]
if user.ChatStatus == "hidden" {
continue
}
// Blocking: hide the presence of both people from the Who List.
if user.Blocks(sub) {
log.Debug("WhoList: hide %s from %s (blocking)", user.Username, sub.Username)
continue
}
who := messages.WhoList{
Username: user.Username,
Status: user.ChatStatus,
Video: user.VideoStatus,
DND: user.DND,
LoginAt: user.loginAt.Unix(),
}
// 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) {
if sub.IsAdmin() {
// They kicked the admin off, but admin can reopen the cam if they want.
// But, unset the user's "auto-open your camera" flag, so if the admin
// reopens it, the admin's cam won't open on the recipient's screen.
who.Video ^= messages.VideoFlagMutualOpen
} else {
// Force their video to "off"
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 {
who.Operator = user.JWTClaims.IsAdmin
who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = user.JWTClaims.ProfileURL
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)
}
sub.SendJSON(messages.Message{
Action: messages.ActionWhoList,
WhoList: users,
})
}
}
// Boots checks whether the subscriber has blocked username from their camera.
func (s *Subscriber) Boots(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.booted[username]
return ok
}
// Mutes checks whether the subscriber has muted username.
func (s *Subscriber) Mutes(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.muted[username]
return ok
}
// Blocks checks whether the subscriber blocks the username, or vice versa (blocking goes both directions).
func (s *Subscriber) Blocks(other *Subscriber) bool {
if s == nil || other == nil {
return false
}
// If either side is an admin, blocking is not allowed.
if s.IsAdmin() || other.IsAdmin() {
return false
}
s.muteMu.RLock()
defer s.muteMu.RUnlock()
// Forward block?
if _, ok := s.blocked[other.Username]; ok {
return true
}
// Reverse block?
other.muteMu.RLock()
defer other.muteMu.RUnlock()
_, ok := other.blocked[s.Username]
return ok
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

View File

@ -0,0 +1,54 @@
/* Forced dark theme for Bulma (custom created for BareRTC) */
/* BareRTC custom overrides */
@import url("chat-dark.css");
/* Copied from bulma.css - original dark theme styles */
:root {
--bulma-white-on-scheme-l: 100%;
--bulma-white-on-scheme: hsla(var(--bulma-white-h), var(--bulma-white-s), var(--bulma-white-on-scheme-l), 1);
--bulma-black-on-scheme-l: 0%;
--bulma-black-on-scheme: hsla(var(--bulma-black-h), var(--bulma-black-s), var(--bulma-black-on-scheme-l), 1);
--bulma-light-on-scheme-l: 96%;
--bulma-light-on-scheme: hsla(var(--bulma-light-h), var(--bulma-light-s), var(--bulma-light-on-scheme-l), 1);
--bulma-dark-on-scheme-l: 56%;
--bulma-dark-on-scheme: hsla(var(--bulma-dark-h), var(--bulma-dark-s), var(--bulma-dark-on-scheme-l), 1);
--bulma-text-on-scheme-l: 54%;
--bulma-text-on-scheme: hsla(var(--bulma-text-h), var(--bulma-text-s), var(--bulma-text-on-scheme-l), 1);
--bulma-primary-on-scheme-l: 41%;
--bulma-primary-on-scheme: hsla(var(--bulma-primary-h), var(--bulma-primary-s), var(--bulma-primary-on-scheme-l), 1);
--bulma-link-on-scheme-l: 73%;
--bulma-link-on-scheme: hsla(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-on-scheme-l), 1);
--bulma-info-on-scheme-l: 70%;
--bulma-info-on-scheme: hsla(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-on-scheme-l), 1);
--bulma-success-on-scheme-l: 53%;
--bulma-success-on-scheme: hsla(var(--bulma-success-h), var(--bulma-success-s), var(--bulma-success-on-scheme-l), 1);
--bulma-warning-on-scheme-l: 53%;
--bulma-warning-on-scheme: hsla(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-on-scheme-l), 1);
--bulma-danger-on-scheme-l: 70%;
--bulma-danger-on-scheme: hsla(var(--bulma-danger-h), var(--bulma-danger-s), var(--bulma-danger-on-scheme-l), 1);
--bulma-scheme-brightness: dark;
--bulma-scheme-main-l: 9%;
--bulma-scheme-main-bis-l: 11%;
--bulma-scheme-main-ter-l: 13%;
--bulma-soft-l: 20%;
--bulma-bold-l: 90%;
--bulma-soft-invert-l: 90%;
--bulma-bold-invert-l: 20%;
--bulma-background-l: 14%;
--bulma-border-weak-l: 21%;
--bulma-border-l: 24%;
--bulma-text-weak-l: 53%;
--bulma-text-l: 71%;
--bulma-text-strong-l: 93%;
--bulma-text-title-l: 100%;
--bulma-hover-background-l-delta: 5%;
--bulma-active-background-l-delta: 10%;
--bulma-hover-border-l-delta: 10%;
--bulma-active-border-l-delta: 20%;
--bulma-hover-color-l-delta: 5%;
--bulma-active-color-l-delta: 10%;
--bulma-shadow-h: 0deg;
--bulma-shadow-s: 0%;
--bulma-shadow-l: 100%;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,55 @@
/* Custom color overrides for Bulma's dark theme */
.chat-container {
background: rgb(39, 39, 39) !important;
background: linear-gradient(0deg, rgb(39, 39, 39) 0%, rgb(66, 66, 66) 100%) !important;
}
.has-background-primary-light {
background-color: rgba(28, 166, 76, 0.25) !important;
}
.has-background-info-light, .has-background-info {
background-color: rgb(26, 79, 95) !important
}
.has-background-success-light, .has-background-success {
background-color: rgba(19, 71, 37, 0.685) !important
}
.has-background-warning-light, .has-background-warning {
background-color: rgb(100, 90, 41) !important;
}
.has-background-at-mention {
background-color: rgb(65, 65, 48) !important;
}
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */
.has-text-dark, .button.is-grey {
color: #b5b5b5 !important;
}
.button.is-dark {
color: #b5b5b5 !important;
border-color: #b5b5b5 !important;
}
/* adjust some background colors darker */
.notification.is-success.is-light {
background-color: #232e29 !important;
color: #56cf98 !important;
}
.notification.is-warning.is-light {
background-color: rgb(51, 46, 21) !important;
color: rgb(253, 227, 97) !important;
}
.has-background-dm {
background-color: #100010 !important;
}
.emoji-button button {
background-color: rgba(0, 0, 0, 0.5) !important;
color: rgba(255, 255, 255, 0.5) !important;
}

View File

@ -0,0 +1,3 @@
/* Custom nonshy color overrides for Bulma's dark theme
(prefers-dark edition) */
@import url("chat-dark.css") screen and (prefers-color-scheme: dark);

View File

@ -6,6 +6,18 @@ body {
min-height: 100vh;
}
/* No dragging images - it plays wrongly with the drag/drop image sharing feature
and users can accidentally share in chat by dragging an on-page image */
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
user-select: none;
-moz-user-select: none;
-webkit-user-drag: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.float-right {
float: right;
}
@ -17,15 +29,19 @@ body {
/* DM title and bg color */
.has-background-private {
background-color: #b748c7;
background-color: #b748c7 !important;
}
.has-background-dm {
background-color: #fff9ff;
background-color: #fff9ff !important;
}
.has-background-at-mention {
background-color: rgb(250, 250, 192);
}
.has-text-private {
color: #CC00CC !important;
}
/* Truncate long text, e.g. usernames in the who list */
.truncate-text-line {
text-overflow: ellipsis;
@ -75,13 +91,6 @@ body {
grid-template-rows: auto 1fr auto;
}
@media (prefers-color-scheme: dark) {
.chat-container {
background: rgb(39, 39, 39);
background: linear-gradient(0deg, rgb(39, 39, 39) 0%, rgb(66, 66, 66) 100%);
}
}
/* Header row */
.chat-container > .chat-header {
grid-column: 1 / 4;
@ -110,6 +119,14 @@ body {
bottom: 4px;
}
/* User status indicator in the lower left corner of DMs */
.user-status-dm-field {
position: absolute;
z-index: 38; /* below auto-scroll checkbox */
left: 12px;
bottom: 4px;
}
/* Footer row: message entry box */
.chat-container > .chat-footer {
grid-column: 1 / 4;
@ -125,7 +142,7 @@ body {
/* Responsive CSS styles */
@media screen and (min-width: 1024px) {
.mobile-only {
display: none;
display: none !important;
}
}
@media screen and (max-width: 1024px) {
@ -237,6 +254,7 @@ div.feed.popped-out {
top: 0;
left: 0;
resize: none;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
.video-feeds.x1 > .feed {
@ -272,12 +290,14 @@ div.feed.popped-out {
position: absolute;
left: 4px;
bottom: 4px;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
.feed > .close {
position: absolute;
right: 4px;
top: 0;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
.feed > .caption {
@ -288,6 +308,7 @@ div.feed.popped-out {
left: 4px;
font-size: small;
padding: 2px 4px;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
/* YouTube embeds */

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
<script>
export default {
props: {
visible: Boolean,
isConfirm: Boolean,
title: String,
icon: String,
message: String,
},
data() {
return {
username: '',
};
},
mounted() {
window.addEventListener('keyup', (e) => {
if (!this.visible) return;
if (e.key === 'Enter') {
return this.callback();
}
if (e.key == 'Escape') {
return this.close();
}
})
},
methods: {
callback() {
this.$emit('close');
this.$emit('callback');
},
close() {
this.$emit('close');
}
}
}
</script>
<template>
<div class="modal" :class="{ 'is-active': 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">
<i v-if="icon" :class="icon" class="mr-2"></i>
{{ title }}
</p>
<button class="delete mr-3 mt-3" aria-label="close" @click.prevent="close"></button>
</header>
<div class="card-content">
<form @submit.prevent="callback()">
<p class="literal mb-4">{{ message }}</p>
<div class="columns is-centered">
<div class="column is-narrow">
<button type="submit"
class="button is-success px-5">
OK
</button>
<button v-if="isConfirm"
type="button"
class="button is-link ml-3 px-5"
@click="close">
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal {
/* a high priority modal over other modals. note: bulma's default z-index is 40 for modals */
z-index: 42;
}
.literal {
white-space: pre-wrap;
}
</style>

View File

@ -30,7 +30,7 @@ export default {
<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 Explicit content</p>
<p class="card-header-title">This camera may contain Explicit content</p>
</header>
<div class="card-content">
<p class="block">

View File

@ -22,7 +22,7 @@ export default {
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">Sign In</p>
<p class="card-header-title">Sign In</p>
</header>
<div class="card-content">
<form @submit.prevent="signIn()">

View File

@ -1,11 +1,12 @@
<script>
import EmojiPicker from 'vue3-emoji-picker';
import LocalStorage from '../lib/LocalStorage';
import 'vue3-emoji-picker/css';
export default {
props: {
message: Object, // chat Message object
isPresence: Boolean, // presence message (joined/left room, kicked, etc.)
action: String, // presence, notification, or (default) normal chat message
appearance: String, // message style appearance (cards, compact, etc.)
user: Object, // User object of the Message author
isOffline: Boolean, // user is not currently online
@ -53,6 +54,9 @@ export default {
{ n: ['banana'], u: '1f34c' },
]
},
// Emoji reactions are toggled fully spelled out (for mobile)
showReactions: false,
};
},
computed: {
@ -81,7 +85,15 @@ export default {
// Compactify a message (remove paragraph breaks added by Markdown renderer)
compactMessage() {
return this.message.message.replace(/<\/p>\s*<p>/g, "<br><br>").replace(/<\/?p>/g, "");
}
},
emojiPickerTheme() {
let theme = LocalStorage.get('theme');
if (theme === 'light' || theme === 'dark') {
return theme;
}
return 'auto';
},
},
methods: {
openProfile() {
@ -174,7 +186,7 @@ export default {
<template>
<!-- Presence message banners -->
<div v-if="isPresence" class="notification is-success is-light py-1 px-3 mb-2">
<div v-if="action === 'presence'" class="notification is-success is-light py-1 px-3 mb-2">
<!-- Tiny avatar next to name and action buttons -->
<div class="columns is-mobile">
@ -203,6 +215,11 @@ export default {
</div>
<!-- Notification message banners (e.g. DM disclaimer) -->
<div v-else-if="action === 'notification'" class="notification is-warning is-light mb-2">
<span v-html="message.message"></span>
</div>
<!-- Card Style (default) -->
<div v-else-if="appearance === 'cards' || !appearance" class="box mb-2 px-4 pt-3 pb-1 position-relative">
<div class="media mb-0">
@ -264,7 +281,7 @@ export default {
<div class="column is-narrow pl-1 pt-0" v-if="!noButtons">
<!-- DMs button -->
<button type="button" v-if="!(message.username === username || isDm)"
class="button is-grey is-outlined is-small px-2" @click="openDMs()"
class="button is-small px-2" @click="openDMs()"
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
:disabled="isDnd">
<i class="fa fa-comment"></i>
@ -272,7 +289,7 @@ export default {
<!-- Mute button -->
<button type="button" v-if="!(message.username === username)"
class="button is-grey is-outlined is-small px-2 ml-1" @click="muteUser()" title="Mute user">
class="button is-small px-2 ml-1" @click="muteUser()" title="Mute user">
<i class="fa fa-comment-slash" :class="{
'has-text-success': isMuted,
'has-text-danger': !isMuted
@ -281,14 +298,14 @@ export default {
<!-- Owner or admin: take back the message -->
<button type="button" v-if="message.username === username || isOp"
class="button is-grey is-outlined is-small px-2 ml-1"
class="button is-small px-2 ml-1"
title="Take back this message (delete it for everybody)" @click="takeback()">
<i class="fa fa-rotate-left has-text-danger"></i>
</button>
<!-- Everyone else: can hide it locally -->
<button type="button" v-if="message.username !== username"
class="button is-grey is-outlined is-small px-2 ml-1"
class="button is-small px-2 ml-1"
title="Hide this message (delete it only for your view)" @click="removeMessage()">
<i class="fa fa-trash"></i>
</button>
@ -301,7 +318,7 @@ export default {
<div v-if="message.msgID && !noButtons" class="emoji-button columns is-mobile is-gapless mb-0">
<!-- Report message button -->
<div class="column" v-if="reportEnabled && message.username !== username">
<button class="button is-small is-outlined mr-1" :class="{
<button class="button is-small is-outlined mr-1 py-2" :class="{
'is-danger': !message.reported,
'has-text-grey': message.reported
}" title="Report this message" @click="reportMessage()">
@ -311,7 +328,7 @@ export default {
</div>
<div class="column dropdown is-right"
:class="{ 'is-up': position >= 2 || reportEnabled, 'is-active': showEmojiPicker }"
:class="{ 'is-up': position >= 2, 'is-active': showEmojiPicker }"
@click="showEmojiPicker = true">
<div class="dropdown-trigger">
<button type="button" class="button is-small px-2" aria-haspopup="true"
@ -327,7 +344,7 @@ export default {
<!-- Emoji reactions menu -->
<EmojiPicker v-if="showEmojiPicker" :native="true" :display-recent="true" :disable-skin-tones="true"
:additional-groups="customEmojiGroups" :group-names="{ frequently_used: 'Frequently Used' }"
theme="auto" @select="onSelectEmoji"></EmojiPicker>
:theme="emojiPickerTheme" @select="onSelectEmoji"></EmojiPicker>
</div>
</div>
</div>
@ -340,11 +357,24 @@ export default {
<!-- Reactions so far? -->
<div v-if="hasReactions" class="mt-1">
<span v-for="(users, emoji) in reactions" v-bind:key="emoji" class="tag is-secondary mr-1 cursor-pointer"
:class="{ 'is-success is-light': iReacted(msg, emoji), 'is-secondary': !iReacted(msg, emoji) }"
<span v-for="(users, emoji) in reactions" v-bind:key="emoji" class="tag mr-1 cursor-pointer"
:class="{ 'has-text-weight-bold': iReacted(emoji), 'is-secondary': !iReacted(emoji) }"
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(emoji)">
{{ emoji }} <small class="ml-1">{{ users.length }}</small>
{{ emoji }}
<small v-if="showReactions" class="ml-1">
{{ users.join(', ') }}
</small>
<small v-else class="ml-1">{{ users.length }}</small>
</span>
<!-- Mobile helper to show all -->
<a href="#" class="tag is-secondary cursor-pointer" @click.prevent="showReactions = !showReactions">
<i class="fa mr-1"
:class="{'fa-angles-left': showReactions,
'fa-angles-right': !showReactions,
}"></i> {{ showReactions ? 'Less' : 'More' }}
</a>
</div>
</div>
@ -397,12 +427,26 @@ export default {
<!-- Reactions so far? -->
<div v-if="hasReactions" class="mb-2">
<span v-for="(users, emoji) in reactions" v-bind:key="emoji" class="tag is-secondary mr-1 cursor-pointer"
:class="{ 'is-success is-light': iReacted(msg, emoji), 'is-secondary': !iReacted(msg, emoji) }"
<span v-for="(users, emoji) in reactions" v-bind:key="emoji" class="tag mr-1 cursor-pointer"
:class="{ 'has-text-weight-bold': iReacted(emoji), 'is-secondary': !iReacted(emoji) }"
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(emoji)">
{{ emoji }} <small class="ml-1">{{ users.length }}</small>
{{ emoji }}
<small v-if="showReactions" class="ml-1">
{{ users.join(', ') }}
</small>
<small v-else class="ml-1">{{ users.length }}</small>
</span>
<!-- Mobile helper to show all -->
<a href="#" class="tag is-secondary cursor-pointer" @click.prevent="showReactions = !showReactions">
<i class="fa mr-1"
:class="{'fa-angles-left': showReactions,
'fa-angles-right': !showReactions,
}"></i> {{ showReactions ? 'Less' : 'More' }}
</a>
</div>
</div>
<!-- Emoji/Menu button -->
@ -411,7 +455,7 @@ export default {
<div class="columns is-mobile is-gapless mb-0">
<!-- More buttons menu (DM, mute, report, etc.) -->
<div class="column dropdown is-right"
:class="{ 'is-up': position >= 2 || reportEnabled, 'is-active': menuVisible }"
:class="{ 'is-up': position >= 2, 'is-active': menuVisible }"
@click="menuVisible = !menuVisible">
<div class="dropdown-trigger">
<button type="button" class="button is-small px-2 mr-1" aria-haspopup="true"
@ -463,7 +507,7 @@ export default {
<!-- Emoji reactions -->
<div class="column dropdown is-right"
:class="{ 'is-up': position >= 2 || reportEnabled, 'is-active': showEmojiPicker }"
:class="{ 'is-up': position >= 2, 'is-active': showEmojiPicker }"
@click="showEmojiPicker = true">
<div class="dropdown-trigger">
<button type="button" class="button is-small px-2" aria-haspopup="true"

View File

@ -1,10 +1,13 @@
<script>
import VideoFlag from '../lib/VideoFlag';
export default {
props: {
visible: Boolean,
jwt: String, // caller's JWT token for authorization
user: Object, // the user we are viewing
username: String, // the local user
isViewerOp: Boolean, // the viewer is an operator (show buttons)
websiteUrl: String,
isDnd: Boolean,
isMuted: Boolean,
@ -53,6 +56,17 @@ export default {
}
return this.user.username;
},
isOnBlueCam() {
// User is broadcasting a cam and is not NSFW.
if ((this.user.video & VideoFlag.Active) && !(this.user.video & VideoFlag.NSFW)) {
return true;
}
return false;
},
isOnCamera() {
// User's camera is enabled.
return (this.user.video & VideoFlag.Active);
},
},
methods: {
refresh() {
@ -115,6 +129,34 @@ export default {
this.$emit('boot-user', this.user.username);
},
// Operator commands (may be rejected by server if not really Op)
markNsfw() {
if (!window.confirm("Mark this user's webcam as 'Explicit'?")) return;
this.$emit('send-command', `/nsfw ${this.user.username}`);
// Close the modal immediately: our view of the user's cam data is a copy
// and we can't follow the current value.
this.cancel();
},
cutCamera() {
if (!window.confirm("Make this user stop broadcasting their camera?")) return;
this.$emit('send-command', `/cut ${this.user.username}`);
this.cancel();
},
kickUser() {
if (!window.confirm("Really kick this user from the chat room?")) return;
this.$emit('send-command', `/kick ${this.user.username}`);
},
banUser() {
let hours = window.prompt(
"Ban this user for how many hours? (Default 24)",
"24",
);
if (!/^\d+$/.test(hours)) return;
this.$emit('send-command', `/ban ${this.user.username} ${hours}`);
},
urlFor(url) {
// Prepend the base websiteUrl if the given URL is relative.
if (url.match(/^https?:/i)) {
@ -192,7 +234,7 @@ export default {
<div v-if="user.username !== username" class="mt-4">
<!-- DMs button -->
<button type="button"
class="button is-grey is-outlined is-small px-2 mb-1"
class="button is-small px-2 mb-1"
@click="openDMs()"
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
:disabled="isDnd">
@ -202,7 +244,7 @@ export default {
<!-- Mute button -->
<button type="button"
class="button is-grey is-outlined is-small px-2 ml-1 mb-1"
class="button is-small px-2 ml-1 mb-1"
@click="muteUser()" title="Mute user">
<i class="fa fa-comment-slash mr-1" :class="{
'has-text-success': isMuted,
@ -213,7 +255,7 @@ export default {
<!-- Boot button -->
<button type="button"
class="button is-grey is-outlined is-small px-2 ml-1 mb-1"
class="button is-small px-2 ml-1 mb-1"
@click="bootUser()" title="Boot user off your webcam">
<i class="fa fa-user-xmark mr-1" :class="{
'has-text-danger': !isBooted,
@ -221,6 +263,43 @@ export default {
}"></i>
{{ isBooted ? 'Allow to watch my webcam' : "Don't allow to watch my webcam" }}
</button>
<!-- Admin actions -->
<div v-if="isViewerOp" class="mt-1">
<!-- Mark camera NSFW -->
<button v-if="isOnBlueCam"
type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
@click="markNsfw()" title="Mark their camera as Explicit (red).">
<i class="fa fa-video mr-1 has-text-danger"></i>
Mark camera as Explicit
</button>
<!-- Cut camera -->
<button v-if="isOnCamera"
type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
@click="cutCamera()" title="Turn their camera off.">
<i class="fa fa-stop mr-1 has-text-danger"></i>
Cut camera
</button>
<!-- Kick user -->
<button type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
@click="kickUser()" title="Kick this user from the chat room.">
<i class="fa fa-shoe-prints mr-1 has-text-danger"></i>
Kick from the room
</button>
<!-- Ban user -->
<button type="button"
class="button is-small is-outlined is-danger has-text-dark px-2 mb-1"
@click="banUser()" title="Ban this user from the chat room for 24 hours.">
<i class="fa fa-clock mr-1 has-text-danger"></i>
Ban from chat
</button>
</div>
</div>
<!-- Profile Fields spinner/error -->
@ -245,7 +324,6 @@ export default {
{{ field.Value }}
</div>
</div>
</div>
<footer class="card-footer">
<a :href="profileURL" target="_blank"

View File

@ -71,7 +71,7 @@ export default {
<label class="label" for="classification">Report classification:</label>
<div class="select is-fullwidth">
<select id="classification" v-model="classification" :disabled="busy">
<option v-for="i in reportClassifications" :value="i">{{ i }}</option>
<option v-for="i in reportClassifications" v-bind:key="i" :value="i">{{ i }}</option>
</select>
</div>
</div>

View File

@ -11,6 +11,7 @@ export default {
isSourceMuted: Boolean, // camera is muted on the broadcaster's end
isWatchingMe: Boolean, // other video is watching us back
isFrozen: Boolean, // video is detected as frozen
watermarkImage: Image, // watermark image to overlay (nullable)
},
components: {
Slider,
@ -28,9 +29,15 @@ export default {
};
},
computed: {
containerID() {
return this.videoID + '-container';
},
videoID() {
return this.localVideo ? 'localVideo' : `videofeed-${this.username}`;
},
textColorClass() {
return this.isExplicit ? 'has-text-camera-red' : 'has-text-camera-blue';
},
},
methods: {
closeVideo() {
@ -44,6 +51,10 @@ export default {
this.$emit('reopen-video', this.username, true);
},
openProfile() {
this.$emit('open-profile', this.username);
},
// Toggle the Mute button
muteVideo() {
this.$emit('mute-video', this.username);
@ -53,10 +64,21 @@ export default {
this.$emit('popout', this.username);
},
fullscreen() {
let $elem = document.getElementById(this.videoID);
fullscreen(force=false) {
// If we are popped-out, pop back in before full screen.
if (this.poppedOut && !force) {
this.popoutVideo();
window.requestAnimationFrame(() => {
this.fullscreen(true);
});
return;
}
let $elem = document.getElementById(this.containerID);
if ($elem) {
if ($elem.requestFullscreen) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else if ($elem.requestFullscreen) {
$elem.requestFullscreen();
} else {
window.alert("Fullscreen not supported by your browser.");
@ -77,19 +99,28 @@ export default {
</script>
<template>
<div class="feed" :class="{
<div class="feed" :id="containerID" :class="{
'popped-out': poppedOut,
'popped-in': !poppedOut,
}" @mouseover="mouseOver = true" @mouseleave="mouseOver = false">
<video class="feed" :id="videoID" autoplay :muted="localVideo"></video>
<video class="feed"
:id="videoID"
autoplay
disablepictureinpicture
playsinline
oncontextmenu="return false;"
:muted="localVideo"></video>
<!-- Watermark layer -->
<div v-if="watermarkImage">
<img :src="watermarkImage" class="watermark">
<img :src="watermarkImage" class="corner-watermark seethru invert-color">
</div>
<!-- Caption -->
<div class="caption" :class="{
'has-text-camera-blue': !isExplicit,
'has-text-camera-red': isExplicit,
}">
<div class="caption" :class="textColorClass">
<i class="fa fa-microphone-slash mr-1 has-text-grey" v-if="isSourceMuted"></i>
{{ username }}
<a href="#" @click.prevent="openProfile" :class="textColorClass">{{ username }}</a>
<i class="fa fa-people-arrows ml-1 has-text-grey is-size-7" :title="username + ' is watching your camera too'"
v-if="isWatchingMe"></i>
@ -99,8 +130,8 @@ export default {
</div>
<!-- Close button (others' videos only) -->
<div class="close" v-if="!localVideo">
<a href="#" class="has-text-danger" title="Close video" @click.prevent="closeVideo()">
<div class="close" v-if="!localVideo" :class="{'seethru': !mouseOver}">
<a href="#" class="button is-small is-danger is-outlined px-2" title="Close video" @click.prevent="closeVideo()">
<i class="fa fa-close"></i>
</a>
</div>
@ -109,13 +140,16 @@ export default {
<div class="controls">
<!-- Mute Button -->
<button type="button" v-if="!isMuted" class="button is-small is-success is-outlined ml-1 px-2"
:class="{'seethru': !mouseOver}"
@click="muteVideo()">
<i class="fa" :class="{
'fa-microphone': localVideo,
'fa-volume-high': !localVideo
}"></i>
</button>
<button type="button" v-else class="button is-small is-danger ml-1 px-2" @click="muteVideo()">
<button type="button" v-else class="button is-small is-danger ml-1 px-2"
:class="{'seethru': !mouseOver}"
@click="muteVideo()">
<i class="fa" :class="{
'fa-microphone-slash': localVideo,
'fa-volume-xmark': !localVideo
@ -124,12 +158,14 @@ export default {
<!-- Pop-out Video -->
<button type="button" class="button is-small is-light is-outlined p-2 ml-2" title="Pop out"
:class="{'seethru': !mouseOver}"
@click="popoutVideo()">
<i class="fa fa-up-right-from-square"></i>
</button>
<!-- Full screen -->
<!-- Full screen. -->
<button type="button" class="button is-small is-light is-outlined p-2 ml-2" title="Go full screen"
:class="{'seethru': !mouseOver}"
@click="fullscreen()">
<i class="fa fa-expand"></i>
</button>
@ -152,4 +188,51 @@ export default {
top: 30px;
bottom: 44px;
}
/* A background image behind video elements in case they don't load properly */
video {
background-image: url(/static/img/connection-error.png);
background-position: center center;
background-repeat: no-repeat;
}
/* Translucent controls until mouse over */
.seethru {
opacity: 0.4;
}
/* Watermark image */
.watermark {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: 40%;
height: 40%;
opacity: 0.02;
animation-name: subtle-pulsate;
animation-duration: 10s;
animation-iteration-count: infinite;
}
.corner-watermark {
position: absolute;
right: 4px;
bottom: 4px;
width: 20%;
min-width: 32px;
min-height: 32px;
max-height: 20%;
}
.invert-color {
filter: invert(100%);
}
/* Animate the primary watermark to pulsate in opacity */
@keyframes subtle-pulsate {
0% { opacity: 0.02; }
50% { opacity: 0.04; }
100% { opacity: 0.02; }
}
</style>

View File

@ -8,12 +8,14 @@ export default {
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
isDnd: Boolean, // user is not accepting DMs
isMuted: Boolean, // user is muted by current user
isBlocked: Boolean, // user is blocked on your main website (can't be unmuted)
isBooted: Boolean, // user is booted by current user
vipConfig: Object, // VIP config settings for BareRTC
isOp: Boolean, // current user is operator (can always DM)
isVideoNotAllowed: Boolean, // whether opening this camera is not allowed
videoIconClass: String, // CSS class for the open video icon
isWatchingTab: Boolean, // is the "Watching" tab (replace video button w/ boot)
statusMessage: Object, // StatusMessage controller
},
data() {
return {
@ -57,7 +59,7 @@ export default {
if ((this.user.video & VideoFlag.Active) && (this.user.video & VideoFlag.NSFW)) {
result += "is-danger is-outlined";
} else if ((this.user.video & VideoFlag.Active) && !(this.user.video & VideoFlag.NSFW)) {
result += "is-info is-outlined";
result += "is-link is-outlined";
} else if (this.isVideoNotAllowed) {
result += "cursor-notallowed";
}
@ -101,6 +103,19 @@ export default {
hasReactions() {
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
},
// Status icons
hasStatusIcon() {
return this.user.status !== 'online' && this.statusMessage != undefined;
},
statusIconClass() {
let status = this.statusMessage.getStatus(this.user.status);
return status.icon;
},
statusLabel() {
let status = this.statusMessage.getStatus(this.user.status);
return `${status.emoji} ${status.label}`;
},
},
methods: {
openProfile() {
@ -157,34 +172,9 @@ export default {
<img v-else src="/static/img/shy.png" width="24" height="24">
<!-- Away symbol -->
<div v-if="user.status !== 'online'" class="status-away-icon">
<i v-if="user.status === 'away'" class="fa fa-clock has-text-light"
title="Status: Away"></i>
<i v-else-if="user.status === 'lunch'" class="fa fa-utensils has-text-light"
title="Status: Out to lunch"></i>
<i v-else-if="user.status === 'call'" class="fa fa-phone-volume has-text-light"
title="Status: On the phone"></i>
<i v-else-if="user.status === 'brb'" class="fa fa-stopwatch-20 has-text-light"
title="Status: Be right back"></i>
<i v-else-if="user.status === 'busy'" class="fa fa-briefcase has-text-light"
title="Status: Working"></i>
<i v-else-if="user.status === 'book'" class="fa fa-book has-text-light"
title="Status: Studying"></i>
<i v-else-if="user.status === 'gaming'"
class="fa fa-gamepad who-status-wide-icon-2 has-text-light"
title="Status: Gaming"></i>
<i v-else-if="user.status === 'idle'" class="fa-regular fa-moon has-text-light"
title="Status: Idle"></i>
<i v-else-if="user.status === 'horny'" class="fa fa-fire has-text-light"
title="Status: Horny"></i>
<i v-else-if="user.status === 'chatty'" class="fa fa-comment has-text-light"
title="Status: Chatty and sociable"></i>
<i v-else-if="user.status === 'introverted'" class="fa fa-spoon has-text-light"
title="Status: Introverted and quiet"></i>
<i v-else-if="user.status === 'exhibitionist'"
class="fa-regular fa-eye who-status-wide-icon-1 has-text-light"
title="Status: Watch me"></i>
<i v-else class="fa fa-clock has-text-light" :title="'Status: ' + user.status"></i>
<div v-if="hasStatusIcon" class="status-away-icon">
<i :class="statusIconClass" class="has-text-light"
:title="'Status: ' + statusLabel"></i>
</div>
</a>
</div>
@ -193,7 +183,7 @@ export default {
@click="openProfile()">
{{ user.username }}
</strong>
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" v-if="user.op"
<sup class="fa fa-peace has-text-warning is-size-7 ml-1" v-if="user.op"
title="Operator"></sup>
<sup class="is-size-7 ml-1" :class="vipConfig.Icon" v-else-if="user.vip"
:title="vipConfig.Name"></sup>
@ -212,16 +202,16 @@ export default {
</button>
<!-- Unmute User button (if muted) -->
<button type="button" v-if="isMuted" class="button is-small px-2 py-1"
<button type="button" v-if="isMuted && !isBlocked" class="button is-small px-2 py-1"
@click="muteUser()" title="This user is muted. Click to unmute them.">
<i class="fa fa-comment-slash has-text-danger"></i>
</button>
<!-- DM button (if not muted) -->
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
:disabled="user.username === username || (user.dnd && !isOp)"
:title="user.dnd ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
<i class="fa" :class="{ 'fa-comment': !user.dnd, 'fa-comment-slash': user.dnd }"></i>
:disabled="user.username === username || (user.dnd && !isOp) || (isBlocked && !isOp)"
:title="(user.dnd || isBlocked) ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
<i class="fa" :class="{ 'fa-comment': !(user.dnd || isBlocked), 'fa-comment-slash': user.dnd || isBlocked }"></i>
</button>
<!-- Video button -->

View File

@ -29,8 +29,10 @@ class ChatClient {
onWatch,
onUnwatch,
onBlock,
onCut,
// Misc function registrations for callback.
onLoggedIn, // connection is fully established (first 'me' echo from server).
onNewJWT, // new JWT token from ping response
bulkMuteUsers, // Upload our blocklist on connect.
focusMessageBox, // Tell caller to focus the message entry box.
@ -59,12 +61,17 @@ class ChatClient {
this.onWatch = onWatch;
this.onUnwatch = onUnwatch;
this.onBlock = onBlock;
this.onCut = onCut;
this.onLoggedIn = onLoggedIn;
this.onNewJWT = onNewJWT;
this.bulkMuteUsers = bulkMuteUsers;
this.focusMessageBox = focusMessageBox;
this.pushHistory = pushHistory;
// Received the first 'me' echo from server (to call onLoggedIn once per connection)
this.firstMe = false;
// WebSocket connection.
this.ws = {
conn: null,
@ -157,6 +164,13 @@ class ChatClient {
break;
case "me":
this.onMe(msg);
// The first me?
if (!this.firstMe) {
this.firstMe = true;
this.onLoggedIn();
}
break;
case "message":
this.onMessage(msg);
@ -191,6 +205,9 @@ class ChatClient {
case "block":
this.onBlock(msg);
break;
case "cut":
this.onCut(msg);
break;
case "error":
this.pushHistory({
channel: msg.channel,
@ -269,7 +286,7 @@ class ChatClient {
}
});
conn.addEventListener("open", ev => {
conn.addEventListener("open", () => {
this.ws.connected = true;
this.ChatClient("Websocket connected!");

View File

@ -7,6 +7,7 @@ const keys = {
'scrollback': Number, // Scrollback buffer (int)
'preferredDeviceNames': Object, // Webcam/mic device names (object, keys video,audio)
'whoSort': String, // user's preferred sort order for the Who List
'theme': String, // light, dark, or auto theme
// Webcam settings (booleans)
'videoMutual': Boolean,
@ -16,6 +17,8 @@ const keys = {
'videoExplicit': Boolean, // whether the user turns explicit on by default
'videoNonExplicit': Boolean, // user prefers not to see explicit
'rememberExpresslyClosed': Boolean,
'autoMuteWebcams': Boolean, // automatically mute other peoples' webcam audio feeds
'videoAutoShare': Boolean, // automatically share your webcam on page load
// Booleans
'usePolling': Boolean, // use the polling API instead of WebSocket
@ -24,6 +27,7 @@ const keys = {
'watchNotif': Boolean,
'muteSounds': Boolean,
'closeDMs': Boolean, // close unsolicited DMs
'debug': Boolean, // Debug views enabled (admin only)
// Don't Show Again on NSFW modals.
'skip-nsfw-modal': Boolean,

212
src/lib/StatusMessage.js Normal file
View File

@ -0,0 +1,212 @@
// Available status options.
const StatusOptions = [
{
category: "Status",
options: [
{
name: "online",
label: "Active",
emoji: "☀️",
icon: "fa fa-clock"
},
{
name: "away",
label: "Away",
emoji: "🕒",
icon: "fa fa-clock"
},
{
name: "brb",
label: "Be right back",
emoji: "⏰",
icon: "fa fa-stopwatch-20"
},
{
name: "afk",
label: "Away from keyboard",
emoji: "⌨️",
icon: "fa fa-keyboard who-status-wide-icon-1"
},
{
name: "lunch",
label: "Out to lunch",
emoji: "🍴",
icon: "fa fa-utensils"
},
{
name: "call",
label: "On the phone",
emoji: "📞",
icon: "fa fa-phone-volume"
},
{
name: "busy",
label: "Working",
emoji: "💼",
icon: "fa fa-briefcase"
},
{
name: "book",
label: "Studying",
emoji: "📖",
icon: "fa fa-book"
},
{
name: "gaming",
label: "Gaming",
emoji: "🎮",
icon: "fa fa-gamepad who-status-wide-icon-2"
},
{
name: "movie",
label: "Watching a movie",
emoji: "🎞️",
icon: "fa fa-film"
},
{
name: "travel",
label: "Traveling",
emoji: "✈️",
icon: "fa fa-plane"
},
// Hidden/special statuses
{
name: "idle",
label: "Idle",
emoji: "🕒",
icon: "fa-regular fa-moon",
hidden: true
},
{
name: "hidden",
label: "Hidden",
emoji: "🕵️",
icon: "",
adminOnly: true
},
],
},
{
category: "Mood",
options: [
{
name: "chatty",
label: "Chatty and sociable",
emoji: "🗨️",
icon: "fa fa-comment"
},
{
name: "introverted",
label: "Introverted and quiet",
emoji: "🥄",
icon: "fa fa-spoon"
},
// If NSFW enabled
{
name: "horny",
label: "Horny",
emoji: "🔥",
icon: "fa fa-fire",
nsfw: true,
},
{
name: "exhibitionist",
label: "Watch me",
emoji: "👀",
icon: "fa-regular fa-eye who-status-wide-icon-1",
nsfw: true,
}
]
}
];
// Flatten the set of all status options.
const StatusFlattened = (function() {
let result = [];
for (let category of StatusOptions) {
for (let option of category.options) {
result.push(option);
}
}
return result;
})();
// Hash map lookup of status by name.
const StatusByName = (function() {
let result = {};
for (let item of StatusFlattened) {
result[item.name] = item;
}
return result;
})();
// An API surface layer of functions.
class StatusMessageController {
// The caller configures:
// - nsfw (bool): the BareRTC PermitNSFW setting, which controls some status options.
// - isAdmin (func): return a boolean if the current user is operator.
// - currentStatus (func): return the name of the user's current status.
constructor() {
this.nsfw = false;
this.isAdmin = function() { return false };
this.currentStatus = function() { return StatusFlattened[0] };
}
// Iterate the category <optgroup> for the Status dropdown menu.
iterSelectOptGroups() {
return StatusOptions;
}
// Iterate the <option> for a category of statuses.
iterSelectOptions(category) {
let current = this.currentStatus(),
isAdmin = this.isAdmin();
for (let group of StatusOptions) {
if (group.category === category) {
// Return the filtered options.
let result = group.options.filter(option => {
if ((option.hidden && current !== option.name) ||
(option.adminOnly && !isAdmin) ||
(option.nsfw && !this.nsfw)) {
return false;
}
return true;
});
return result;
}
}
return [];
}
// Get details on a status message.
getStatus(name) {
if (StatusByName[name] != undefined) {
return StatusByName[name];
}
// Return a dummy status object.
return {
name: name,
label: name,
icon: "fa fa-clock",
emoji: "🕒"
};
}
// Offline status.
offline() {
return {
name: "offline",
label: "Offline",
icon: "fa fa-house-circle-xmark",
emoji: "🌜",
}
}
}
const StatusMessage = new StatusMessageController();
export default StatusMessage;

View File

@ -2,8 +2,16 @@
// special nuances in their WebRTC video sharing support. This is intended to
// detect: iPads, iPhones, and Safari on macOS.
function isAppleWebkit() {
// By User-Agent.
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
const ua = navigator.userAgent;
// By User-Agent: Apple mobiles.
if (/iPad|iPhone|iPod/.test(ua)) {
return true;
}
// Safari browser: claims to be Safari but not Chrome
// (Google Chrome claims to be both)
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
return true;
}

View File

@ -42,6 +42,7 @@ var DefaultSounds = {
Leave: "Quiet",
Watch: "Quiet",
Unwatch: "Quiet",
Mentioned: "Ping",
};
export { SoundEffects, DefaultSounds };

27
src/lib/watermark.js Normal file
View File

@ -0,0 +1,27 @@
import QrCode from 'qrcodejs';
// WatermarkImage outputs a QR code containing watermark data about the current user.
//
// To help detect when someone has screen recorded and shared it, and being able to know who/when/etc.
function WatermarkImage(username) {
let now = new Date();
let dateString = [
now.getFullYear(),
('0' + (now.getMonth()+1)).slice(-2),
('0' + (now.getDate())).slice(-2),
].join('-');
let fields = [
window.location.hostname,
username,
dateString,
].join(' ');
console.error("watermark message:", fields);
const matrix = QrCode.generate(fields);
const uri = QrCode.render('svg-uri', matrix);
return uri;
}
export default WatermarkImage;

View File

@ -43,7 +43,10 @@
<hr>
<h1>Help &amp; Support</h1>
<h1 id="help">
Help &amp; Support
<a href="#help" class="fa fa-paragraph is-size-6"></a>
</h1>
<ul>
<li><a href="#tour">Tour of the user interface</a></li>
@ -51,14 +54,18 @@
<li><a href="#features">Feature Highlights</a></li>
<li><a href="#browsers">Supported Browsers</a></li>
<li><a href="#privacy">Privacy</a></li>
<li><a href="#troubleshooting">Troubleshooting</a></li>
</ul>
<h1 id="tour">Tour of the user interface</h1>
<h1 id="tour">
Tour of the user interface
<a href="#tour" class="fa fa-paragraph is-size-6"></a>
</h1>
<div class="has-text-centered mb-4">
<img src="/static/img/screenshot.png" alt="Screenshot of the user interface on desktop">
<img src="/static/img/screenshot.png" width="1269" height="582" alt="Screenshot of the user interface on desktop">
<br><em>Pictured: Screenshot of the user interface on tablet or desktop-sized screens.</em><br><br>
<img src="/static/img/mobile.png" alt="Screenshot of the user interface on mobile">
<img src="/static/img/mobile.png" width="720" height="703" alt="Screenshot of the user interface on mobile">
<br><em>Pictured: Screenshot of the mobile interface</em>
</div>
@ -197,9 +204,17 @@
in to their webcams, respectively).
</p>
<h1 id="features">Feature Highlights</h1>
<hr>
<h3 id="emoji-reactions">Emoji Reactions</h3>
<h1 id="features">
Feature Highlights
<a href="#features" class="fa fa-paragraph is-size-6"></a>
</h1>
<h3 id="emoji-reactions">
Emoji Reactions
<a href="#emoji-reactions" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
You may add emoji reactions to chat messages by clicking on the
@ -215,7 +230,10 @@
reaction by clicking on it.
</p>
<h3>Muting spammy users</h3>
<h3 id="mute">
Muting spammy users
<a href="#mute" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
If somebody on chat is bothering you, you may <strong>mute</strong> their messages by clicking
@ -241,7 +259,10 @@
session (until you log off).
</p>
<h3 id="markdown">Styling Your Messages</h3>
<h3 id="markdown">
Styling Your Messages
<a href="#markdown" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
BareRTC supports Markdown syntax for your chat messages. You can make text <strong>bold</strong>
@ -259,9 +280,17 @@
website.
</p>
<h1 id="video-sharing">Video Sharing How-To's</h1>
<hr>
<h3 id="webcam">About webcam sharing</h3>
<h1 id="video-sharing">
Video Sharing How-To's
<a href="#video-sharing" class="fa fa-paragraph is-size-6"></a>
</h1>
<h3 id="webcam">
About webcam sharing
<a href="#webcam" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
The WebRTC technology used by this chat room allows for direct, <strong>peer to peer</strong>
@ -288,7 +317,10 @@
way so that they may be banned from the chat room.
</p>
<h3>How do I go on video?</h3>
<h3 id="broadcast">
How do I go on video?
<a href="#broadcast" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
To share your webcam, click on the green
@ -321,14 +353,20 @@
</li>
</ul>
<h3>How do I stop my camera?</h3>
<h3 id="stop-webcam">
How do I stop my camera?
<a href="#stop-webcam" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
When you are broadcasting, the red "<i class="fa fa-stop"></i> Stop" button at the top of the
page will turn your camera off.
</p>
<h3>How do I mute my camera's microphone?</h3>
<h3 id="mute-microphone">
How do I mute my camera's microphone?
<a href="#mute-microphone" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
The button to <i class="fa fa-mute"></i> Mute will be at the top of the page next to the
@ -336,7 +374,10 @@
your own webcam video preview to toggle the mute that way. Either button works!
</p>
<h3>How do I see who is watching me?</h3>
<h3 id="watching">
How do I see who is watching me?
<a href="#watching" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
You may click on the <button type="button" class="button is-small is-info is-outlined ml-1 px-1">
@ -350,7 +391,10 @@
You may also click on the <i class="fa fa-eye"></i> Watching tab in the Who's Online list.
</p>
<h3>How do I stop somebody from watching me?</h3>
<h3 id="boot">
How do I stop somebody from watching me?
<a href="#boot" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
On the Who's Online list, click on the <i class="fa fa-eye"></i> Watching tab to see who
@ -374,117 +418,77 @@
sure that you have booted them!
</p>
<h1 id="browsers">Supported Browsers</h1>
<h3 id="video-size">
How do I make videos larger on my screen?
<a href="#video-size" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
Currently, BareRTC works best on <i class="fab fa-chrome"></i> Chromium browsers (including Google
Chrome, Microsoft Edge, Opera, Brave, and other Chromium derivatives) as well as
<i class="fab fa-firefox"></i> Mozilla Firefox.
There are a few methods available to change the size of videos on the chat room,
depending on whether your device has a mouse cursor or is a touch screen (such as a tablet).
</p>
<p>
It works in these browsers on desktop operating systems (including <i class="fab fa-windows"></i> Windows,
<i class="fab fa-apple"></i> Mac OS and <i class="fab fa-linux"></i> GNU/Linux) as well as on
<i class="fab fa-android"></i> Android devices.
Options that may work with all devices (with mouse or touch screen):
</p>
<p>
Feature support matrix of browsers currently tested:
</p>
<table class="table table-striped mb-5">
<thead>
<tr>
<th>Device Type</th>
<th>Web Browsers</th>
<th>Chat works?</th>
<th>Video works?</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="fa fa-computer"></i> All Desktops</td>
<td><i class="fab fa-firefox"></i> Mozilla Firefox</td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
</tr>
<tr>
<td><i class="fa fa-computer"></i> All Desktops</td>
<td><i class="fab fa-chrome"></i> Chromium (Chrome, Edge)</td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
</tr>
<tr>
<td><i class="fab fa-apple"></i> Mac OS Desktop</td>
<td><i class="fab fa-chrome"></i> Safari</td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
<td><i class="fa fa-asterisk has-text-warning-dark"></i></td>
</tr>
<tr>
<td><i class="fab fa-android"></i> Android</td>
<td><i class="fab fa-firefox"></i> Mozilla Firefox</td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
</tr>
<tr>
<td><i class="fab fa-android"></i> Android</td>
<td><i class="fab fa-chrome"></i> Chromium (Vanadium)</td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
</tr>
<tr>
<td><i class="fab fa-apple"></i> iPhone &amp; iPad</td>
<td><i class="fa fa-globe"></i> All browsers</td>
<td><i class="fa fa-check has-text-success-dark"></i></td>
<td><i class="fa fa-asterisk has-text-warning-dark"></i></td>
</tr>
</tbody>
</table>
<h5 id="safari"><i class="fa fa-asterisk has-text-warning-dark"></i> Apple Safari Browsers</h5>
<p>
This section applies to web browsers based on Safari, which includes <strong>every</strong> web
browser on iPad and iPhone. There are some more nuances to the support level of these browsers on
BareRTC.
</p>
<p>
The regular chat features (logging in, chatting, sharing pictures) are 100% supported on Safari,
but webcam sharing may be more difficult to get working. On iPad, iPhone, and Safari browsers,
connecting to somebody's webcam may <strong>only</strong> work correctly if you follow exactly
these steps:
</p>
<ol>
<ul>
<li>
First, you need to share your own local webcam first (by clicking on the green "Share webcam"
button at the top of the chat page).
Chat Settings: by clicking on the blue menu button on the top-right corner of the page, the
Display tab has a "Video size" dropdown with some options to change the size of all videos
open on your screen.
</li>
<li>
Then, you may only be able to view somebody else's camera <strong>IF</strong> they have marked
the setting "When somebody opens my camera, I also open their camera automatically."
On wide screens (such as an iPad in landscape orientation): when you have videos open, look
for the <i class="fa fa-magnifying-glass-plus"></i> <i class="fa fa-magnifying-glass-minus"></i>
buttons in the header just above the video dock. Clicking on these buttons will scale the docked
videos larger or smaller (in similar increments as the Chat Settings dropdown).
</li>
</ol>
</ul>
<p>
This is because Apple Safari only supports two-directional video calls, which is in contrast to the
way that BareRTC was designed to work: it is intended to be possible that somebody can start their webcam
and be watched by anybody who wants to tune in, without it always needing to be a two-way video call. But
for Apple Safari browsers, uni-directional video channels are not supported, so iPads and iPhones are only
able to successfully connect to a video <strong>if</strong> that person opts-in to open your video in
return (thus making it a two-way video call).
If you are on a device with a mouse cursor, such as a desktop or a laptop with a touchpad, there
are a couple of ways to resize individual videos how you see fit:
</p>
<ul>
<li>
When videos are docked in the top of the page, hover your mouse over the bottom-right corner
of a video. It should show a 'resize' cursor, and you can click and drag from the bottom-right
corner to resize the video.
</li>
<li>
Videos can also be "popped out" of the dock at the top of the page. The pop-out button appears
on top of videos, next to the mute audio button. When popped out, you can drag videos anywhere
on the page and you can resize them by dragging from any edge except the top.
</li>
</ul>
<p>
For <i class="fab fa-apple"></i> <strong>macOS users</strong>, it is recommended to use a Chromium or
Firefox browser instead of Safari for the best experience. It is only Safari-based browsers that have
this limitation. Unfortunately, there is no alternative for iPad or iPhone: <em>every</em> web browser on
iOS is based on Safari per Apple's app store guidelines. (Chrome and Firefox on iOS were not allowed to
bring their <em>actual</em> web browser engine to Apple's mobiles).
And finally: you can put a video "full screen" too. In the control buttons at the bottom of each
video (beginning with the mute audio button), the one on the right will toggle the video into a
full screen view.
</p>
<h1 id="privacy">Privacy</h1>
<hr>
<h1 id="browsers">
Supported Browsers
<a href="#browsers" class="fa fa-paragraph is-size-6"></a>
</h1>
<p>
All features of the chat room, including webcam sharing, should generally work on all current
web browsers and devices (Firefox, all Chromium browsers including Google Chrome or Microsoft
Edge, Safari, Androids and iPads).
</p>
<hr>
<h1 id="privacy">
Privacy
<a href="#privacy" class="fa fa-paragraph is-size-6"></a>
</h1>
<p>
Some of the privacy considerations with this chat room include:
@ -493,7 +497,7 @@
<ul>
<li>
The server does not maintain long-term state of chat history. Messages are pushed out as
soon as they come in.
soon as they come in, and users are forgotten when they log off.
</li>
<li>
However, the server does keep temporary <strong>log files</strong> to help the server admin
@ -503,6 +507,13 @@
the contents of DMs may be logged since debug mode will record the low-level chat protocol
messages sent between the server and clients.
</li>
<li>
If the administrator has enabled it, your <strong>recent direct message</strong> histories
may be recorded (for your convenience) in a database. Old messages expire off after about 90
days, and you can clear all your DMs history from the Chat Settings -> Misc tab. DMs are only
logged when the associate website logs you in to the chat room, so that only you can access
them to remember where you left off with a chat partner.
</li>
<li>
Chat moderators <strong>DO NOT</strong> get to see the contents of your Direct Message
conversations - that feature is not programmed in to this chat room. The server logs which
@ -521,6 +532,229 @@
so choose your risk tolerance accordingly.
</li>
</ul>
<hr>
<h1 id="troubleshooting">
Troubleshooting
<a href="#troubleshooting" class="fa fa-paragraph is-size-6"></a>
</h1>
<p>
This section contains some troubleshooting advice for issues commonly experienced in the chat room.
</p>
<h2 id="webcam-troubleshooting">
Webcam Sharing
<a href="#webcam-troubleshooting" class="fa fa-paragraph is-size-6"></a>
</h2>
<h3 id="NotAllowedError">
Permission Denied, or NotAllowedError
<a href="#NotAllowedError" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
If you see an error message from <strong class="has-text-danger">ChatClient</strong> that says
something like "Webcam error: Permission denied" or a "NotAllowedError," this section is for you.
</p>
<p>
The reason for this error is that your web browser did not grant access to your Camera and Microphone
for the chat room to use. This may be because you accidentally clicked on the "Deny" button when your
browser asked you for permission, or because your web browser <em>itself</em> does not have permission for
these devices on your computer.
</p>
<p>
When you encounter this error, there are two places to look to resolve this:
</p>
<ol>
<li>
In your web browser's settings (e.g. in Google Chrome or Firefox), make sure that you are granting
permission for your Camera and Microphone to the chat room's website ({{.Hostname}}).
</li>
<li>
In your operating system's settings, ensure that your web browser itself has permission to use
your Camera and Microphone. <strong>Notice:</strong> on recent Mac OS and Windows systems, your
web browser might not have permission by default to access these devices!
</li>
</ol>
<p>
Please see the following sections for in-depth guidance on where to look.
</p>
<p>
<strong>Notice:</strong> webcam sharing will require both your Camera <em>and</em> your Microphone
permission - if either one is denied, webcam sharing will fail.
</p>
<h4>1. Check your web browser's permissions for {{.Hostname}}</h4>
<p>
Go into your web browser's settings, to the "Privacy" or "Permissions" section and verify that
<code>{{.Hostname}}</code> has permissions to use your Camera <strong>and</strong> your Microphone.
Or at the very least: make sure that these settings are not set to "Deny" for {{.Hostname}}.
</p>
<p>
For example, on <i class="fab fa-chrome"></i> <strong>Google Chrome:</strong>
</p>
<ul>
<li>Go into your browser Settings -> "Privacy and security"</li>
<li>Click on "Site settings" <small>("Controls what information sites can use and show (location, camera, pop-ups, and more")</small></li>
<li>From here you can check the permissions in two ways:
<ul>
<li>Under the "Recent activity" section, look for {{.Hostname}} or click "View permissions and data stored across sites" to look for it there.</li>
<li>Or: under the "Permissions" section, click into the "Camera" and "Microphone" settings to see the list of sites you've given/denied
permission for, and change {{.Hostname}} to "Allow" or remove {{.Hostname}} from the list of sites.
</li>
</ul>
</li>
</ul>
<p>
Or for example, on <i class="fab fa-firefox"></i> <strong>Mozilla Firefox:</strong>
</p>
<ul>
<li>Go into your browser Settings -> "Privacy & Security"</li>
<li>Scroll down to the "Permissions" section where you see buttons for Camera and Microphone.</li>
<li>Click on the "Settings..." button for Camera and Microphone to see the list of websites you've given/denied permission for.</li>
<li>Find <code>{{.Hostname}}</code> and remove it from the list.</li>
</ul>
<p>
After changing your browser settings, <strong>exit and restart your web browser</strong> and log on to the chat room.
When going on webcam, hopefully your browser should ask you for permission for your webcam and microphone: be sure to
click on "Allow" when prompted.
</p>
<p>
If the change in your web browser's settings doesn't resolve the permission error, then check in your operating system's settings.
</p>
<h4>2. Check in your operating system's settings</h4>
<p>
For example, <strong>if you are on <i class="fab fa-apple"></i> Mac OS:</strong>
</p>
<ul>
<li>Click on the Apple icon in your top menu bar and open "System Settings"</li>
<li>Click on "Privacy &amp; Security"</li>
<li>Look for the Camera and Microphone settings and ensure that they are <strong>allowed</strong> for your web browser.</li>
<li>For more information, try: <a href="https://support.apple.com/guide/mac-help/control-access-to-your-camera-mchlf6d108da/mac">https://support.apple.com/guide/mac-help/control-access-to-your-camera-mchlf6d108da/mac</a></li>
</ul>
<p>
Or for example, <strong>if you are on <i class="fab fa-windows"></i> Windows 10 or Windows 11:</strong>
</p>
<ul>
<li>Click on the Start button > Settings > Privacy & Security > Camera, and make sure Camera access is turned <strong>on.</strong></li>
<li>Make sure that "Let apps access your camera" is also turned on.</li>
<li>Ensure that your web browser (e.g. Google Chrome) has permission to access your camera and microphone.</li>
<li>For more information, try: <a href="https://support.microsoft.com/en-us/windows/manage-app-permissions-for-your-camera-in-windows-87ebc757-1f87-7bbf-84b5-0686afb6ca6b">https://support.microsoft.com/en-us/windows/manage-app-permissions-for-your-camera-in-windows-87ebc757-1f87-7bbf-84b5-0686afb6ca6b</a></li>
</ul>
<h3 id="other-webcam-errors">
Other Webcam Errors
<a href="#other-webcam-errors" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
The most common error is a Permission Error as described above, but there are some less common
error messages you may experience. Here is a short list of some of them:
</p>
<ul>
<li>
<strong>AbortError:</strong> e.g. "Starting videoinput failed"
<br><br>
A common cause for this error may be that your webcam device is already in use by a different program,
so that your web browser was not able to open it. Please make sure that you are not running another video
chat program (such as Skype or Zoom) which has your camera active, and try again.
</li>
<li>
<strong>NotFoundError</strong> or <strong>NotReadableError</strong>
<br><br>
This error may indicate that your camera device was not available to your web browser. For example, your
webcam might not be plugged in to USB or you may be missing hardware drivers for it. To begin diagnosing
this problem, check whether <other>other</other> websites or apps are able to use your camera: if none of
them can either, it heavily points toward a hardware or driver error.
</li>
</ul>
<p>
Other possible errors should be uncommon. If this troubleshooting guide has not been helpful, try copying
the error message into a search engine and find information online: all of the text following "Webcam error:"
will be coming from your web browser, so relevant results may be found online. A possible place to start may
be the <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia">Mozilla documentation for getUserMedia.</a>
</p>
<h3 id="webcam-not-loading">
Other peoples' webcams don't load
<a href="#webcam-not-loading" class="fa fa-paragraph is-size-6"></a>
</h3>
<p>
It may sometimes happen that you clicked to watch somebody else's webcam on chat, and their video doesn't
load. If it only happens with <em>some</em> cameras (but other cameras load correctly), then the problem is
most likely on <em>that person's</em> side and not your own: their camera likely doesn't work for anybody
else who is trying to watch it, either.
</p>
<p>
Webcam sharing on the chat room works by "peer to peer" direct connections between chatters. Usually, this
tends to "just work" for most people, but sometimes your network connection or firewall can get in the way
and prevent a connection from being established.
</p>
<p>
The most common kinds of network conditions that cause problems connecting to webcams include:
</p>
<ul>
<li>
If you are on a heavily firewalled network, such as in a school or workplace.
</li>
<li>
If you are on a cellular network, e.g. from a smartphone or tablet. Some cell phone carriers
add firewalls to their network to prevent direct peer-to-peer connections with mobile devices.
</li>
<li>
If you are using a Virtual Private Network, or VPN: some VPN providers don't forward the kind
of network messages that are needed to establish a peer-to-peer connection.
</li>
</ul>
<p>
For some specific advice:
</p>
<ul>
<li>
If you can not connect to <strong>any</strong> webcam at all (e.g. many people are on webcam but you
can not get <strong>any</strong> of them to work): most likely it is because you are on a firewalled
network, like those listed above. If you are on a cell network, try switching to WiFi; or if you are
using a VPN, try turning off your VPN.
</li>
<li>
If <strong>only one or two webcams</strong> won't open for you, but others on the chat room are working:
the problem is likely not on your end! It is likely that the people whose cameras are not working are
on a firewalled network, and they are likely having worse problems than you are!
</li>
<li>
If somebody's webcam button has a slash \ through it, like <i class="fa fa-video-slash has-text-info"></i>
then it <em>probably</em> means that this person requires <strong>you</strong> to share your webcam first
before you can open theirs. You can click on their camera button to see an exact reason why their camera
has the \ slash through it.
</li>
</ul>
</div>
</div>

View File

@ -1431,7 +1431,7 @@
<div class="column pr-0 is-clipped"
:class="{'pl-1': u.avatar}">
<strong class="truncate-text-line is-size-7">[[ u.username ]]</strong>
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1"
<sup class="fa fa-peace has-text-warning is-size-7 ml-1"
v-if="u.op"
title="Operator"></sup>
<sup class="is-size-7 ml-1"