Compare commits
135 Commits
ipad-testi
...
master
Author | SHA1 | Date | |
---|---|---|---|
a4a04d57cd | |||
70d71611e9 | |||
98bf0d9e84 | |||
f2629ecb06 | |||
134f9218a8 | |||
358e8d5aec | |||
9cd6ee98a4 | |||
671857952d | |||
96e1a6efa4 | |||
89ae43b78a | |||
9e7466f967 | |||
095cf1d4ea | |||
a3d0cc95f9 | |||
f802de88ce | |||
a70d6d54b3 | |||
3a7204178c | |||
971a6d800d | |||
16b148fc92 | |||
bbd6836c68 | |||
7f88439c84 | |||
9b8e7dc440 | |||
d4b69311ae | |||
49712ee966 | |||
72b6c45583 | |||
4c9d207b62 | |||
147315fee2 | |||
b011e36ddf | |||
a536862a91 | |||
fd36d09727 | |||
9c77bdb62e | |||
b74edd1512 | |||
b82e8f651b | |||
747f4fd5d4 | |||
745c282650 | |||
e70b439cdd | |||
b5bbbde784 | |||
f36c83dbcc | |||
f094213a34 | |||
b8b53c65f3 | |||
3424be2f4d | |||
d510ac791f | |||
9932cb5a2c | |||
93c4e12680 | |||
a0786b2fa9 | |||
bef135fbd6 | |||
ed82920de9 | |||
92a376786d | |||
f0dd1d952c | |||
96d61614f4 | |||
c7ef254361 | |||
2a0f8b0cdf | |||
5e68c99514 | |||
206784e0b9 | |||
e74f7297e6 | |||
8e87c377e8 | |||
bf59a7b6c9 | |||
27380ec558 | |||
ebf5b3f47e | |||
21797788a2 | |||
dffd432221 | |||
449929b8d1 | |||
aa162a5b7a | |||
139f9ece70 | |||
f75ad32728 | |||
264b8f2a46 | |||
0e0aac991d | |||
d57d41ea3a | |||
00c6015148 | |||
c3808bbe89 | |||
538347ebc7 | |||
2cf4e5cc27 | |||
30fbba2f55 | |||
deb3bb616b | |||
356d2ddfa8 | |||
f0a6585af1 | |||
1e702b0e1e | |||
8004edb7b8 | |||
db819af8af | |||
2ac3e8e128 | |||
95c6c7859f | |||
fea1d1c7b9 | |||
bdb5e6359b | |||
30c5538ce6 | |||
802fab3862 | |||
7ecea89e03 | |||
cb2975edca | |||
ef79b2aa9b | |||
f18fce63ce | |||
7373882abf | |||
2810169ce9 | |||
489f5b6aad | |||
b363bd3cab | |||
dec0f63eca | |||
85a431c6b5 | |||
1d29c6da18 | |||
b5d0885c23 | |||
15b291826e | |||
a1b0d2e965 | |||
4b971fcf41 | |||
6fda8dca63 | |||
4b8ae56abd | |||
810115d20c | |||
267cda7989 | |||
e600250908 | |||
d651f96678 | |||
b7dc4c8df6 | |||
d01bae9966 | |||
1acc626819 | |||
239e80a7cc | |||
56ae9dbe9c | |||
ff6e36a142 | |||
7999ffc6d9 | |||
676c183528 | |||
25bbe84a61 | |||
f091747380 | |||
3b06676343 | |||
cbfbcd768f | |||
52dd53240e | |||
5d0515cba6 | |||
dbfd45794a | |||
a2cb32cce2 | |||
d7226e7f1d | |||
8853f9882b | |||
d8c92800f3 | |||
d8cb1c7c11 | |||
8906e89a51 | |||
e728644a77 | |||
a7342988ba | |||
7ffa6b4dbd | |||
940f14e2d6 | |||
0174bf7bd8 | |||
6e2aa517f5 | |||
f65f653430 | |||
3404373a4b | |||
0607fac724 |
11
.eslintrc.cjs
Normal file
11
.eslintrc.cjs
Normal file
|
@ -0,0 +1,11 @@
|
|||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
}
|
||||
}
|
28
.gitignore
vendored
28
.gitignore
vendored
|
@ -1,2 +1,30 @@
|
|||
settings.toml
|
||||
chatbot/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
128
Install.md
Normal file
128
Install.md
Normal 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.
|
56
Protocol.md
56
Protocol.md
|
@ -40,7 +40,7 @@ VideoFlag: {
|
|||
Active: 1 << 0, // or 00000001 in binary
|
||||
NSFW: 1 << 1, // or 00000010
|
||||
Muted: 1 << 2, // or 00000100, etc.
|
||||
IsTalking: 1 << 3,
|
||||
NonExplicit: 1 << 3,
|
||||
MutualRequired: 1 << 4,
|
||||
MutualOpen: 1 << 5,
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -343,11 +356,36 @@ The `unmute` action does the opposite and removes the mute status:
|
|||
}
|
||||
```
|
||||
|
||||
## Block
|
||||
|
||||
Sent by: Client, Server.
|
||||
|
||||
The block command places a hard block between the current user and the target.
|
||||
|
||||
When either user blocks the other:
|
||||
|
||||
* They do not see each other in the Who's Online list at all.
|
||||
* They can not see each other's messages, including presence messages.
|
||||
|
||||
**Note:** the chat page currently does not have a front-end button to block a user. This feature is currently used by the Blocklist feature to apply a block to a set of users at once upon join.
|
||||
|
||||
```javascript
|
||||
// Client Block
|
||||
{
|
||||
"action": "block",
|
||||
"username": "target"
|
||||
}
|
||||
```
|
||||
|
||||
The server may send a "block" message to the client in response to the BlockNow API endpoint: your main website can communicate that a block was just added, so if either user is currently in chat the block can apply immediately instead of at either user's next re-join of the room.
|
||||
|
||||
The server "block" message follows the same format, having the username of the other party.
|
||||
|
||||
## Blocklist
|
||||
|
||||
Sent by: Client.
|
||||
|
||||
The blocklist command is basically a bulk mute for (potentially) many usernames at once.
|
||||
The blocklist command is basically a bulk block for (potentially) many usernames at once.
|
||||
|
||||
```javascript
|
||||
// Client blocklist
|
||||
|
@ -359,11 +397,11 @@ The blocklist command is basically a bulk mute for (potentially) many usernames
|
|||
|
||||
How this works: if you have an existing website and use JWT authentication to sign users into chat, your site can pre-emptively sync the user's block list **before** the user enters the room, using the `/api/blocklist` endpoint (see the README.md for BareRTC).
|
||||
|
||||
The chat server holds onto blocklists temporarily in memory: when that user loads the chat room (with a JWT token!), the front-end page receives the cached blocklist. As part of the "on connected" handler, the chat page sends the `blocklist` command over WebSocket to perform a mass mute on these users in one go.
|
||||
The chat server holds onto blocklists temporarily in memory: when that user loads the chat room (with a JWT token!), the front-end page receives the cached blocklist. As part of the "on connected" handler, the chat page sends the `blocklist` command over WebSocket to perform a mass block on these users in one go.
|
||||
|
||||
The reason for this workflow is in case the chat server is rebooted _while_ the user is in the room. The cached blocklist pushed by your website is forgotten by the chat server back-end, but the client's page was still open with the cached blocklist already, and it will send the `blocklist` command to the server when it reconnects, eliminating any gaps.
|
||||
|
||||
## Boot
|
||||
## Boot, Unboot
|
||||
|
||||
Sent by: Client.
|
||||
|
||||
|
@ -384,6 +422,16 @@ When a user is booted:
|
|||
|
||||
Note: it is designed that the person being booted off can not detect that they have been booted. They will see your RTC PeerConnection close + get a Who List that says you are not sharing video - exactly the same as if you had simply turned off your camera completely.
|
||||
|
||||
There is also a client side Unboot command, to undo the effects of a boot:
|
||||
|
||||
```javascript
|
||||
// Client Unboot
|
||||
{
|
||||
"action": "unboot",
|
||||
"username": "target"
|
||||
}
|
||||
```
|
||||
|
||||
## WebRTC Signaling
|
||||
|
||||
Sent by: Client, Server.
|
||||
|
|
78
README.md
78
README.md
|
@ -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
|
||||
|
||||
|
@ -184,6 +205,25 @@ 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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -31,11 +31,13 @@ func (h *BotHandlers) watchForDeadlock() {
|
|||
|
||||
for {
|
||||
time.Sleep(15 * time.Second)
|
||||
go func() {
|
||||
h.client.Send(messages.Message{
|
||||
Action: messages.ActionMessage,
|
||||
Channel: "@" + h.client.Username(),
|
||||
Message: "deadlock ping",
|
||||
Message: fmt.Sprintf("deadlock ping %s", time.Now().Format(time.RFC3339)),
|
||||
})
|
||||
}()
|
||||
|
||||
// Has it been a while since our last ping?
|
||||
if time.Since(h.deadlockLastOK) > deadlockTTL {
|
||||
|
@ -50,6 +52,7 @@ func (h *BotHandlers) watchForDeadlock() {
|
|||
func (h *BotHandlers) onMessageFromSelf(msg messages.Message) {
|
||||
// If it is our own DM channel thread, it's for deadlock detection.
|
||||
if msg.Channel == "@"+h.client.Username() {
|
||||
log.Info("(Deadlock test) got echo from self, server still seems OK: %s", msg.Message)
|
||||
h.deadlockLastOK = time.Now()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||
"github.com/aichaos/rivescript-go"
|
||||
"github.com/aichaos/rivescript-go/lang/javascript"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -60,7 +61,7 @@ type BotHandlers struct {
|
|||
|
||||
// Store the reactions we have previously sent by messageID,
|
||||
// so we don't accidentally take back our own reactions.
|
||||
reactions map[int]map[string]interface{}
|
||||
reactions map[int64]map[string]interface{}
|
||||
reactionsMu sync.Mutex
|
||||
|
||||
// Deadlock detection (deadlock_watch.go): record time of last successful
|
||||
|
@ -81,9 +82,15 @@ func (c *Client) SetupChatbot() error {
|
|||
}),
|
||||
autoGreet: map[string]time.Time{},
|
||||
messageBuf: []messages.Message{},
|
||||
reactions: map[int]map[string]interface{}{},
|
||||
reactions: map[int64]map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Add JavaScript support.
|
||||
handler.rs.SetHandler("javascript", javascript.New(handler.rs))
|
||||
|
||||
// Attach RiveScript object macros.
|
||||
handler.setObjectMacros()
|
||||
|
||||
log.Info("Initializing RiveScript brain")
|
||||
if err := handler.rs.LoadDirectory("./brain"); err != nil {
|
||||
return fmt.Errorf("RiveScript LoadDirectory: %s", err)
|
||||
|
@ -92,9 +99,6 @@ func (c *Client) SetupChatbot() error {
|
|||
return fmt.Errorf("RiveScript SortReplies: %s", err)
|
||||
}
|
||||
|
||||
// Attach RiveScript object macros.
|
||||
handler.setObjectMacros()
|
||||
|
||||
// Set all the handler funcs.
|
||||
c.OnWho = handler.OnWho
|
||||
c.OnMe = handler.OnMe
|
||||
|
@ -105,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
|
||||
|
@ -130,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.
|
||||
|
@ -145,7 +156,7 @@ func (h *BotHandlers) cacheMessage(msg messages.Message) {
|
|||
}
|
||||
|
||||
// Get a message by ID from the recent message buffer.
|
||||
func (h *BotHandlers) getMessageByID(msgID int) (messages.Message, bool) {
|
||||
func (h *BotHandlers) getMessageByID(msgID int64) (messages.Message, bool) {
|
||||
h.messageBufMu.RLock()
|
||||
defer h.messageBufMu.RUnlock()
|
||||
for _, msg := range h.messageBuf {
|
||||
|
@ -229,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
|
||||
}
|
||||
|
@ -380,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)
|
||||
|
@ -392,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",
|
||||
})
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||
"github.com/aichaos/rivescript-go"
|
||||
"github.com/aichaos/rivescript-go/lang/javascript"
|
||||
)
|
||||
|
||||
// Set up object macros for RiveScript.
|
||||
|
@ -19,6 +20,8 @@ func (h *BotHandlers) setObjectMacros() {
|
|||
UTF8: true,
|
||||
Debug: rs.Debug,
|
||||
})
|
||||
bot.SetHandler("javascript", javascript.New(bot))
|
||||
|
||||
if err := bot.LoadDirectory("brain"); err != nil {
|
||||
return fmt.Sprintf("Error on LoadDirectory: %s", err)
|
||||
}
|
||||
|
@ -42,7 +45,7 @@ func (h *BotHandlers) setObjectMacros() {
|
|||
time.Sleep(2500 * time.Millisecond)
|
||||
h.client.Send(messages.Message{
|
||||
Action: messages.ActionReact,
|
||||
MessageID: msgID,
|
||||
MessageID: int64(msgID),
|
||||
Message: args[1],
|
||||
})
|
||||
}()
|
||||
|
@ -54,6 +57,19 @@ func (h *BotHandlers) setObjectMacros() {
|
|||
return "[react: invalid number of parameters]"
|
||||
})
|
||||
|
||||
// Mark a camera NSFW for a username.
|
||||
h.rs.SetSubroutine("nsfw", func(rs *rivescript.RiveScript, args []string) string {
|
||||
if len(args) >= 1 {
|
||||
var username = strings.TrimPrefix(args[0], "@")
|
||||
h.client.Send(messages.Message{
|
||||
Action: messages.ActionMessage,
|
||||
Message: fmt.Sprintf("/nsfw %s", username),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return "[nsfw: invalid number of parameters]"
|
||||
})
|
||||
|
||||
// Takeback a message (admin action especially)
|
||||
h.rs.SetSubroutine("takeback", func(rs *rivescript.RiveScript, args []string) string {
|
||||
if len(args) >= 1 {
|
||||
|
@ -61,7 +77,7 @@ func (h *BotHandlers) setObjectMacros() {
|
|||
// Take it back.
|
||||
h.client.Send(messages.Message{
|
||||
Action: messages.ActionTakeback,
|
||||
MessageID: msgID,
|
||||
MessageID: int64(msgID),
|
||||
})
|
||||
} else {
|
||||
return fmt.Sprintf("[takeback: %s]", err)
|
||||
|
@ -78,7 +94,7 @@ func (h *BotHandlers) setObjectMacros() {
|
|||
var comment = strings.Join(args[1:], " ")
|
||||
|
||||
// Look up this message.
|
||||
if msg, ok := h.getMessageByID(msgID); ok {
|
||||
if msg, ok := h.getMessageByID(int64(msgID)); ok {
|
||||
// Report it with the custom comment.
|
||||
h.client.Send(messages.Message{
|
||||
Action: messages.ActionReport,
|
||||
|
@ -120,4 +136,25 @@ func (h *BotHandlers) setObjectMacros() {
|
|||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// Send a public chat message to a channel name.
|
||||
h.rs.SetSubroutine("send-message", func(rs *rivescript.RiveScript, args []string) string {
|
||||
if len(args) >= 2 {
|
||||
var (
|
||||
channel = args[0]
|
||||
message = strings.Join(args[1:], " ")
|
||||
)
|
||||
|
||||
// Slide into their DMs.
|
||||
log.Error("Send chat to [%s]: %s", channel, message)
|
||||
h.client.Send(messages.Message{
|
||||
Action: messages.ActionMessage,
|
||||
Channel: channel,
|
||||
Message: message,
|
||||
})
|
||||
} else {
|
||||
return "[send-message: invalid number of parameters]"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
|
199
docs/API.md
199
docs/API.md
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -171,6 +171,25 @@ Example: say you have a global keyword trigger on public rooms and want to DM a
|
|||
< topic
|
||||
```
|
||||
|
||||
**Note:** the `dm` command will auto insert the @ prefix for the channel name, so it can only send to DM threads. Use `send-message` for the ability to send a message to a public channel (or DM thread) by having more control over the exact spelling of the channel name.
|
||||
|
||||
## Send Message
|
||||
|
||||
Send a chat message to a public channel. Like the `dm` macro but doesn't assume it to be a DM thread.
|
||||
|
||||
Usage: `send-message <channel> <message to send>`
|
||||
|
||||
Example:
|
||||
|
||||
```rivescript
|
||||
+ to * send the message *
|
||||
* <get isAdmin> <> true => This command is only available to operators.
|
||||
- I will send that to the <star1> channel.
|
||||
^ <call>send-message <star1> "{sentence}<star2>{/sentence}"</call>
|
||||
```
|
||||
|
||||
Then you could say: "to lobby send the message hey everyone" to send to a public channel (by its internal ID), or "to @soandso send the message hey there" to send it to a DM thread.
|
||||
|
||||
## Takeback
|
||||
|
||||
Take back a message by its ID. This may be useful if you have a global moderator trigger set up so you can remove a user's message.
|
||||
|
@ -211,3 +230,17 @@ Example:
|
|||
```
|
||||
|
||||
Note: the `report` command returns no text (except on error).
|
||||
|
||||
## NSFW
|
||||
|
||||
Send a BareRTC `/nsfw` operator command to mark a user's camera as Explicit.
|
||||
|
||||
Usage: `nsfw <username>`
|
||||
|
||||
Example:
|
||||
|
||||
```rivescript
|
||||
+ please mark the camera red for *
|
||||
- I will issue the NSFW camera command for: <star>
|
||||
^ <call>nsfw <star></call>
|
||||
```
|
||||
|
|
|
@ -3,21 +3,28 @@
|
|||
On first run it will create the default settings.toml file for you which you may then customize to your liking:
|
||||
|
||||
```toml
|
||||
Version = 2
|
||||
Version = 7
|
||||
Title = "BareRTC"
|
||||
Branding = "BareRTC"
|
||||
WebsiteURL = "https://www.example.com"
|
||||
CORSHosts = ["https://www.example.com"]
|
||||
WebsiteURL = "http://www.example.com"
|
||||
CORSHosts = ["http://www.example.com"]
|
||||
AdminAPIKey = "e635e463-7987-4788-94f3-671a5c2a589f"
|
||||
PermitNSFW = true
|
||||
UseXForwardedFor = true
|
||||
WebSocketReadLimit = 41943040
|
||||
UseXForwardedFor = false
|
||||
WebSocketReadLimit = 40971520
|
||||
MaxImageWidth = 1280
|
||||
PreviewImageWidth = 360
|
||||
|
||||
[JWT]
|
||||
Enabled = false
|
||||
Enabled = true
|
||||
Strict = true
|
||||
SecretKey = ""
|
||||
SecretKey = "05c45344-1c52-430b-beb9-c3f64ff7ed12"
|
||||
LandingPageURL = "https://www.example.com/enter-chat"
|
||||
|
||||
[TURN]
|
||||
URLs = ["stun:stun.l.google.com:19302"]
|
||||
Username = ""
|
||||
Credential = ""
|
||||
|
||||
[[PublicChannels]]
|
||||
ID = "lobby"
|
||||
|
@ -29,28 +36,174 @@ PreviewImageWidth = 360
|
|||
ID = "offtopic"
|
||||
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>"
|
||||
Icon = "fa fa-circle"
|
||||
MutuallySecret = false
|
||||
|
||||
[[MessageFilters]]
|
||||
Enabled = true
|
||||
PublicChannels = true
|
||||
PrivateChannels = true
|
||||
KeywordPhrases = [
|
||||
"\\bswear words\\b",
|
||||
"\\b(swearing|cursing)\\b",
|
||||
"suck my ([^\\s]+)"
|
||||
]
|
||||
CensorMessage = true
|
||||
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:
|
||||
|
||||
* Website settings:
|
||||
* **Title** goes in the title bar of the chat page.
|
||||
* **Branding** is the title shown in the corner of the page. HTML is permitted here! You may write an `<img>` tag to embed an image or use custom markup to color and prettify your logo.
|
||||
* **WebsiteURL** is the base URL of your actual website which is used in a couple of places:
|
||||
## Website Settings
|
||||
|
||||
* **Version** number for the settings file itself. When new features are added, the Version will increment and your settings.toml will be written to disk with sensible defaults filled in for the new options.
|
||||
* **Title** goes in the title bar of the chat page.
|
||||
* **Branding** is the title shown in the corner of the page. HTML is permitted here! You may write an `<img>` tag to embed an image or use custom markup to color and prettify your logo.
|
||||
* **WebsiteURL** is the base URL of your actual website which is used in a couple of places:
|
||||
* The About page will link to your website.
|
||||
* If using [JWT authentication](#authentication), avatar and profile URLs may be relative (beginning with a "/") and will append to your website URL to safe space on the JWT token size!
|
||||
* **UseXForwardedFor**: set it to true and (for logging) the user's remote IP will use the X-Real-IP header or the first address in X-Forwarded-For. Set this if you run the app behind a proxy like nginx if you want IPs not to be all localhost.
|
||||
* **CORSHosts**: your website's domain names that will be allowed to access [JSON APIs](#JSON APIs), like `/api/statistics`.
|
||||
* **PermitNSFW**: for user webcam streams, expressly permit "NSFW" content if the user opts in to mark their feed as such. Setting this will enable pop-up modals regarding NSFW video and give broadcasters an opt-in button, which will warn other users before they click in to watch.
|
||||
* **WebSocketReadLimit**: sets a size limit for WebSocket messages - it essentially also caps the max upload size for shared images (add a buffer as images will be base64 encoded on upload).
|
||||
* **MaxImageWidth**: for pictures shared in chat the server will resize them down to no larger than this width for the full size view.
|
||||
* **PreviewImageWidth**: to not flood the chat, the image in chat is this wide and users can click it to see the MaxImageWidth in a lightbox modal.
|
||||
* **JWT**: settings for JWT [Authentication](#authentication).
|
||||
* Enabled (bool): activate the JWT token authentication feature.
|
||||
* Strict (bool): if true, **only** valid signed JWT tokens may log in. If false, users with no/invalid token can enter their own username without authentication.
|
||||
* SecretKey (string): the JWT signing secret shared with your back-end app.
|
||||
* **PublicChannels**: list the public channels and their configuration. The default channel will be the first one listed.
|
||||
* ID (string): an arbitrary 'username' for the chat channel, like "lobby".
|
||||
* Name (string): the user friendly name for the channel, like "Off Topic"
|
||||
* Icon (string, optional): CSS class names for FontAwesome icon for the channel, like "fa fa-message"
|
||||
* WelcomeMessages ([]string, optional): messages that are delivered by ChatServer to the user when they connect to the server. Useful to give an introduction to each channel, list its rules, etc.
|
||||
* **CORSHosts** names HTTP hosts for Cross Origin Resource Sharing. Usually, this will be the same as your WebsiteURL. This feature is used with the [Web API](API.md) if your front-end page needs to call e.g. the /api/statistics endpoint on BareRTC.
|
||||
* **AdminAPIKey** is a shared secret authentication key for the admin API endpoints.
|
||||
* **PermitNSFW**: for user webcam streams, expressly permit "NSFW" content if the user opts in to mark their feed as such. Setting this will enable pop-up modals regarding NSFW video and give broadcasters an opt-in button, which will warn other users before they click in to watch.
|
||||
* **UseXForwardedFor**: set it to true and (for logging) the user's remote IP will use the X-Real-IP header or the first address in X-Forwarded-For. Set this if you run the app behind a proxy like nginx if you want IPs not to be all localhost.
|
||||
* **WebSocketReadLimit**: sets a size limit for WebSocket messages - it essentially also caps the max upload size for shared images (add a buffer as images will be base64 encoded on upload).
|
||||
* **MaxImageWidth**: for pictures shared in chat the server will resize them down to no larger than this width for the full size view.
|
||||
* **PreviewImageWidth**: to not flood the chat, the image in chat is this wide and users can click it to see the MaxImageWidth in a lightbox modal.
|
||||
|
||||
## JWT Authentication
|
||||
|
||||
Settings for JWT [Authentication](#authentication):
|
||||
|
||||
* **Enabled** (bool): activate the JWT token authentication feature.
|
||||
* **Strict** (bool): if true, **only** valid signed JWT tokens may log in. If false, users with no/invalid token can enter their own username without authentication.
|
||||
* **SecretKey** (string): the JWT signing secret shared with your back-end app.
|
||||
|
||||
## Public Channels
|
||||
|
||||
Settings for the default public text channels of your room.
|
||||
|
||||
* **ID** (string): an arbitrary 'username' for the chat channel, like "lobby".
|
||||
* **Name** (string): the user friendly name for the channel, like "Off Topic"
|
||||
* **Icon** (string, optional): CSS class names for FontAwesome icon for the channel, like "fa fa-message"
|
||||
* **WelcomeMessages** ([]string, optional): messages that are delivered by ChatServer to the user when they connect to the server. Useful to give an introduction to each channel, list its rules, etc.
|
||||
|
||||
## VIP Status
|
||||
|
||||
If using JWT authentication, your website can mark some users as VIPs when sending them over to the chat. The `[VIP]` section of settings.toml lets you customize the branding and behavior in BareRTC:
|
||||
|
||||
* **Name** (string): what you call your VIP users, used in mouse-over tooltips.
|
||||
* **Branding** (string): HTML supported, this will appear in webcam sharing modals to "make my cam only visible to fellow VIP users"
|
||||
* **Icon** (string): icon CSS name from Font Awesome.
|
||||
* **MutuallySecret** (bool): if true, the VIP features are hidden and only visible to people who are, themselves, VIP. For example, the icon on the Who List will only show to VIP users but non-VIP will not see the icon.
|
||||
|
||||
## Message Filters
|
||||
|
||||
BareRTC supports optional server-side filtering of messages. These can be applied to monitor public channels, Direct Messages, or both; and provide a variety of options how you want to handle filtered messages.
|
||||
|
||||
You can configure multiple sets of filters to treat different sets of keywords with different behaviors.
|
||||
|
||||
Options for the `[[MessageFilters]]` section include:
|
||||
|
||||
* **Enabled** (bool): whether to enable this filter. The default settings.toml has a filter template example by default, but it's not enabled.
|
||||
* **PublicChannels** (bool): whether to apply the filter to public channel messages.
|
||||
* **PrivateChannels** (bool): whether to apply the filter to private (Direct Message) channels.
|
||||
* **KeywordPhrases** ([]string): a listing of regular expression compatible strings to search the user's message again.
|
||||
* Tip: use word-boundary `\b` metacharacters to detect whole words and reduce false positives from partial word matches.
|
||||
* **CensorMessage** (bool): if true, the matching keywords will be substituted with asterisks in the user's message when it appears in chat.
|
||||
* **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.
|
|
@ -9,6 +9,11 @@ Webhooks are configured in your settings.toml file and look like so:
|
|||
Name = "report"
|
||||
Enabled = true
|
||||
URL = "http://localhost:8080/v1/barertc/report"
|
||||
|
||||
[[WebhookURLs]]
|
||||
Name = "profile"
|
||||
Enabled = true
|
||||
URL = "http://localhost:8080/v1/barertc/profile"
|
||||
```
|
||||
|
||||
All Webhooks will be called as **POST** requests and will contain a JSON payload that will always have the following two keys:
|
||||
|
@ -43,3 +48,40 @@ Example JSON payload posted to the webhook:
|
|||
```
|
||||
|
||||
BareRTC expects your webhook URL to return a 200 OK status code or it will surface an error in chat to the reporter.
|
||||
|
||||
## Profile Webhook
|
||||
|
||||
Enabling this webhook will allow your site to deliver more detailed profile information on demand for your users. This is used in chat when somebody opens the Profile Card modal for a user in chat.
|
||||
|
||||
BareRTC will call your webhook URL with the following payload:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"Action": "profile",
|
||||
"APIKey": "shared secret from settings.toml#AdminAPIKey",
|
||||
"Username": "soandso"
|
||||
}
|
||||
```
|
||||
|
||||
The expected response from your endpoint should follow this format:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"StatusCode": 200,
|
||||
"Data": {
|
||||
"OK": true,
|
||||
"Error": "any error messaging (omittable if no errors)",
|
||||
"ProfileFields": [
|
||||
{
|
||||
"Name": "Age",
|
||||
"Value": "30yo",
|
||||
},
|
||||
{
|
||||
"Name": "Gender",
|
||||
"Value": "Man",
|
||||
},
|
||||
// etc.
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
27
go.mod
27
go.mod
|
@ -7,13 +7,14 @@ 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.0
|
||||
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
|
||||
github.com/urfave/cli/v2 v2.25.7
|
||||
golang.org/x/image v0.11.0
|
||||
golang.org/x/image v0.12.0
|
||||
nhooyr.io/websocket v1.8.7
|
||||
)
|
||||
|
||||
|
@ -21,8 +22,15 @@ require (
|
|||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
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.16.7 // 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
|
||||
|
@ -37,8 +45,13 @@ require (
|
|||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/crypto v0.12.0 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
golang.org/x/term v0.11.0 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/net v0.15.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
|
||||
)
|
||||
|
|
66
go.sum
66
go.sum
|
@ -20,13 +20,17 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
|
||||
github.com/dop251/goja v0.0.0-20230812105242-81d76064690d h1:9aaGwVf4q+kknu+mROAXUApJ1DoOwhE8dGj/XLBYzWg=
|
||||
github.com/dop251/goja v0.0.0-20230812105242-81d76064690d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
|
||||
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de h1:lA38Xtzr1Wo+iQdkN2E11ziKXJYRxLlzK/e2/fdxoEI=
|
||||
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=
|
||||
|
@ -35,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=
|
||||
|
@ -45,12 +51,12 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
|
|||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
|
@ -71,10 +77,11 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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.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=
|
||||
|
@ -84,8 +91,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:
|
|||
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
|
@ -96,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=
|
||||
|
@ -116,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=
|
||||
|
@ -172,11 +182,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
|
||||
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
|
||||
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
@ -189,8 +199,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -213,13 +223,14 @@ 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.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.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=
|
||||
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
|
@ -228,8 +239,9 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
|
@ -239,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=
|
||||
|
@ -269,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=
|
||||
|
|
74
index.html
Normal file
74
index.html
Normal file
|
@ -0,0 +1,74 @@
|
|||
{{define "index"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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?{{.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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Photo Detail Modal -->
|
||||
<div class="modal" id="photo-modal">
|
||||
<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" oncontextmenu="return false">
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></button>
|
||||
</div>
|
||||
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- 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}};
|
||||
const WebhookURLs = {{.Config.WebhookURLs}};
|
||||
const VIP = {{.Config.VIP}};
|
||||
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) {
|
||||
let $modalImg = document.querySelector("#modalImage"),
|
||||
$modal = document.querySelector("#photo-modal");
|
||||
$modalImg.src = url;
|
||||
$modal.classList.add("is-active");
|
||||
return false;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Add global body click to hide the hamburger menu for chat settings.
|
||||
const settingsMenu = document.querySelector("#chat-settings-hamburger-menu");
|
||||
settingsMenu.addEventListener('click', (e) => {
|
||||
settingsMenu.classList.toggle('is-active');
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.body.addEventListener('click', () => {
|
||||
if (settingsMenu != undefined && settingsMenu.classList.contains("is-active")) {
|
||||
settingsMenu.classList.remove('is-active');
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
2107
package-lock.json
generated
Normal file
2107
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "barertc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"watch": "vite build -w --sourcemap=true --minify=false",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"floating-vue": "^2.0.0-beta.24",
|
||||
"hark": "^1.2.3",
|
||||
"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",
|
||||
"vue3-slider": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.3.1",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-plugin-vue": "^9.16.1",
|
||||
"vite": "^4.4.9"
|
||||
}
|
||||
}
|
649
pkg/api.go
649
pkg/api.go
|
@ -2,6 +2,7 @@ package barertc
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
@ -12,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,
|
||||
|
@ -354,6 +356,653 @@ func (s *Server) BlockList() http.HandlerFunc {
|
|||
})
|
||||
}
|
||||
|
||||
// BlockNow (/api/block/now) allows your website to add to a current online chatter's
|
||||
// blocked list immediately.
|
||||
//
|
||||
// For example: the BlockList endpoint does a bulk sync of the blocklist at the time
|
||||
// a user joins the chat room, but if users are already on chat when the blocking begins,
|
||||
// it doesn't take effect until one or the other re-joins the room. This API endpoint
|
||||
// can apply the blocking immediately to the currently online users.
|
||||
//
|
||||
// It is a POST request with a json body containing the following schema:
|
||||
//
|
||||
// {
|
||||
// "APIKey": "from settings.toml",
|
||||
// "Usernames": [ "source", "target" ]
|
||||
// }
|
||||
//
|
||||
// The pair of usernames will be the two users who block one another (in any order).
|
||||
// If any of the users are currently connected to the chat, they will all mutually
|
||||
// block one another immediately.
|
||||
func (s *Server) BlockNow() http.HandlerFunc {
|
||||
type request struct {
|
||||
APIKey string
|
||||
Usernames []string
|
||||
}
|
||||
|
||||
type result struct {
|
||||
OK bool
|
||||
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(¶ms); 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 update their blocklist accordingly.
|
||||
var changed bool
|
||||
for _, username := range params.Usernames {
|
||||
if sub, err := s.GetSubscriber(username); err == nil {
|
||||
for _, otherName := range params.Usernames {
|
||||
if username == otherName {
|
||||
continue
|
||||
}
|
||||
log.Info("BlockNow API: %s is currently on chat, add block for %+v", username, otherName)
|
||||
|
||||
sub.muteMu.Lock()
|
||||
sub.muted[otherName] = struct{}{}
|
||||
sub.blocked[otherName] = struct{}{}
|
||||
sub.muteMu.Unlock()
|
||||
|
||||
// Changes have been made to online users.
|
||||
changed = true
|
||||
|
||||
// Send a server-side "block" command to the subscriber, so their front-end page might
|
||||
// update the cachedBlocklist so there's no leakage in case of chat server rebooting.
|
||||
sub.SendJSON(messages.Message{
|
||||
Action: messages.ActionBlock,
|
||||
Username: otherName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any changes to blocklists were made: send the Who List.
|
||||
if changed {
|
||||
s.SendWhoList()
|
||||
}
|
||||
|
||||
enc.Encode(result{
|
||||
OK: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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(¶ms); 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.
|
||||
// If your webhook is not configured or not reachable, this endpoint returns
|
||||
// an error to the caller.
|
||||
//
|
||||
// Authentication: the caller must send their current chat JWT token when
|
||||
// hitting this endpoint.
|
||||
//
|
||||
// It is a POST request with a json body containing the following schema:
|
||||
//
|
||||
// {
|
||||
// "JWTToken": "the caller's jwt token",
|
||||
// "Username": [ "soandso" ]
|
||||
// }
|
||||
//
|
||||
// The response JSON will look like the following (this also mirrors the
|
||||
// response json as sent by your site's webhook URL):
|
||||
//
|
||||
// {
|
||||
// "OK": true,
|
||||
// "Error": "only on errors",
|
||||
// "ProfileFields": [
|
||||
// {
|
||||
// "Name": "Age",
|
||||
// "Value": "30yo",
|
||||
// },
|
||||
// {
|
||||
// "Name": "Gender",
|
||||
// "Value": "Man",
|
||||
// },
|
||||
// ...
|
||||
// ]
|
||||
// }
|
||||
func (s *Server) UserProfile() http.HandlerFunc {
|
||||
type request struct {
|
||||
JWTToken string
|
||||
Username string
|
||||
}
|
||||
|
||||
type profileField struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
type result struct {
|
||||
OK bool
|
||||
Error string `json:",omitempty"`
|
||||
ProfileFields []profileField `json:",omitempty"`
|
||||
}
|
||||
|
||||
type webhookRequest struct {
|
||||
Action string
|
||||
APIKey string
|
||||
Username string
|
||||
}
|
||||
|
||||
type webhookResponse struct {
|
||||
StatusCode int
|
||||
Data result
|
||||
}
|
||||
|
||||
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(¶ms); 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.
|
||||
_, _, err := jwt.ParseAndValidate(params.JWTToken)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
enc.Encode(result{
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the profile data from your website.
|
||||
data, err := PostWebhook("profile", webhookRequest{
|
||||
Action: "profile",
|
||||
APIKey: config.Current.AdminAPIKey,
|
||||
Username: params.Username,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Couldn't get profile information: %s", err)
|
||||
}
|
||||
|
||||
// Success? Try and parse the response into our expected format.
|
||||
var resp webhookResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
// A nice error message?
|
||||
if resp.Data.Error != "" {
|
||||
enc.Encode(result{
|
||||
Error: resp.Data.Error,
|
||||
})
|
||||
} else {
|
||||
enc.Encode(result{
|
||||
Error: fmt.Sprintf("Didn't get expected response for profile data: %s", err),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// At this point the expected resp mirrors our own, so return it.
|
||||
if resp.StatusCode != http.StatusOK || resp.Data.Error != "" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
enc.Encode(resp.Data)
|
||||
})
|
||||
}
|
||||
|
||||
// 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(¶ms); 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(¶ms); 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.
|
||||
|
|
105
pkg/commands.go
105
pkg/commands.go
|
@ -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)
|
||||
|
@ -152,14 +212,15 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
|
|||
other.SendJSON(messages.Message{
|
||||
Action: messages.ActionKick,
|
||||
})
|
||||
s.DeleteSubscriber(other)
|
||||
other.authenticated = false
|
||||
other.Username = ""
|
||||
sub.ChatServer("%s has been kicked from the room", username)
|
||||
|
||||
// Broadcast it to everyone.
|
||||
s.Broadcast(messages.Message{
|
||||
Action: messages.ActionPresence,
|
||||
Username: username,
|
||||
Message: "has been kicked from the room!",
|
||||
Message: messages.PresenceKicked,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -200,7 +261,8 @@ func (s *Server) KickAllCommand() {
|
|||
continue
|
||||
}
|
||||
|
||||
s.DeleteSubscriber(sub)
|
||||
sub.authenticated = false
|
||||
sub.Username = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -216,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 {
|
||||
|
@ -227,27 +289,26 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
|
|||
|
||||
log.Info("Operator %s bans %s for %d hours", sub.Username, username, duration/time.Hour)
|
||||
|
||||
other, err := s.GetSubscriber(username)
|
||||
if err != nil {
|
||||
sub.ChatServer("/ban: username not found: %s", username)
|
||||
} else {
|
||||
// Ban them.
|
||||
// Add them to the ban list.
|
||||
BanUser(username, duration)
|
||||
|
||||
// Broadcast it to everyone.
|
||||
// If the target user is currently online, disconnect them and broadcast the ban to everybody.
|
||||
if other, err := s.GetSubscriber(username); err == nil {
|
||||
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)
|
||||
other.SendJSON(messages.Message{
|
||||
Action: messages.ActionKick,
|
||||
})
|
||||
s.DeleteSubscriber(other)
|
||||
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
|
||||
other.authenticated = false
|
||||
other.Username = ""
|
||||
}
|
||||
|
||||
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
|
||||
}
|
||||
|
||||
// UnbanCommand handles the `/unban` operator command.
|
||||
|
@ -260,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)
|
||||
|
@ -298,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 {
|
||||
|
@ -328,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 {
|
||||
|
|
|
@ -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 = 6
|
||||
var currentVersion = 15
|
||||
|
||||
// Config for your BareRTC app.
|
||||
type Config struct {
|
||||
|
@ -33,10 +33,12 @@ type Config struct {
|
|||
CORSHosts []string
|
||||
AdminAPIKey string
|
||||
PermitNSFW bool
|
||||
BlockableAdmins bool
|
||||
|
||||
UseXForwardedFor bool
|
||||
|
||||
WebSocketReadLimit int64
|
||||
WebSocketSendTimeout int
|
||||
MaxImageWidth int
|
||||
PreviewImageWidth int
|
||||
|
||||
|
@ -45,6 +47,17 @@ type Config struct {
|
|||
PublicChannels []Channel
|
||||
|
||||
WebhookURLs []WebhookURL
|
||||
|
||||
VIP VIP
|
||||
|
||||
MessageFilters []*MessageFilter
|
||||
ModerationRule []*ModerationRule
|
||||
|
||||
DirectMessageHistory DirectMessageHistory
|
||||
|
||||
Strings Strings
|
||||
|
||||
Logging Logging
|
||||
}
|
||||
|
||||
type TurnConfig struct {
|
||||
|
@ -53,17 +66,43 @@ type TurnConfig struct {
|
|||
Credential string
|
||||
}
|
||||
|
||||
type VIP struct {
|
||||
Name string
|
||||
Branding string
|
||||
Icon string
|
||||
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)
|
||||
return template.JS(data)
|
||||
}
|
||||
|
||||
// GetChannel looks up and returns a channel by ID.
|
||||
func (c Config) GetChannel(id string) (Channel, bool) {
|
||||
for _, ch := range c.PublicChannels {
|
||||
if ch.ID == id {
|
||||
return ch, true
|
||||
}
|
||||
}
|
||||
return Channel{}, false
|
||||
}
|
||||
|
||||
// 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
|
||||
PermitPhotos bool // photos are allowed to be shared
|
||||
|
||||
// ChatServer messages to send to the user immediately upon connecting.
|
||||
WelcomeMessages []string
|
||||
|
@ -76,6 +115,31 @@ 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
|
||||
Directory string
|
||||
Channels []string
|
||||
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()
|
||||
|
||||
|
@ -91,6 +155,7 @@ func DefaultConfig() Config {
|
|||
"https://www.example.com",
|
||||
},
|
||||
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
|
||||
WebSocketSendTimeout: 10, // seconds
|
||||
MaxImageWidth: 1280,
|
||||
PreviewImageWidth: 360,
|
||||
PublicChannels: []Channel{
|
||||
|
@ -108,6 +173,16 @@ func DefaultConfig() Config {
|
|||
WelcomeMessages: []string{
|
||||
"Welcome to the Off Topic channel!",
|
||||
},
|
||||
PermitPhotos: true,
|
||||
},
|
||||
{
|
||||
ID: "vip",
|
||||
Name: "VIPs Only",
|
||||
VIP: true,
|
||||
PermitPhotos: true,
|
||||
WelcomeMessages: []string{
|
||||
"This channel is only for operators and VIPs.",
|
||||
},
|
||||
},
|
||||
},
|
||||
TURN: TurnConfig{
|
||||
|
@ -120,6 +195,50 @@ func DefaultConfig() Config {
|
|||
Name: "report",
|
||||
URL: "https://example.com/barertc/report",
|
||||
},
|
||||
{
|
||||
Name: "profile",
|
||||
URL: "https://example.com/barertc/user-profile",
|
||||
},
|
||||
},
|
||||
VIP: VIP{
|
||||
Name: "VIP",
|
||||
Branding: "<em>VIP Members</em>",
|
||||
Icon: "fa fa-circle",
|
||||
},
|
||||
MessageFilters: []*MessageFilter{
|
||||
{
|
||||
PublicChannels: true,
|
||||
PrivateChannels: true,
|
||||
KeywordPhrases: []string{
|
||||
`\bswear words\b`,
|
||||
`\b(swearing|cursing)\b`,
|
||||
`suck my ([^\s]+)`,
|
||||
},
|
||||
CensorMessage: true,
|
||||
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"},
|
||||
Usernames: []string{},
|
||||
},
|
||||
}
|
||||
c.JWT.Strict = true
|
||||
|
@ -166,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
|
||||
}
|
||||
|
|
47
pkg/config/message_filters.go
Normal file
47
pkg/config/message_filters.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
)
|
||||
|
||||
// MessageFilter configures censored or auto-flagged messages in chat.
|
||||
type MessageFilter struct {
|
||||
Enabled bool
|
||||
PublicChannels bool
|
||||
PrivateChannels bool
|
||||
KeywordPhrases []string
|
||||
CensorMessage bool
|
||||
ForwardMessage bool
|
||||
ReportMessage bool
|
||||
ChatServerResponse string
|
||||
|
||||
// Private use variables.
|
||||
isRegexpCompiled bool
|
||||
regexps []*regexp.Regexp
|
||||
regexpMu sync.Mutex
|
||||
}
|
||||
|
||||
// IterPhrases returns the keyword phrases as regular expressions.
|
||||
func (mf *MessageFilter) IterPhrases() []*regexp.Regexp {
|
||||
if mf.isRegexpCompiled {
|
||||
return mf.regexps
|
||||
}
|
||||
|
||||
// Compile and return the regexps.
|
||||
mf.regexpMu.Lock()
|
||||
defer mf.regexpMu.Unlock()
|
||||
mf.regexps = []*regexp.Regexp{}
|
||||
for _, phrase := range mf.KeywordPhrases {
|
||||
re, err := regexp.Compile(phrase)
|
||||
if err != nil {
|
||||
log.Error("MessageFilter: phrase '%s' did not compile as a regexp: %s", phrase, err)
|
||||
continue
|
||||
}
|
||||
mf.regexps = append(mf.regexps, re)
|
||||
}
|
||||
|
||||
return mf.regexps
|
||||
}
|
248
pkg/handlers.go
248
pkg/handlers.go
|
@ -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"
|
||||
)
|
||||
|
||||
|
@ -62,7 +63,8 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
|
|||
other.SendJSON(messages.Message{
|
||||
Action: messages.ActionKick,
|
||||
})
|
||||
s.DeleteSubscriber(other)
|
||||
other.authenticated = false
|
||||
other.Username = ""
|
||||
}
|
||||
|
||||
// They will take over their original username.
|
||||
|
@ -82,7 +84,6 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
|
|||
sub.SendJSON(messages.Message{
|
||||
Action: messages.ActionKick,
|
||||
})
|
||||
s.DeleteSubscriber(sub)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -159,6 +160,38 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
|
|||
MessageID: mid,
|
||||
}
|
||||
|
||||
// Run message filters.
|
||||
if filter, ok := s.filterMessage(sub, msg, &message); ok {
|
||||
// What do we do with the matched filter?
|
||||
|
||||
// If we will not send this message out, do echo it back to
|
||||
// the sender (possibly with censors applied).
|
||||
if !filter.ForwardMessage {
|
||||
s.SendTo(sub.Username, message)
|
||||
}
|
||||
|
||||
// Is ChatServer to say something?
|
||||
if filter.ChatServerResponse != "" {
|
||||
sub.ChatServer(filter.ChatServerResponse)
|
||||
}
|
||||
|
||||
// Are we to report the message to the site admin?
|
||||
if filter.ReportMessage {
|
||||
// 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.")
|
||||
} else if err := s.reportFilteredMessage(sub, msg); err != nil {
|
||||
// Send the report to the main website.
|
||||
log.Error("Reporting filtered message: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If we are not forwarding this message, stop here.
|
||||
if !filter.ForwardMessage {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Is this a DM?
|
||||
if strings.HasPrefix(msg.Channel, "@") {
|
||||
// Echo the message only to both parties.
|
||||
|
@ -168,39 +201,83 @@ 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() {
|
||||
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
|
||||
}
|
||||
|
||||
// If there is blocking happening, do not send.
|
||||
if sub.Blocks(rcpt) {
|
||||
return
|
||||
}
|
||||
|
||||
// Log this conversation?
|
||||
if IsLoggingUsername(sub) && IsLoggingUsername(rcpt) {
|
||||
// Both sides are logged, copy it to both logs.
|
||||
LogMessage(sub, rcpt.Username, sub.Username, msg)
|
||||
LogMessage(rcpt, sub.Username, sub.Username, msg)
|
||||
} else if IsLoggingUsername(sub) {
|
||||
// The sender of this message is being logged.
|
||||
LogMessage(sub, rcpt.Username, sub.Username, msg)
|
||||
} else if IsLoggingUsername(rcpt) {
|
||||
// The recipient of this message is being logged.
|
||||
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)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Are we logging this public channel?
|
||||
if IsLoggingChannel(msg.Channel) {
|
||||
LogChannel(s, msg.Channel, sub.Username, msg)
|
||||
}
|
||||
|
||||
// Broadcast a chat message to the room.
|
||||
s.Broadcast(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 {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to everybody to remove this message.
|
||||
s.Broadcast(messages.Message{
|
||||
|
@ -227,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
|
||||
|
@ -289,6 +377,11 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
|
|||
return
|
||||
}
|
||||
|
||||
// If there is blocking happening, do not send.
|
||||
if sub.Blocks(rcpt) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.SendTo(msg.Channel, message); err != nil {
|
||||
sub.ChatServer("Your message could not be delivered: %s", err)
|
||||
}
|
||||
|
@ -301,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.
|
||||
|
@ -312,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" {
|
||||
|
@ -333,14 +448,37 @@ 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
|
||||
}
|
||||
|
||||
// Enforce whether the viewer has permission to see this camera.
|
||||
if ok, reason := s.IsVideoNotAllowed(sub, other); !ok {
|
||||
sub.ChatServer(
|
||||
"Could not open that video: %s", reason,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -372,13 +510,56 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
|
|||
})
|
||||
}
|
||||
|
||||
// OnBoot is a user kicking you off their video stream.
|
||||
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
|
||||
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
|
||||
// IsVideoNotAllowed verifies whether a viewer can open a broadcaster's camera.
|
||||
//
|
||||
// Returns a boolean and an error message to return if false.
|
||||
func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, string) {
|
||||
var (
|
||||
ourVideoActive = (sub.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
|
||||
theirVideoActive = (other.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
|
||||
theirMutualRequired = (other.VideoStatus & messages.VideoFlagMutualRequired) == messages.VideoFlagMutualRequired
|
||||
theirVIPRequired = (other.VideoStatus & messages.VideoFlagOnlyVIP) == messages.VideoFlagOnlyVIP
|
||||
)
|
||||
|
||||
// Conditions in which we can not watch their video.
|
||||
var conditions = []struct {
|
||||
If bool
|
||||
Error string
|
||||
}{
|
||||
{
|
||||
If: !theirVideoActive,
|
||||
Error: "Their video is not currently enabled.",
|
||||
},
|
||||
{
|
||||
If: theirMutualRequired && !ourVideoActive,
|
||||
Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username),
|
||||
},
|
||||
{
|
||||
If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(),
|
||||
Error: "You do not have permission to view that camera.",
|
||||
},
|
||||
{
|
||||
If: (other.Mutes(sub.Username) || other.Blocks(sub)) && !sub.IsAdmin(),
|
||||
Error: "You do not have permission to view that camera.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range conditions {
|
||||
if c.If {
|
||||
return false, c.Error
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// OnBoot is a user kicking you off their video stream.
|
||||
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) {
|
||||
sub.muteMu.Lock()
|
||||
|
||||
if boot {
|
||||
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
|
||||
sub.booted[msg.Username] = struct{}{}
|
||||
sub.muteMu.Unlock()
|
||||
|
||||
// If the subject of the boot is an admin, inform them they have been booted.
|
||||
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
|
||||
|
@ -387,6 +568,12 @@ func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
|
|||
sub.Username,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
log.Info("%s unboots %s from their camera", sub.Username, msg.Username)
|
||||
delete(sub.booted, msg.Username)
|
||||
}
|
||||
|
||||
sub.muteMu.Unlock()
|
||||
|
||||
s.SendWhoList()
|
||||
}
|
||||
|
@ -417,13 +604,34 @@ func (s *Server) OnMute(sub *Subscriber, msg messages.Message, mute bool) {
|
|||
s.SendWhoList()
|
||||
}
|
||||
|
||||
// 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", 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() {
|
||||
sub.ChatServer(
|
||||
"You are not allowed to block a chat operator.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
sub.muteMu.Lock()
|
||||
sub.blocked[msg.Username] = struct{}{}
|
||||
sub.muteMu.Unlock()
|
||||
|
||||
// Send the Who List so the blocker/blockee can disappear from each other's list.
|
||||
s.SendWhoList()
|
||||
}
|
||||
|
||||
// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website.
|
||||
func (s *Server) OnBlocklist(sub *Subscriber, msg messages.Message) {
|
||||
log.Info("%s syncs their blocklist: %s", sub.Username, msg.Usernames)
|
||||
log.Info("[%s] syncs their blocklist: %s", sub.Username, msg.Usernames)
|
||||
|
||||
sub.muteMu.Lock()
|
||||
for _, username := range msg.Usernames {
|
||||
sub.muted[username] = struct{}{}
|
||||
sub.blocked[username] = struct{}{}
|
||||
}
|
||||
|
||||
sub.muteMu.Unlock()
|
||||
|
@ -439,8 +647,14 @@ 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{
|
||||
if _, err := PostWebhook(WebhookReport, WebhookRequest{
|
||||
Action: WebhookReport,
|
||||
APIKey: config.Current.AdminAPIKey,
|
||||
Report: WebhookRequestReport{
|
||||
|
@ -464,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
|
||||
}
|
||||
|
||||
|
@ -480,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
|
||||
}
|
||||
|
||||
|
@ -496,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
|
||||
}
|
||||
|
||||
|
@ -511,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
|
||||
}
|
||||
|
||||
|
|
|
@ -14,11 +14,13 @@ import (
|
|||
type Claims struct {
|
||||
// Custom claims.
|
||||
IsAdmin bool `json:"op,omitempty"`
|
||||
VIP bool `json:"vip,omitempty"`
|
||||
Avatar string `json:"img,omitempty"`
|
||||
ProfileURL string `json:"url,omitempty"`
|
||||
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
65
pkg/jwt/rules.go
Normal 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
|
||||
}
|
156
pkg/logging.go
Normal file
156
pkg/logging.go
Normal file
|
@ -0,0 +1,156 @@
|
|||
package barertc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/config"
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||
)
|
||||
|
||||
// IsLoggingUsername checks whether the app is currently configured to log a user's DMs.
|
||||
func IsLoggingUsername(sub *Subscriber) bool {
|
||||
if !config.Current.Logging.Enabled || sub == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Has a cached setting and writer.
|
||||
if sub.log {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check the server config.
|
||||
for _, username := range config.Current.Logging.Usernames {
|
||||
if username == sub.Username {
|
||||
sub.log = true
|
||||
}
|
||||
}
|
||||
|
||||
return sub.log
|
||||
}
|
||||
|
||||
// IsLoggingChannel checks whether the app is currently logging a public channel.
|
||||
func IsLoggingChannel(channel string) bool {
|
||||
if !config.Current.Logging.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range config.Current.Logging.Channels {
|
||||
if value == channel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LogMessage appends to a user's conversation log.
|
||||
func LogMessage(sub *Subscriber, otherUsername, senderUsername string, msg messages.Message) {
|
||||
if sub == nil || !sub.log {
|
||||
return
|
||||
}
|
||||
|
||||
// Create or get the filehandle.
|
||||
fh, err := initLogFile(sub, "@"+sub.Username, otherUsername)
|
||||
if err != nil {
|
||||
log.Error("LogMessage(%s): %s", sub.Username, err)
|
||||
return
|
||||
}
|
||||
|
||||
fh.Write(
|
||||
[]byte(fmt.Sprintf(
|
||||
"%s [%s] %s\n",
|
||||
time.Now().Format(time.RFC3339),
|
||||
senderUsername,
|
||||
msg.Message,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
// LogChannel appends to a channel's conversation log.
|
||||
func LogChannel(s *Server, channel string, username string, msg messages.Message) {
|
||||
fh, err := initLogFile(s, channel)
|
||||
if err != nil {
|
||||
log.Error("LogChannel(%s): %s", channel, err)
|
||||
}
|
||||
|
||||
fh.Write(
|
||||
[]byte(fmt.Sprintf(
|
||||
"%s [%s] %s\n",
|
||||
time.Now().Format(time.RFC3339),
|
||||
username,
|
||||
msg.Message,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
// Tear down log files for subscribers.
|
||||
func (s *Subscriber) teardownLogs() {
|
||||
if s.logfh == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for username, fh := range s.logfh {
|
||||
log.Error("TeardownLogs(%s/%s)", s.Username, username)
|
||||
fh.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize a logging directory.
|
||||
func initLogFile(sub LogCacheable, components ...string) (io.WriteCloser, error) {
|
||||
// Initialize the logfh cache?
|
||||
var logfh = sub.GetLogFilehandleCache()
|
||||
|
||||
var (
|
||||
suffix = components[len(components)-1]
|
||||
middle = components[:len(components)-1]
|
||||
paths = append([]string{
|
||||
config.Current.Logging.Directory,
|
||||
}, middle...,
|
||||
)
|
||||
filename = strings.Join(
|
||||
append(paths, suffix+".txt"),
|
||||
"/",
|
||||
)
|
||||
)
|
||||
|
||||
// Already have this conversation log open?
|
||||
if fh, ok := logfh[suffix]; ok {
|
||||
return fh, nil
|
||||
}
|
||||
|
||||
log.Warn("Initialize log directory: path=%+v suffix=%s", paths, suffix)
|
||||
if err := os.MkdirAll(strings.Join(paths, "/"), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fh, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logfh[suffix] = fh
|
||||
return logfh[suffix], nil
|
||||
}
|
||||
|
||||
// Interface for objects that hold log filehandle caches.
|
||||
type LogCacheable interface {
|
||||
GetLogFilehandleCache() map[string]io.WriteCloser
|
||||
}
|
||||
|
||||
// Implementations of LogCacheable.
|
||||
func (sub *Subscriber) GetLogFilehandleCache() map[string]io.WriteCloser {
|
||||
if sub.logfh == nil {
|
||||
sub.logfh = map[string]io.WriteCloser{}
|
||||
}
|
||||
return sub.logfh
|
||||
}
|
||||
func (s *Server) GetLogFilehandleCache() map[string]io.WriteCloser {
|
||||
if s.logfh == nil {
|
||||
s.logfh = map[string]io.WriteCloser{}
|
||||
}
|
||||
return s.logfh
|
||||
}
|
173
pkg/message_filters.go
Normal file
173
pkg/message_filters.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
package barertc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/config"
|
||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||
)
|
||||
|
||||
// Functionality for handling server-side message filtering and reporting.
|
||||
|
||||
// filterMessage will check an incoming user message against the configured
|
||||
// server-side filters and react accordingly. This function also is
|
||||
// responsible for collecting the recent contexts (10 messages per channel).
|
||||
//
|
||||
// Parameters: the rawMsg is their (pre-Markdown-formatted) original message
|
||||
// (for the message context); the msg pointer is their post-formatted one, which
|
||||
// may be modified to censor their word before returning.
|
||||
//
|
||||
// Returns the matching message filter (or nil) and a boolean (matched).
|
||||
func (s *Server) filterMessage(sub *Subscriber, rawMsg messages.Message, msg *messages.Message) (*config.MessageFilter, bool) {
|
||||
// Collect the recent channel context first.
|
||||
if strings.HasPrefix(msg.Channel, "@") {
|
||||
// DM
|
||||
pushDirectMessageContext(sub, sub.Username, msg.Channel[1:], rawMsg)
|
||||
|
||||
// If either party is an admin user, waive filtering this DM chat.
|
||||
if sub.IsAdmin() {
|
||||
return nil, false
|
||||
} else if other, err := s.GetSubscriber(msg.Channel[1:]); err == nil && other.IsAdmin() {
|
||||
return nil, false
|
||||
}
|
||||
} else {
|
||||
// Public channel
|
||||
pushMessageContext(sub, msg.Channel, rawMsg)
|
||||
}
|
||||
|
||||
// Check it against the configured filters.
|
||||
var matched bool
|
||||
for _, filter := range config.Current.MessageFilters {
|
||||
if !filter.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, phrase := range filter.IterPhrases() {
|
||||
m := phrase.FindAllStringSubmatch(msg.Message, -1)
|
||||
for _, match := range m {
|
||||
// Found a match!
|
||||
matched = true
|
||||
|
||||
// Censor it?
|
||||
if filter.CensorMessage {
|
||||
msg.Message = strings.ReplaceAll(msg.Message, match[0], strings.Repeat("*", len(match[0])))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
return filter, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Report the filtered message along with recent context.
|
||||
func (s *Server) reportFilteredMessage(sub *Subscriber, msg messages.Message) error {
|
||||
if !WebhookEnabled(WebhookReport) {
|
||||
return errors.New("report webhook is not enabled on this server")
|
||||
}
|
||||
|
||||
// Prepare the report.
|
||||
var context string
|
||||
if strings.HasPrefix(msg.Channel, "@") {
|
||||
context = getDirectMessageContext(sub.Username, msg.Channel[1:])
|
||||
} else {
|
||||
context = getMessageContext(msg.Channel)
|
||||
}
|
||||
|
||||
if _, err := PostWebhook(WebhookReport, WebhookRequest{
|
||||
Action: WebhookReport,
|
||||
APIKey: config.Current.AdminAPIKey,
|
||||
Report: WebhookRequestReport{
|
||||
FromUsername: sub.Username,
|
||||
AboutUsername: sub.Username,
|
||||
Channel: msg.Channel,
|
||||
Timestamp: time.Now().Format(time.RFC1123),
|
||||
Reason: "Server Side Message Filter",
|
||||
Message: msg.Message,
|
||||
Comment: fmt.Sprintf(
|
||||
"This is an automated report via server side chat filters.\n\n"+
|
||||
"The recent context in this channel included the following conversation:\n\n"+
|
||||
"%s",
|
||||
context,
|
||||
),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Message Context Caching
|
||||
//
|
||||
// Hold the recent (10) messages for each channel so in case of automated
|
||||
// reporting, the context can be delivered in the report.
|
||||
var (
|
||||
messageContexts = map[string][]string{}
|
||||
messageContextMu sync.RWMutex
|
||||
messageContextSize = 30
|
||||
)
|
||||
|
||||
// Push a message onto the recent messages context.
|
||||
func pushMessageContext(sub *Subscriber, channel string, msg messages.Message) {
|
||||
messageContextMu.Lock()
|
||||
defer messageContextMu.Unlock()
|
||||
|
||||
// Initialize the context for new channel the first time.
|
||||
if _, ok := messageContexts[channel]; !ok {
|
||||
messageContexts[channel] = []string{}
|
||||
}
|
||||
|
||||
// Append this message to it.
|
||||
messageContexts[channel] = append(messageContexts[channel], fmt.Sprintf(
|
||||
"%s [%s] %s",
|
||||
time.Now().Format("2006-01-02 15:04:05"),
|
||||
sub.Username,
|
||||
strings.TrimSpace(msg.Message),
|
||||
))
|
||||
|
||||
// Trim the context to recent messages only.
|
||||
if len(messageContexts[channel]) > messageContextSize {
|
||||
messageContexts[channel] = messageContexts[channel][len(messageContexts[channel])-messageContextSize:]
|
||||
}
|
||||
}
|
||||
|
||||
// Push a message context for DMs. A channel name will be derived consistently
|
||||
// based on the sorted pair of usernames.
|
||||
func pushDirectMessageContext(sub *Subscriber, username1, username2 string, msg messages.Message) {
|
||||
var names = []string{username1, username2}
|
||||
sort.Strings(names)
|
||||
pushMessageContext(
|
||||
sub,
|
||||
fmt.Sprintf("@%s", strings.Join(names, ":")),
|
||||
msg,
|
||||
)
|
||||
}
|
||||
|
||||
// Get the recent message context, pretty printed.
|
||||
func getMessageContext(channel string) string {
|
||||
messageContextMu.RLock()
|
||||
defer messageContextMu.RUnlock()
|
||||
|
||||
if _, ok := messageContexts[channel]; !ok {
|
||||
return "(No recent message history in this channel)"
|
||||
}
|
||||
|
||||
return strings.Join(messageContexts[channel], "\n\n")
|
||||
}
|
||||
|
||||
func getDirectMessageContext(username1, username2 string) string {
|
||||
var names = []string{username1, username2}
|
||||
sort.Strings(names)
|
||||
return getMessageContext(
|
||||
fmt.Sprintf("@%s", strings.Join(names, ":")),
|
||||
)
|
||||
}
|
|
@ -1,15 +1,18 @@
|
|||
package messages
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Auto incrementing Message ID for anything pushed out by the server.
|
||||
var (
|
||||
messageID int
|
||||
messageID = time.Now().Unix()
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// NextMessageID atomically increments and returns a new MessageID.
|
||||
func NextMessageID() int {
|
||||
func NextMessageID() int64 {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
messageID++
|
||||
|
@ -42,7 +45,7 @@ type Message struct {
|
|||
DND bool `json:"dnd,omitempty"` // Do Not Disturb, e.g. DMs are closed
|
||||
|
||||
// Message ID to support takebacks/local deletions
|
||||
MessageID int `json:"msgID,omitempty"`
|
||||
MessageID int64 `json:"msgID,omitempty"`
|
||||
|
||||
// Sent on `open` actions along with the (other) Username.
|
||||
OpenSecret string `json:"openSecret,omitempty"`
|
||||
|
@ -68,8 +71,10 @@ const (
|
|||
// Actions sent by the client side only
|
||||
ActionLogin = "login" // post the username to backend
|
||||
ActionBoot = "boot" // boot a user off your video feed
|
||||
ActionUnboot = "unboot" // unboot a user
|
||||
ActionMute = "mute" // mute a user's chat messages
|
||||
ActionUnmute = "unmute"
|
||||
ActionBlock = "block" // hard block another user
|
||||
ActionBlocklist = "blocklist" // mute in bulk for usernames
|
||||
ActionReport = "report" // user reports a message
|
||||
|
||||
|
@ -83,11 +88,13 @@ const (
|
|||
ActionFile = "file" // image sharing in chat
|
||||
ActionTakeback = "takeback" // user takes back (deletes) their message for everybody
|
||||
ActionReact = "react" // emoji reaction to a chat message
|
||||
ActionTyping = "typing" // typing indicator for DM threads
|
||||
|
||||
// Actions sent by server only
|
||||
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).
|
||||
|
||||
|
@ -107,6 +114,7 @@ type WhoList struct {
|
|||
|
||||
// JWT auth extra settings.
|
||||
Operator bool `json:"op"`
|
||||
VIP bool `json:"vip,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
ProfileURL string `json:"profileURL,omitempty"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
|
@ -119,7 +127,17 @@ const (
|
|||
VideoFlagActive int = 1 << iota // user's camera is enabled/broadcasting
|
||||
VideoFlagNSFW // viewer's camera is marked as NSFW
|
||||
VideoFlagMuted // user source microphone is muted
|
||||
VideoFlagIsTalking // broadcaster seems to be talking
|
||||
VideoFlagNonExplicit // viewer prefers not to see NSFW cameras (don't auto-open red cams/auto-close blue cams going red)
|
||||
VideoFlagMutualRequired // video wants viewers to share their camera too
|
||||
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!"
|
||||
)
|
||||
|
|
231
pkg/messages/messages_test.go
Normal file
231
pkg/messages/messages_test.go
Normal file
|
@ -0,0 +1,231 @@
|
|||
package messages_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||
)
|
||||
|
||||
// Boolean representation of the video flags, for testing purposes.
|
||||
type flags struct {
|
||||
Active bool
|
||||
NSFW bool
|
||||
Muted bool
|
||||
IsTalking bool
|
||||
MutualRequired bool
|
||||
MutualOpen bool
|
||||
OnlyVIP bool
|
||||
}
|
||||
|
||||
// Check a video flag integer against the expected bools set on the flags object.
|
||||
func (f flags) Check(video int) error {
|
||||
if video&messages.VideoFlagActive == messages.VideoFlagActive && !f.Active {
|
||||
return errors.New("Active expected to be set")
|
||||
} else if video&messages.VideoFlagActive != messages.VideoFlagActive && f.Active {
|
||||
return errors.New("Active expected NOT to be set")
|
||||
}
|
||||
|
||||
if video&messages.VideoFlagNSFW == messages.VideoFlagNSFW && !f.NSFW {
|
||||
return errors.New("NSFW expected to be set")
|
||||
} else if video&messages.VideoFlagNSFW != messages.VideoFlagNSFW && f.NSFW {
|
||||
return errors.New("NSFW expected NOT to be set")
|
||||
}
|
||||
|
||||
if video&messages.VideoFlagMuted == messages.VideoFlagMuted && !f.Muted {
|
||||
return errors.New("Muted expected to be set")
|
||||
} else if video&messages.VideoFlagMuted != messages.VideoFlagMuted && f.Muted {
|
||||
return errors.New("Muted 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 {
|
||||
return errors.New("MutualRequired expected NOT to be set")
|
||||
}
|
||||
|
||||
if video&messages.VideoFlagMutualOpen == messages.VideoFlagMutualOpen && !f.MutualOpen {
|
||||
return errors.New("MutualOpen expected to be set")
|
||||
} else if video&messages.VideoFlagMutualOpen != messages.VideoFlagMutualOpen && f.MutualOpen {
|
||||
return errors.New("MutualOpen expected NOT to be set")
|
||||
}
|
||||
|
||||
if video&messages.VideoFlagOnlyVIP == messages.VideoFlagOnlyVIP && !f.OnlyVIP {
|
||||
return errors.New("OnlyVIP expected to be set")
|
||||
} else if video&messages.VideoFlagOnlyVIP != messages.VideoFlagOnlyVIP && f.OnlyVIP {
|
||||
return errors.New("OnlyVIP expected NOT to be set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestVideoFlag(t *testing.T) {
|
||||
type schema struct {
|
||||
Flag int
|
||||
Expect flags
|
||||
}
|
||||
|
||||
// Tests to run
|
||||
var tests = []schema{
|
||||
{
|
||||
Flag: 0,
|
||||
Expect: flags{},
|
||||
},
|
||||
{
|
||||
Flag: 1,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 2,
|
||||
Expect: flags{
|
||||
NSFW: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 3,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
NSFW: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 4,
|
||||
Expect: flags{
|
||||
Muted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 5,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
Muted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 6,
|
||||
Expect: flags{
|
||||
NSFW: true,
|
||||
Muted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 7,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
NSFW: true,
|
||||
Muted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: messages.VideoFlagActive | messages.VideoFlagMuted | messages.VideoFlagMutualRequired | messages.VideoFlagMutualOpen,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
Muted: true,
|
||||
MutualRequired: true,
|
||||
MutualOpen: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: messages.VideoFlagActive | messages.VideoFlagOnlyVIP | messages.VideoFlagMutualOpen,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
OnlyVIP: true,
|
||||
MutualOpen: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 32,
|
||||
Expect: flags{
|
||||
MutualOpen: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Flag: 49,
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
MutualRequired: true,
|
||||
MutualOpen: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
if err := tc.Expect.Check(tc.Flag); err != nil {
|
||||
t.Errorf("Test #%d: video flag %d failed check: %s", i, tc.Flag, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoFlagMutation(t *testing.T) {
|
||||
// Test bitwise mutations of the video flag.
|
||||
var flag int
|
||||
var tests = []struct {
|
||||
Mutate func(int) int
|
||||
Expect flags
|
||||
}{
|
||||
{
|
||||
Mutate: func(v int) int { return 1 },
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Mutate: func(v int) int {
|
||||
return v | messages.VideoFlagMutualOpen
|
||||
},
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
MutualOpen: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Mutate: func(v int) int {
|
||||
return v | messages.VideoFlagMutualRequired
|
||||
},
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
MutualOpen: true,
|
||||
MutualRequired: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Mutate: func(v int) int {
|
||||
return v | messages.VideoFlagMuted ^ messages.VideoFlagMutualRequired
|
||||
},
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
MutualOpen: true,
|
||||
Muted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Mutate: func(v int) int {
|
||||
return v ^ messages.VideoFlagMutualOpen
|
||||
},
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
Muted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Mutate: func(v int) int {
|
||||
return v | messages.VideoFlagOnlyVIP | messages.VideoFlagNSFW
|
||||
},
|
||||
Expect: flags{
|
||||
Active: true,
|
||||
Muted: true,
|
||||
OnlyVIP: true,
|
||||
NSFW: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
flag = tc.Mutate(flag)
|
||||
if err := tc.Expect.Check(flag); err != nil {
|
||||
t.Errorf("Test #%d: video flag %d failed check: %s", i, flag, err)
|
||||
}
|
||||
}
|
||||
}
|
29
pkg/models/database.go
Normal file
29
pkg/models/database.go
Normal 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
|
||||
}
|
235
pkg/models/direct_messages.go
Normal file
235
pkg/models/direct_messages.go
Normal 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
39
pkg/moderation_rules.go
Normal 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
|
||||
}
|
16
pkg/pages.go
16
pkg/pages.go
|
@ -81,7 +81,7 @@ func IndexPage() http.HandlerFunc {
|
|||
return template.JS(fmt.Sprintf("%v", v))
|
||||
},
|
||||
})
|
||||
tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
|
||||
tmpl, err := tmpl.ParseFiles("dist/index.html")
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
@ -110,6 +110,7 @@ func AboutPage() http.HandlerFunc {
|
|||
|
||||
// The current website settings.
|
||||
"Config": config.Current,
|
||||
"Hostname": r.Host,
|
||||
}
|
||||
|
||||
tmpl.Funcs(template.FuncMap{
|
||||
|
@ -125,3 +126,16 @@ func AboutPage() http.HandlerFunc {
|
|||
tmpl.ExecuteTemplate(w, "index", values)
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutPage returns the HTML template for the logout page.
|
||||
func LogoutPage() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Load the template, TODO: once on server startup.
|
||||
tmpl := template.New("index")
|
||||
tmpl, err := tmpl.ParseFiles("web/templates/logout.html")
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "index", nil)
|
||||
})
|
||||
}
|
||||
|
|
240
pkg/polling_api.go
Normal file
240
pkg/polling_api.go
Normal file
|
@ -0,0 +1,240 @@
|
|||
package barertc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||
"git.kirsle.net/apps/barertc/pkg/util"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Polling user timeout before disconnecting them.
|
||||
const PollingUserTimeout = time.Minute
|
||||
|
||||
// JSON payload structure for polling API.
|
||||
type PollMessage struct {
|
||||
// Send the username after authenticated.
|
||||
Username string `json:"username,omitempty"`
|
||||
|
||||
// SessionID for authentication.
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
|
||||
// BareRTC protocol message.
|
||||
Message messages.Message `json:"msg"`
|
||||
}
|
||||
|
||||
type PollResponse struct {
|
||||
// Session ID.
|
||||
Username string `json:"username,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
|
||||
// Pending messages.
|
||||
Messages []messages.Message `json:"messages"`
|
||||
}
|
||||
|
||||
// Helper method to send an error as a PollResponse.
|
||||
func PollResponseError(message string) PollResponse {
|
||||
return PollResponse{
|
||||
Messages: []messages.Message{
|
||||
{
|
||||
Action: messages.ActionError,
|
||||
Username: "ChatServer",
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// KickIdlePollUsers is a goroutine that will disconnect polling API users
|
||||
// who haven't been seen in a while.
|
||||
func (s *Server) KickIdlePollUsers() {
|
||||
log.Debug("KickIdlePollUsers goroutine engaged")
|
||||
for {
|
||||
time.Sleep(10 * time.Second)
|
||||
for _, sub := range s.IterSubscribers() {
|
||||
if sub.usePolling && time.Since(sub.lastPollAt) > PollingUserTimeout {
|
||||
// Send an exit message.
|
||||
if sub.authenticated && sub.ChatStatus != "hidden" {
|
||||
log.Error("KickIdlePollUsers: %s last seen %s ago", sub.Username, sub.lastPollAt)
|
||||
|
||||
sub.authenticated = false
|
||||
s.Broadcast(messages.Message{
|
||||
Action: messages.ActionPresence,
|
||||
Username: sub.Username,
|
||||
Message: messages.PresenceTimedOut,
|
||||
})
|
||||
s.SendWhoList()
|
||||
}
|
||||
|
||||
s.DeleteSubscriber(sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FlushPollResponse returns a response for the polling API that will flush
|
||||
// all pending messages sent to the client.
|
||||
func (sub *Subscriber) FlushPollResponse() PollResponse {
|
||||
var msgs = []messages.Message{}
|
||||
|
||||
// Drain the messages from the outbox channel.
|
||||
for len(sub.messages) > 0 {
|
||||
message := <-sub.messages
|
||||
var msg messages.Message
|
||||
json.Unmarshal(message, &msg)
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
|
||||
return PollResponse{
|
||||
Username: sub.Username,
|
||||
SessionID: sub.sessionID,
|
||||
Messages: msgs,
|
||||
}
|
||||
}
|
||||
|
||||
// Functions for the Polling API as an alternative to WebSockets.
|
||||
func (s *Server) PollingAPI() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := util.IPAddress(r)
|
||||
|
||||
// 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(PollResponseError("Only POST methods allowed"))
|
||||
return
|
||||
} else if r.Header.Get("Content-Type") != "application/json" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
enc.Encode(PollResponseError("Only application/json content-types allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
// Parse the request payload.
|
||||
var (
|
||||
params PollMessage
|
||||
dec = json.NewDecoder(r.Body)
|
||||
)
|
||||
if err := dec.Decode(¶ms); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
enc.Encode(PollResponseError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Debug logging.
|
||||
log.Debug("Polling connection from %s - %s", ip, r.Header.Get("User-Agent"))
|
||||
|
||||
// Are they resuming an authenticated session?
|
||||
var sub *Subscriber
|
||||
if params.Username != "" || params.SessionID != "" {
|
||||
if params.Username == "" || params.SessionID == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
enc.Encode(PollResponseError("Authentication error: SessionID and Username both required."))
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("Polling API: check if %s (%s) is authenticated", params.Username, params.SessionID)
|
||||
|
||||
// Look up the subscriber.
|
||||
var (
|
||||
authOK bool
|
||||
err error
|
||||
)
|
||||
sub, err = s.GetSubscriber(params.Username)
|
||||
if err == nil {
|
||||
// Validate the SessionID.
|
||||
if sub.sessionID == params.SessionID {
|
||||
authOK = true
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication error.
|
||||
if !authOK {
|
||||
s.DeleteSubscriber(sub)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
enc.Encode(PollResponse{
|
||||
Messages: []messages.Message{
|
||||
{
|
||||
Action: messages.ActionError,
|
||||
Username: "ChatServer",
|
||||
Message: "Your authentication has expired, please log back into the chat again.",
|
||||
},
|
||||
{
|
||||
Action: messages.ActionKick,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Ping their last seen time.
|
||||
sub.lastPollAt = time.Now()
|
||||
}
|
||||
|
||||
// If they are authenticated, handle this message.
|
||||
if sub != nil && sub.authenticated {
|
||||
s.OnClientMessage(sub, params.Message)
|
||||
|
||||
// If they use JWT authentication, give them a ping back with an updated
|
||||
// JWT once in a while. Equivalent to the WebSockets pinger channel.
|
||||
if time.Since(sub.lastPollJWT) > PingInterval {
|
||||
sub.lastPollJWT = time.Now()
|
||||
|
||||
if sub.JWTClaims != nil {
|
||||
if jwt, err := sub.JWTClaims.ReSign(); err != nil {
|
||||
log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err)
|
||||
} else {
|
||||
sub.SendJSON(messages.Message{
|
||||
Action: messages.ActionPing,
|
||||
JWTToken: jwt,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enc.Encode(sub.FlushPollResponse())
|
||||
return
|
||||
}
|
||||
|
||||
// Not authenticated: the only acceptable message is login.
|
||||
if params.Message.Action != messages.ActionLogin {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
enc.Encode(PollResponseError("Not logged in."))
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare a Subscriber object for them. Do not add it to the server
|
||||
// roster unless their login succeeds.
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
sub = s.NewPollingSubscriber(ctx, cancel)
|
||||
|
||||
// Tentatively add them to the server. If they don't pass authentication,
|
||||
// remove their subscriber immediately. Note: they need added here so they
|
||||
// will receive their own "has entered the room" and WhoList updates.
|
||||
s.AddSubscriber(sub)
|
||||
|
||||
s.OnLogin(sub, params.Message)
|
||||
|
||||
// Are they authenticated?
|
||||
if sub.authenticated {
|
||||
// Generate a SessionID number.
|
||||
sessionID := uuid.New().String()
|
||||
sub.sessionID = sessionID
|
||||
|
||||
log.Debug("Polling API: new user authenticated in: %s (sid %s)", sub.Username, sub.sessionID)
|
||||
} else {
|
||||
s.DeleteSubscriber(sub)
|
||||
}
|
||||
|
||||
enc.Encode(sub.FlushPollResponse())
|
||||
})
|
||||
}
|
|
@ -1,8 +1,13 @@
|
|||
package barertc
|
||||
|
||||
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
|
||||
|
@ -16,28 +21,46 @@ type Server struct {
|
|||
// Connected WebSocket subscribers.
|
||||
subscribersMu sync.RWMutex
|
||||
subscribers map[*Subscriber]struct{}
|
||||
|
||||
// Cached filehandles for channel logging.
|
||||
logfh map[string]io.WriteCloser
|
||||
}
|
||||
|
||||
// NewServer initializes the Server.
|
||||
func NewServer() *Server {
|
||||
return &Server{
|
||||
subscriberMessageBuffer: 16,
|
||||
subscriberMessageBuffer: 32,
|
||||
subscribers: make(map[*Subscriber]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
mux.Handle("/about", AboutPage())
|
||||
mux.Handle("/logout", LogoutPage())
|
||||
mux.Handle("/ws", s.WebSocket())
|
||||
mux.Handle("/poll", s.PollingAPI())
|
||||
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("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
|
||||
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"))))
|
||||
|
||||
s.mux = mux
|
||||
|
||||
|
@ -46,5 +69,7 @@ func (s *Server) Setup() error {
|
|||
|
||||
// ListenAndServe starts the web server.
|
||||
func (s *Server) ListenAndServe(address string) error {
|
||||
// Run the polling user idle kicker.
|
||||
go s.KickIdlePollUsers()
|
||||
return http.ListenAndServe(address, s.mux)
|
||||
}
|
||||
|
|
565
pkg/subscriber.go
Normal file
565
pkg/subscriber.go
Normal 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
|
||||
}
|
|
@ -39,18 +39,20 @@ func GetWebhook(name string) (config.WebhookURL, bool) {
|
|||
}
|
||||
|
||||
// PostWebhook submits a JSON body to one of the app's configured webhooks.
|
||||
func PostWebhook(name string, payload any) error {
|
||||
//
|
||||
// Returns the bytes of the response body (hopefully, JSON data) and any errors.
|
||||
func PostWebhook(name string, payload any) ([]byte, error) {
|
||||
webhook, ok := GetWebhook(name)
|
||||
if !ok {
|
||||
return errors.New("PostWebhook(%s): webhook name %s is not configured")
|
||||
return nil, errors.New("PostWebhook(%s): webhook name %s is not configured")
|
||||
} else if !webhook.Enabled {
|
||||
return errors.New("PostWebhook(%s): webhook is not enabled")
|
||||
return nil, errors.New("PostWebhook(%s): webhook is not enabled")
|
||||
}
|
||||
|
||||
// JSON request body.
|
||||
jsonStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make the API request to BareRTC.
|
||||
|
@ -58,7 +60,7 @@ func PostWebhook(name string, payload any) error {
|
|||
log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
|
@ -67,15 +69,15 @@ func PostWebhook(name string, payload any) error {
|
|||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Error("PostWebhook(%s): unexpected response from webhook URL %s (code %d): %s", name, url, resp.StatusCode, body)
|
||||
return errors.New("unexpected error from webhook URL")
|
||||
return body, errors.New("unexpected error from webhook URL")
|
||||
}
|
||||
|
||||
return nil
|
||||
return body, nil
|
||||
}
|
||||
|
|
370
pkg/websocket.go
370
pkg/websocket.go
|
@ -2,158 +2,17 @@ package barertc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
messages chan []byte
|
||||
closeSlow func()
|
||||
|
||||
muteMu sync.RWMutex
|
||||
booted map[string]struct{} // usernames booted off your camera
|
||||
muted map[string]struct{} // usernames you muted
|
||||
|
||||
// Record which message IDs belong to this user.
|
||||
midMu sync.Mutex
|
||||
messageIDs map[int]struct{}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
case messages.ActionMute, messages.ActionUnmute:
|
||||
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
|
||||
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)
|
||||
default:
|
||||
sub.ChatServer("Unsupported message type.")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// IsAdmin safely checks if the subscriber is an admin.
|
||||
func (sub *Subscriber) IsAdmin() bool {
|
||||
return sub.JWTClaims != nil && sub.JWTClaims.IsAdmin
|
||||
}
|
||||
|
||||
// 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)
|
||||
return sub.conn.Write(sub.ctx, websocket.MessageText, data)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
@ -178,19 +37,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
|||
// ctx := c.CloseRead(r.Context())
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
|
||||
sub := &Subscriber{
|
||||
conn: c,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
messages: make(chan []byte, s.subscriberMessageBuffer),
|
||||
closeSlow: func() {
|
||||
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
|
||||
},
|
||||
booted: make(map[string]struct{}),
|
||||
muted: make(map[string]struct{}),
|
||||
messageIDs: make(map[int]struct{}),
|
||||
ChatStatus: "online",
|
||||
}
|
||||
sub := s.NewWebSocketSubscriber(ctx, c, cancel)
|
||||
|
||||
s.AddSubscriber(sub)
|
||||
defer s.DeleteSubscriber(sub)
|
||||
|
@ -200,7 +47,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
|||
for {
|
||||
select {
|
||||
case msg := <-sub.messages:
|
||||
err = writeTimeout(ctx, time.Second*5, c, msg)
|
||||
err = writeTimeout(ctx, time.Second*time.Duration(config.Current.WebSocketSendTimeout), c, msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -209,7 +56,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
|||
var token string
|
||||
if sub.JWTClaims != nil {
|
||||
if jwt, err := sub.JWTClaims.ReSign(); err != nil {
|
||||
log.Error("ReSign JWT token for %s: %s", sub.Username, err)
|
||||
log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err)
|
||||
} else {
|
||||
token = jwt
|
||||
}
|
||||
|
@ -228,217 +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) {
|
||||
log.Error("DeleteSubscriber: %s", sub.Username)
|
||||
|
||||
// Cancel its context to clean up the for-loop goroutine.
|
||||
if sub.cancel != nil {
|
||||
sub.cancel()
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
who := messages.WhoList{
|
||||
Username: user.Username,
|
||||
Status: user.ChatStatus,
|
||||
Video: user.VideoStatus,
|
||||
DND: user.DND,
|
||||
LoginAt: user.loginAt.Unix(),
|
||||
}
|
||||
|
||||
// If this person had booted us, force their camera to "off"
|
||||
if (user.Boots(sub.Username) || user.Mutes(sub.Username)) && !sub.IsAdmin() {
|
||||
who.Video = 0
|
||||
}
|
||||
|
||||
if 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
54
public/static/css/bulma-dark-theme.css
Normal file
54
public/static/css/bulma-dark-theme.css
Normal 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%;
|
||||
}
|
3
public/static/css/bulma-no-dark-mode.min.css
vendored
Normal file
3
public/static/css/bulma-no-dark-mode.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/static/css/bulma-no-dark-mode.min.css.map
Normal file
1
public/static/css/bulma-no-dark-mode.min.css.map
Normal file
File diff suppressed because one or more lines are too long
22437
public/static/css/bulma.css
vendored
Normal file
22437
public/static/css/bulma.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
public/static/css/bulma.css.map
Normal file
1
public/static/css/bulma.css.map
Normal file
File diff suppressed because one or more lines are too long
3
public/static/css/bulma.min.css
vendored
Normal file
3
public/static/css/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
55
public/static/css/chat-dark.css
Normal file
55
public/static/css/chat-dark.css
Normal 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;
|
||||
}
|
3
public/static/css/chat-prefers-dark.css
Normal file
3
public/static/css/chat-prefers-dark.css
Normal 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);
|
|
@ -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,10 +29,17 @@ body {
|
|||
|
||||
/* DM title and bg color */
|
||||
.has-background-private {
|
||||
background-color: #b748c7;
|
||||
background-color: #b748c7 !important;
|
||||
}
|
||||
.has-background-dm {
|
||||
background-color: #ffefff;
|
||||
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 */
|
||||
|
@ -72,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;
|
||||
|
@ -107,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;
|
||||
|
@ -122,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) {
|
||||
|
@ -221,20 +241,25 @@ body {
|
|||
width: 168px;
|
||||
height: 112px;
|
||||
background-color: black;
|
||||
border: 1px solid black;
|
||||
margin: 3px;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* A speaking webcam */
|
||||
.feed.is-speaking {
|
||||
border: 1px solid #09F !important;
|
||||
}
|
||||
|
||||
/* A popped-out video feed window */
|
||||
div.feed.popped-out {
|
||||
position: absolute;
|
||||
border: 1px solid #FFF;
|
||||
cursor: move;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
resize: none;
|
||||
z-index: 1; /* work around Safari video being on top when return from fullscreen */
|
||||
}
|
||||
|
||||
.video-feeds.x1 > .feed {
|
||||
|
@ -270,12 +295,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 {
|
||||
|
@ -286,6 +313,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 */
|
||||
|
@ -336,3 +364,8 @@ div.feed.popped-out {
|
|||
.has-text-gender-other {
|
||||
color: #cc00cc !important;
|
||||
}
|
||||
|
||||
/* VIP colors for profile icon */
|
||||
.has-background-vip {
|
||||
background-image: linear-gradient(141deg, #d1e1ff 0, #ffddff 100%)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user