Compare commits
121 Commits
Author | SHA1 | Date | |
---|---|---|---|
7a25ee3091 | |||
0e46d6e679 | |||
9f64c6907b | |||
1b43948a9e | |||
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 |
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.
|
33
Protocol.md
|
@ -40,7 +40,7 @@ VideoFlag: {
|
||||||
Active: 1 << 0, // or 00000001 in binary
|
Active: 1 << 0, // or 00000001 in binary
|
||||||
NSFW: 1 << 1, // or 00000010
|
NSFW: 1 << 1, // or 00000010
|
||||||
Muted: 1 << 2, // or 00000100, etc.
|
Muted: 1 << 2, // or 00000100, etc.
|
||||||
IsTalking: 1 << 3,
|
NonExplicit: 1 << 3,
|
||||||
MutualRequired: 1 << 4,
|
MutualRequired: 1 << 4,
|
||||||
MutualOpen: 1 << 5,
|
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
|
## Mute, Unmute
|
||||||
|
|
||||||
Sent by: Client.
|
Sent by: Client.
|
||||||
|
@ -345,7 +358,7 @@ The `unmute` action does the opposite and removes the mute status:
|
||||||
|
|
||||||
## Block
|
## Block
|
||||||
|
|
||||||
Sent by: Client.
|
Sent by: Client, Server.
|
||||||
|
|
||||||
The block command places a hard block between the current user and the target.
|
The block command places a hard block between the current user and the target.
|
||||||
|
|
||||||
|
@ -364,6 +377,10 @@ When either user blocks the other:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Blocklist
|
||||||
|
|
||||||
Sent by: Client.
|
Sent by: Client.
|
||||||
|
@ -384,7 +401,7 @@ The chat server holds onto blocklists temporarily in memory: when that user load
|
||||||
|
|
||||||
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.
|
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.
|
Sent by: Client.
|
||||||
|
|
||||||
|
@ -405,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.
|
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
|
## WebRTC Signaling
|
||||||
|
|
||||||
Sent by: Client, Server.
|
Sent by: Client, Server.
|
||||||
|
|
59
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.
|
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)
|
- [BareRTC](#barertc)
|
||||||
* [Configuration](#configuration)
|
- [Installation](#installation)
|
||||||
* [Authentication](#authentication)
|
- [Features](#features)
|
||||||
* [JWT Strict Mode](#jwt-strict-mode)
|
- [Configuration](#configuration)
|
||||||
* [Running Without Authentication](#running-without-authentication)
|
- [Authentication](#authentication)
|
||||||
* [Known Bugs Running Without Authentication](#known-bugs-running-without-authentication)
|
- [Moderator Commands](#moderator-commands)
|
||||||
* [Moderator Commands](#moderator-commands)
|
- [JSON APIs](#json-apis)
|
||||||
* [JSON APIs](#json-apis)
|
- [Webhook URLs](#webhook-urls)
|
||||||
* [Tour of the Codebase](#tour-of-the-codebase)
|
- [Chatbot](#chatbot)
|
||||||
* [Deploying This App](#deploying-this-app)
|
- [Tour of the Codebase](#tour-of-the-codebase)
|
||||||
* [License](#license)
|
- [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
|
# 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!
|
* WebRTC means peer-to-peer video streaming so cheap on hosting costs!
|
||||||
* Simple integration with your existing userbase via signed JWT tokens.
|
* 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.
|
* User configurable sound effects to be notified of DMs or users entering/exiting the room.
|
||||||
* Operator commands
|
* Operator commands to kick, ban users, mark cameras NSFW, etc.
|
||||||
* [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
|
|
||||||
|
|
||||||
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!
|
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:
|
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.
|
* `/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).
|
* `/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
|
# JSON APIs
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ type Client struct {
|
||||||
OnOpen HandlerFunc
|
OnOpen HandlerFunc
|
||||||
OnWatch HandlerFunc
|
OnWatch HandlerFunc
|
||||||
OnUnwatch HandlerFunc
|
OnUnwatch HandlerFunc
|
||||||
|
OnCut HandlerFunc
|
||||||
OnError HandlerFunc
|
OnError HandlerFunc
|
||||||
OnDisconnect HandlerFunc
|
OnDisconnect HandlerFunc
|
||||||
OnPing HandlerFunc
|
OnPing HandlerFunc
|
||||||
|
@ -129,6 +130,8 @@ func (c *Client) Run() error {
|
||||||
c.Handle(msg, c.OnWatch)
|
c.Handle(msg, c.OnWatch)
|
||||||
case messages.ActionUnwatch:
|
case messages.ActionUnwatch:
|
||||||
c.Handle(msg, c.OnUnwatch)
|
c.Handle(msg, c.OnUnwatch)
|
||||||
|
case messages.ActionCut:
|
||||||
|
c.Handle(msg, c.OnCut)
|
||||||
case messages.ActionError:
|
case messages.ActionError:
|
||||||
c.Handle(msg, c.OnError)
|
c.Handle(msg, c.OnError)
|
||||||
case messages.ActionKick:
|
case messages.ActionKick:
|
||||||
|
|
|
@ -31,11 +31,13 @@ func (h *BotHandlers) watchForDeadlock() {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
time.Sleep(15 * time.Second)
|
time.Sleep(15 * time.Second)
|
||||||
|
go func() {
|
||||||
h.client.Send(messages.Message{
|
h.client.Send(messages.Message{
|
||||||
Action: messages.ActionMessage,
|
Action: messages.ActionMessage,
|
||||||
Channel: "@" + h.client.Username(),
|
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?
|
// Has it been a while since our last ping?
|
||||||
if time.Since(h.deadlockLastOK) > deadlockTTL {
|
if time.Since(h.deadlockLastOK) > deadlockTTL {
|
||||||
|
@ -50,6 +52,7 @@ func (h *BotHandlers) watchForDeadlock() {
|
||||||
func (h *BotHandlers) onMessageFromSelf(msg messages.Message) {
|
func (h *BotHandlers) onMessageFromSelf(msg messages.Message) {
|
||||||
// If it is our own DM channel thread, it's for deadlock detection.
|
// If it is our own DM channel thread, it's for deadlock detection.
|
||||||
if msg.Channel == "@"+h.client.Username() {
|
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()
|
h.deadlockLastOK = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"git.kirsle.net/apps/barertc/pkg/log"
|
"git.kirsle.net/apps/barertc/pkg/log"
|
||||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||||
"github.com/aichaos/rivescript-go"
|
"github.com/aichaos/rivescript-go"
|
||||||
|
"github.com/aichaos/rivescript-go/lang/javascript"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -60,7 +61,7 @@ type BotHandlers struct {
|
||||||
|
|
||||||
// Store the reactions we have previously sent by messageID,
|
// Store the reactions we have previously sent by messageID,
|
||||||
// so we don't accidentally take back our own reactions.
|
// 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
|
reactionsMu sync.Mutex
|
||||||
|
|
||||||
// Deadlock detection (deadlock_watch.go): record time of last successful
|
// Deadlock detection (deadlock_watch.go): record time of last successful
|
||||||
|
@ -81,9 +82,15 @@ func (c *Client) SetupChatbot() error {
|
||||||
}),
|
}),
|
||||||
autoGreet: map[string]time.Time{},
|
autoGreet: map[string]time.Time{},
|
||||||
messageBuf: []messages.Message{},
|
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")
|
log.Info("Initializing RiveScript brain")
|
||||||
if err := handler.rs.LoadDirectory("./brain"); err != nil {
|
if err := handler.rs.LoadDirectory("./brain"); err != nil {
|
||||||
return fmt.Errorf("RiveScript LoadDirectory: %s", err)
|
return fmt.Errorf("RiveScript LoadDirectory: %s", err)
|
||||||
|
@ -92,9 +99,6 @@ func (c *Client) SetupChatbot() error {
|
||||||
return fmt.Errorf("RiveScript SortReplies: %s", err)
|
return fmt.Errorf("RiveScript SortReplies: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach RiveScript object macros.
|
|
||||||
handler.setObjectMacros()
|
|
||||||
|
|
||||||
// Set all the handler funcs.
|
// Set all the handler funcs.
|
||||||
c.OnWho = handler.OnWho
|
c.OnWho = handler.OnWho
|
||||||
c.OnMe = handler.OnMe
|
c.OnMe = handler.OnMe
|
||||||
|
@ -105,6 +109,7 @@ func (c *Client) SetupChatbot() error {
|
||||||
c.OnOpen = handler.OnOpen
|
c.OnOpen = handler.OnOpen
|
||||||
c.OnWatch = handler.OnWatch
|
c.OnWatch = handler.OnWatch
|
||||||
c.OnUnwatch = handler.OnUnwatch
|
c.OnUnwatch = handler.OnUnwatch
|
||||||
|
c.OnCut = handler.OnCut
|
||||||
c.OnError = handler.OnError
|
c.OnError = handler.OnError
|
||||||
c.OnDisconnect = handler.OnDisconnect
|
c.OnDisconnect = handler.OnDisconnect
|
||||||
c.OnPing = handler.OnPing
|
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)
|
log.Error("OnMe: the server has renamed us to '%s'", msg.Username)
|
||||||
h.client.claims.Subject = 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.
|
// 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.
|
// 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()
|
h.messageBufMu.RLock()
|
||||||
defer h.messageBufMu.RUnlock()
|
defer h.messageBufMu.RUnlock()
|
||||||
for _, msg := range h.messageBuf {
|
for _, msg := range h.messageBuf {
|
||||||
|
@ -229,7 +240,6 @@ func (h *BotHandlers) OnMessage(msg messages.Message) {
|
||||||
// Set their user variables.
|
// Set their user variables.
|
||||||
h.SetUserVariables(msg)
|
h.SetUserVariables(msg)
|
||||||
reply, err := h.rs.Reply(msg.Username, msg.Message)
|
reply, err := h.rs.Reply(msg.Username, msg.Message)
|
||||||
log.Error("REPLY: %s", reply)
|
|
||||||
if NoReply(reply) {
|
if NoReply(reply) {
|
||||||
return
|
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.
|
// OnError handles ChatServer messages from the backend.
|
||||||
func (h *BotHandlers) OnError(msg messages.Message) {
|
func (h *BotHandlers) OnError(msg messages.Message) {
|
||||||
log.Error("[%s] %s", msg.Username, msg.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.
|
// OnPing handles server keepalive pings.
|
||||||
func (h *BotHandlers) OnPing(msg messages.Message) {
|
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/log"
|
||||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||||
"github.com/aichaos/rivescript-go"
|
"github.com/aichaos/rivescript-go"
|
||||||
|
"github.com/aichaos/rivescript-go/lang/javascript"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set up object macros for RiveScript.
|
// Set up object macros for RiveScript.
|
||||||
|
@ -19,6 +20,8 @@ func (h *BotHandlers) setObjectMacros() {
|
||||||
UTF8: true,
|
UTF8: true,
|
||||||
Debug: rs.Debug,
|
Debug: rs.Debug,
|
||||||
})
|
})
|
||||||
|
bot.SetHandler("javascript", javascript.New(bot))
|
||||||
|
|
||||||
if err := bot.LoadDirectory("brain"); err != nil {
|
if err := bot.LoadDirectory("brain"); err != nil {
|
||||||
return fmt.Sprintf("Error on LoadDirectory: %s", err)
|
return fmt.Sprintf("Error on LoadDirectory: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -42,7 +45,7 @@ func (h *BotHandlers) setObjectMacros() {
|
||||||
time.Sleep(2500 * time.Millisecond)
|
time.Sleep(2500 * time.Millisecond)
|
||||||
h.client.Send(messages.Message{
|
h.client.Send(messages.Message{
|
||||||
Action: messages.ActionReact,
|
Action: messages.ActionReact,
|
||||||
MessageID: msgID,
|
MessageID: int64(msgID),
|
||||||
Message: args[1],
|
Message: args[1],
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
@ -54,6 +57,19 @@ func (h *BotHandlers) setObjectMacros() {
|
||||||
return "[react: invalid number of parameters]"
|
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)
|
// Takeback a message (admin action especially)
|
||||||
h.rs.SetSubroutine("takeback", func(rs *rivescript.RiveScript, args []string) string {
|
h.rs.SetSubroutine("takeback", func(rs *rivescript.RiveScript, args []string) string {
|
||||||
if len(args) >= 1 {
|
if len(args) >= 1 {
|
||||||
|
@ -61,7 +77,7 @@ func (h *BotHandlers) setObjectMacros() {
|
||||||
// Take it back.
|
// Take it back.
|
||||||
h.client.Send(messages.Message{
|
h.client.Send(messages.Message{
|
||||||
Action: messages.ActionTakeback,
|
Action: messages.ActionTakeback,
|
||||||
MessageID: msgID,
|
MessageID: int64(msgID),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
return fmt.Sprintf("[takeback: %s]", err)
|
return fmt.Sprintf("[takeback: %s]", err)
|
||||||
|
@ -78,7 +94,7 @@ func (h *BotHandlers) setObjectMacros() {
|
||||||
var comment = strings.Join(args[1:], " ")
|
var comment = strings.Join(args[1:], " ")
|
||||||
|
|
||||||
// Look up this message.
|
// 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.
|
// Report it with the custom comment.
|
||||||
h.client.Send(messages.Message{
|
h.client.Send(messages.Message{
|
||||||
Action: messages.ActionReport,
|
Action: messages.ActionReport,
|
||||||
|
@ -120,4 +136,25 @@ func (h *BotHandlers) setObjectMacros() {
|
||||||
}
|
}
|
||||||
return ""
|
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
|
@ -110,3 +110,202 @@ The JSON response to this endpoint may look like:
|
||||||
"Error": "if error, or this key is omitted if OK"
|
"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
|
"url": "/u/username", // user profile URL
|
||||||
"gender": "m", // gender (m, f, o)
|
"gender": "m", // gender (m, f, o)
|
||||||
"emoji": "🤖", // emoji icon
|
"emoji": "🤖", // emoji icon
|
||||||
|
"rules": ["redcam", "noimage"], // moderation rules (optional)
|
||||||
|
|
||||||
// Standard JWT claims that we support:
|
// Standard JWT claims that we support:
|
||||||
"iss": "my own app", // Issuer name
|
"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.
|
* Country flag emojis, to indicate where your users are connecting from.
|
||||||
* Robot emojis, to indicate bot users.
|
* Robot emojis, to indicate bot users.
|
||||||
* Any emoji you want! Mark your special guests or VIP users, etc.
|
* 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
|
## JWT Strict Mode
|
||||||
|
|
||||||
|
|
|
@ -171,6 +171,25 @@ Example: say you have a global keyword trigger on public rooms and want to DM a
|
||||||
< topic
|
< 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
|
## 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.
|
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).
|
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>
|
||||||
|
```
|
||||||
|
|
|
@ -37,11 +37,54 @@ PreviewImageWidth = 360
|
||||||
Name = "Off Topic"
|
Name = "Off Topic"
|
||||||
WelcomeMessages = ["Welcome to the Off Topic channel!"]
|
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]
|
[VIP]
|
||||||
Name = "VIP"
|
Name = "VIP"
|
||||||
Branding = "<em>VIP Members</em>"
|
Branding = "<em>VIP Members</em>"
|
||||||
Icon = "fa fa-circle"
|
Icon = "fa fa-circle"
|
||||||
MutuallySecret = false
|
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:
|
A description of the config directives includes:
|
||||||
|
@ -87,3 +130,80 @@ If using JWT authentication, your website can mark some users as VIPs when sendi
|
||||||
* **Branding** (string): HTML supported, this will appear in webcam sharing modals to "make my cam only visible to fellow VIP users"
|
* **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.
|
* **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.
|
* **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"
|
Name = "report"
|
||||||
Enabled = true
|
Enabled = true
|
||||||
URL = "http://localhost:8080/v1/barertc/report"
|
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:
|
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.
|
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
|
@ -7,13 +7,14 @@ require (
|
||||||
github.com/BurntSushi/toml v1.3.2
|
github.com/BurntSushi/toml v1.3.2
|
||||||
github.com/aichaos/rivescript-go v0.4.0
|
github.com/aichaos/rivescript-go v0.4.0
|
||||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
|
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/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/mattn/go-shellwords v1.0.12
|
||||||
github.com/microcosm-cc/bluemonday v1.0.25
|
github.com/microcosm-cc/bluemonday v1.0.25
|
||||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
|
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
|
||||||
github.com/urfave/cli/v2 v2.25.7
|
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
|
nhooyr.io/websocket v1.8.7
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,8 +22,15 @@ require (
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/disintegration/imaging v1.6.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/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 v1.6.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // 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/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
golang.org/x/crypto v0.12.0 // indirect
|
golang.org/x/crypto v0.13.0 // indirect
|
||||||
golang.org/x/net v0.14.0 // indirect
|
golang.org/x/net v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.11.0 // indirect
|
golang.org/x/sys v0.15.0 // indirect
|
||||||
golang.org/x/term v0.11.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
|
@ -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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
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.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.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-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-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-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
|
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 h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
||||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
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=
|
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-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 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
|
||||||
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
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/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 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
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 h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
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/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/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/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.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 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
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=
|
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 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
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/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/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/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
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=
|
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 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
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.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
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/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 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
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.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 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
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-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-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.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.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
|
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||||
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
|
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.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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
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-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.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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
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-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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.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-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.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.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.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
|
||||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
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.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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
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.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.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/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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
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-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-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-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-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-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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
|
||||||
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
|
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
|
||||||
|
|
24
index.html
|
@ -5,8 +5,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
|
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css?{{.CacheHash}}">
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css?{{.CacheHash}}">
|
|
||||||
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
|
<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}}">
|
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
|
||||||
<title>{{.Config.Title}}</title>
|
<title>{{.Config.Title}}</title>
|
||||||
|
@ -18,7 +17,7 @@
|
||||||
<div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div>
|
<div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div>
|
||||||
<div class="modal-content photo-modal">
|
<div class="modal-content photo-modal">
|
||||||
<div class="image is-fullwidth">
|
<div class="image is-fullwidth">
|
||||||
<img id="modalImage">
|
<img id="modalImage" oncontextmenu="return false">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></button>
|
<button class="modal-close is-large" aria-label="close" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></button>
|
||||||
|
@ -29,7 +28,9 @@
|
||||||
<!-- BareRTC constants injected by IndexPage route -->
|
<!-- BareRTC constants injected by IndexPage route -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
const Branding = {{.Config.Branding}};
|
const Branding = {{.Config.Branding}};
|
||||||
|
const BareRTCStrings = {{.Config.Strings}};
|
||||||
const PublicChannels = {{.Config.GetChannels}};
|
const PublicChannels = {{.Config.GetChannels}};
|
||||||
|
const DMDisclaimer = {{.Config.DirectMessageHistory.DisclaimerMessage}};
|
||||||
const WebsiteURL = "{{.Config.WebsiteURL}}";
|
const WebsiteURL = "{{.Config.WebsiteURL}}";
|
||||||
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
|
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
|
||||||
const TURN = {{.Config.TURN}};
|
const TURN = {{.Config.TURN}};
|
||||||
|
@ -38,7 +39,9 @@
|
||||||
const UserJWTToken = {{.JWTTokenString}};
|
const UserJWTToken = {{.JWTTokenString}};
|
||||||
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
|
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
|
||||||
const UserJWTClaims = {{.JWTClaims.ToJSON}};
|
const UserJWTClaims = {{.JWTClaims.ToJSON}};
|
||||||
|
const UserJWTRules = {{.JWTClaims.Rules.ToDict}};
|
||||||
const CachedBlocklist = {{.CachedBlocklist}};
|
const CachedBlocklist = {{.CachedBlocklist}};
|
||||||
|
const CacheHash = {{.CacheHash}};
|
||||||
|
|
||||||
// Show the photo detail modal.
|
// Show the photo detail modal.
|
||||||
function setModalImage(url) {
|
function setModalImage(url) {
|
||||||
|
@ -48,6 +51,21 @@
|
||||||
$modal.classList.add("is-active");
|
$modal.classList.add("is-active");
|
||||||
return false;
|
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>
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|
125
package-lock.json
generated
|
@ -8,8 +8,14 @@
|
||||||
"name": "barertc",
|
"name": "barertc",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"floating-vue": "^2.0.0-beta.24",
|
||||||
|
"hark": "^1.2.3",
|
||||||
"interactjs": "^1.10.18",
|
"interactjs": "^1.10.18",
|
||||||
"vue": "^3.3.4"
|
"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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.3.1",
|
"@vitejs/plugin-vue": "^4.3.1",
|
||||||
|
@ -446,6 +452,27 @@
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw=="
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.11.11",
|
"version": "0.11.11",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz",
|
||||||
|
@ -524,6 +551,15 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.3.4.tgz",
|
||||||
|
@ -1134,6 +1170,24 @@
|
||||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/floating-vue": {
|
||||||
|
"version": "2.0.0-beta.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/floating-vue/-/floating-vue-2.0.0-beta.24.tgz",
|
||||||
|
"integrity": "sha512-URSzP6YXaF4u1oZ9XGL8Sn8puuM7ivp5jkOUrpy5Q1mfo9BfGppJOn+ierTmsSUfJEeHBae8KT7r5DeI3vQIEw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "~1.1.1",
|
||||||
|
"vue-resize": "^2.0.0-alpha.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nuxt/kit": "^3.2.0",
|
||||||
|
"vue": "^3.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@nuxt/kit": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
@ -1207,6 +1261,14 @@
|
||||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/hark": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hark/-/hark-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-u68vz9SCa38ESiFJSDjqK8XbXqWzyot7Cj6Y2b6jk2NJ+II3MY2dIrLMg/kjtIAun4Y1DHF/20hfx4rq1G5GMg==",
|
||||||
|
"dependencies": {
|
||||||
|
"wildemitter": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
@ -1216,6 +1278,11 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
|
||||||
|
@ -1623,6 +1690,11 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcodejs": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "git+ssh://git@github.com/danielgjackson/qrcodejs.git#86770ec12f0f9abee8728fc9018ab7bd0949f4bc",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
@ -1802,6 +1874,11 @@
|
||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/textarea-caret": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q=="
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
@ -1932,6 +2009,47 @@
|
||||||
"eslint": ">=6.0.0"
|
"eslint": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-mention": {
|
||||||
|
"version": "2.0.0-alpha.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-mention/-/vue-mention-2.0.0-alpha.3.tgz",
|
||||||
|
"integrity": "sha512-NtM6Z6UpqHByKJPyiy2SrBy3K7wyi/6bvXltaRfWcSQdNwW3YrWzrr1M7lYB4NoWRhDFuk+4X1GpY8HH06g+XQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"textarea-caret": "^3.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"floating-vue": "^2.0.0-beta.1",
|
||||||
|
"vue": "^3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-resize": {
|
||||||
|
"version": "2.0.0-alpha.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz",
|
||||||
|
"integrity": "sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue3-emoji-picker": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue3-emoji-picker/-/vue3-emoji-picker-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-dKSI1NyrinYFykllwcOqBB1sw7EHdwQG4tjHYSO+khQkY8Csn4Evn5X2nAdz8Kl8o3P1J0jV4BGwbQ2dVWCxMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.0",
|
||||||
|
"idb": "^7.1.0",
|
||||||
|
"vue": "^3.2.23"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue3-slider": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue3-slider/-/vue3-slider-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-S+ojp3A21FiOM389Xp/bdUKSzjxcsTaDwI4N1aWVPoNh9//E8M+dqs1Ufjh86LulPAWLCN0ZR6eaD8jLZZ574g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
@ -1947,6 +2065,11 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wildemitter": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/wildemitter/-/wildemitter-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UMmSUoIQSir+XbBpTxOTS53uJ8s/lVhADCkEbhfRjUGFDPme/XGOb0sBWLx5sTz7Wx/2+TlAw1eK9O5lw5PiEw=="
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|
|
@ -10,8 +10,14 @@
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"floating-vue": "^2.0.0-beta.24",
|
||||||
|
"hark": "^1.2.3",
|
||||||
"interactjs": "^1.10.18",
|
"interactjs": "^1.10.18",
|
||||||
"vue": "^3.3.4"
|
"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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.3.1",
|
"@vitejs/plugin-vue": "^4.3.1",
|
||||||
|
|
649
pkg/api.go
|
@ -2,6 +2,7 @@ package barertc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -12,6 +13,7 @@ import (
|
||||||
"git.kirsle.net/apps/barertc/pkg/jwt"
|
"git.kirsle.net/apps/barertc/pkg/jwt"
|
||||||
"git.kirsle.net/apps/barertc/pkg/log"
|
"git.kirsle.net/apps/barertc/pkg/log"
|
||||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
"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,
|
// 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.
|
// Blocklist cache sent over from your website.
|
||||||
var (
|
var (
|
||||||
// Map of username to the list of usernames they block.
|
// Map of username to the list of usernames they block.
|
||||||
|
|
105
pkg/commands.go
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kirsle.net/apps/barertc/pkg/config"
|
"git.kirsle.net/apps/barertc/pkg/config"
|
||||||
|
@ -47,20 +48,33 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg messages.Message) bool {
|
||||||
case "/nsfw":
|
case "/nsfw":
|
||||||
s.NSFWCommand(words, sub)
|
s.NSFWCommand(words, sub)
|
||||||
return true
|
return true
|
||||||
|
case "/cut":
|
||||||
|
s.CutCommand(words, sub)
|
||||||
|
return true
|
||||||
|
case "/unmute-all":
|
||||||
|
s.UnmuteAllCommand(words, sub)
|
||||||
|
return true
|
||||||
case "/help":
|
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" +
|
"* `/kick <username>` to kick from chat\n" +
|
||||||
"* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" +
|
"* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" +
|
||||||
"* `/unban <username>` to list the ban on a user\n" +
|
"* `/unban <username>` to list the ban on a user\n" +
|
||||||
"* `/bans` to list current banned users and their expiration date\n" +
|
"* `/bans` to list current banned users and their expiration date\n" +
|
||||||
"* `/nsfw <username>` to mark their camera NSFW\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" +
|
"* `/op <username>` to grant operator rights to a user\n" +
|
||||||
"* `/deop <username>` to remove operator rights from a user\n" +
|
"* `/deop <username>` to remove operator rights from a user\n" +
|
||||||
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
|
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
|
||||||
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
|
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
|
||||||
"* `/reconfigure` to dynamically reload the chat server settings file\n" +
|
"* `/reconfigure` to dynamically reload the chat server settings file\n" +
|
||||||
"* `/help` to show this message\n\n" +
|
"* `/help-advanced` to show this message",
|
||||||
"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
|
return true
|
||||||
case "/shutdown":
|
case "/shutdown":
|
||||||
|
@ -109,14 +123,23 @@ func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
|
||||||
if len(words) == 1 {
|
if len(words) == 1 {
|
||||||
sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.")
|
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)
|
other, err := s.GetSubscriber(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sub.ChatServer("/nsfw: username not found: %s", username)
|
sub.ChatServer("/nsfw: username not found: %s", username)
|
||||||
} else {
|
} 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.
|
// 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 " +
|
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 the admin who marked it was previously booted
|
||||||
if other.Boots(sub.Username) {
|
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.
|
// KickCommand handles the `/kick` operator command.
|
||||||
func (s *Server) KickCommand(words []string, sub *Subscriber) {
|
func (s *Server) KickCommand(words []string, sub *Subscriber) {
|
||||||
if len(words) == 1 {
|
if len(words) == 1 {
|
||||||
|
@ -141,7 +201,7 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username := words[1]
|
username := strings.TrimPrefix(words[1], "@")
|
||||||
other, err := s.GetSubscriber(username)
|
other, err := s.GetSubscriber(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sub.ChatServer("/kick: username not found: %s", username)
|
sub.ChatServer("/kick: username not found: %s", username)
|
||||||
|
@ -152,14 +212,15 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
|
||||||
other.SendJSON(messages.Message{
|
other.SendJSON(messages.Message{
|
||||||
Action: messages.ActionKick,
|
Action: messages.ActionKick,
|
||||||
})
|
})
|
||||||
s.DeleteSubscriber(other)
|
other.authenticated = false
|
||||||
|
other.Username = ""
|
||||||
sub.ChatServer("%s has been kicked from the room", username)
|
sub.ChatServer("%s has been kicked from the room", username)
|
||||||
|
|
||||||
// Broadcast it to everyone.
|
// Broadcast it to everyone.
|
||||||
s.Broadcast(messages.Message{
|
s.Broadcast(messages.Message{
|
||||||
Action: messages.ActionPresence,
|
Action: messages.ActionPresence,
|
||||||
Username: username,
|
Username: username,
|
||||||
Message: "has been kicked from the room!",
|
Message: messages.PresenceKicked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -200,7 +261,8 @@ func (s *Server) KickAllCommand() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
s.DeleteSubscriber(sub)
|
sub.authenticated = false
|
||||||
|
sub.Username = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -216,7 +278,7 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
|
||||||
|
|
||||||
// Parse the command.
|
// Parse the command.
|
||||||
var (
|
var (
|
||||||
username = words[1]
|
username = strings.TrimPrefix(words[1], "@")
|
||||||
duration = 24 * time.Hour
|
duration = 24 * time.Hour
|
||||||
)
|
)
|
||||||
if len(words) >= 3 {
|
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)
|
log.Info("Operator %s bans %s for %d hours", sub.Username, username, duration/time.Hour)
|
||||||
|
|
||||||
other, err := s.GetSubscriber(username)
|
// Add them to the ban list.
|
||||||
if err != nil {
|
|
||||||
sub.ChatServer("/ban: username not found: %s", username)
|
|
||||||
} else {
|
|
||||||
// Ban them.
|
|
||||||
BanUser(username, duration)
|
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{
|
s.Broadcast(messages.Message{
|
||||||
Action: messages.ActionPresence,
|
Action: messages.ActionPresence,
|
||||||
Username: username,
|
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.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{
|
other.SendJSON(messages.Message{
|
||||||
Action: messages.ActionKick,
|
Action: messages.ActionKick,
|
||||||
})
|
})
|
||||||
s.DeleteSubscriber(other)
|
other.authenticated = false
|
||||||
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
|
other.Username = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnbanCommand handles the `/unban` operator command.
|
// UnbanCommand handles the `/unban` operator command.
|
||||||
|
@ -260,7 +321,7 @@ func (s *Server) UnbanCommand(words []string, sub *Subscriber) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the command.
|
// Parse the command.
|
||||||
var username = words[1]
|
var username = strings.TrimPrefix(words[1], "@")
|
||||||
|
|
||||||
if UnbanUser(username) {
|
if UnbanUser(username) {
|
||||||
sub.ChatServer("The ban on %s has been lifted.", 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.
|
// Parse the command.
|
||||||
var username = words[1]
|
var username = strings.TrimPrefix(words[1], "@")
|
||||||
if other, err := s.GetSubscriber(username); err != nil {
|
if other, err := s.GetSubscriber(username); err != nil {
|
||||||
sub.ChatServer("/op: user %s was not found.", username)
|
sub.ChatServer("/op: user %s was not found.", username)
|
||||||
} else {
|
} else {
|
||||||
|
@ -328,7 +389,7 @@ func (s *Server) DeopCommand(words []string, sub *Subscriber) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the command.
|
// Parse the command.
|
||||||
var username = words[1]
|
var username = strings.TrimPrefix(words[1], "@")
|
||||||
if other, err := s.GetSubscriber(username); err != nil {
|
if other, err := s.GetSubscriber(username); err != nil {
|
||||||
sub.ChatServer("/deop: user %s was not found.", username)
|
sub.ChatServer("/deop: user %s was not found.", username)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
// Version of the config format - when new fields are added, it will attempt
|
// Version of the config format - when new fields are added, it will attempt
|
||||||
// to write the settings.toml to disk so new defaults populate.
|
// to write the settings.toml to disk so new defaults populate.
|
||||||
var currentVersion = 7
|
var currentVersion = 15
|
||||||
|
|
||||||
// Config for your BareRTC app.
|
// Config for your BareRTC app.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
@ -33,10 +33,12 @@ type Config struct {
|
||||||
CORSHosts []string
|
CORSHosts []string
|
||||||
AdminAPIKey string
|
AdminAPIKey string
|
||||||
PermitNSFW bool
|
PermitNSFW bool
|
||||||
|
BlockableAdmins bool
|
||||||
|
|
||||||
UseXForwardedFor bool
|
UseXForwardedFor bool
|
||||||
|
|
||||||
WebSocketReadLimit int64
|
WebSocketReadLimit int64
|
||||||
|
WebSocketSendTimeout int
|
||||||
MaxImageWidth int
|
MaxImageWidth int
|
||||||
PreviewImageWidth int
|
PreviewImageWidth int
|
||||||
|
|
||||||
|
@ -47,6 +49,15 @@ type Config struct {
|
||||||
WebhookURLs []WebhookURL
|
WebhookURLs []WebhookURL
|
||||||
|
|
||||||
VIP VIP
|
VIP VIP
|
||||||
|
|
||||||
|
MessageFilters []*MessageFilter
|
||||||
|
ModerationRule []*ModerationRule
|
||||||
|
|
||||||
|
DirectMessageHistory DirectMessageHistory
|
||||||
|
|
||||||
|
Strings Strings
|
||||||
|
|
||||||
|
Logging Logging
|
||||||
}
|
}
|
||||||
|
|
||||||
type TurnConfig struct {
|
type TurnConfig struct {
|
||||||
|
@ -62,6 +73,13 @@ type VIP struct {
|
||||||
MutuallySecret bool
|
MutuallySecret bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DirectMessageHistory struct {
|
||||||
|
Enabled bool
|
||||||
|
SQLiteDatabase string
|
||||||
|
RetentionDays int
|
||||||
|
DisclaimerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
// GetChannels returns a JavaScript safe array of the default PublicChannels.
|
// GetChannels returns a JavaScript safe array of the default PublicChannels.
|
||||||
func (c Config) GetChannels() template.JS {
|
func (c Config) GetChannels() template.JS {
|
||||||
data, _ := json.Marshal(c.PublicChannels)
|
data, _ := json.Marshal(c.PublicChannels)
|
||||||
|
@ -84,6 +102,7 @@ type Channel struct {
|
||||||
Name string // Like "Main Chat Room"
|
Name string // Like "Main Chat Room"
|
||||||
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
|
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
|
||||||
VIP bool // For VIP users only
|
VIP bool // For VIP users only
|
||||||
|
PermitPhotos bool // photos are allowed to be shared
|
||||||
|
|
||||||
// ChatServer messages to send to the user immediately upon connecting.
|
// ChatServer messages to send to the user immediately upon connecting.
|
||||||
WelcomeMessages []string
|
WelcomeMessages []string
|
||||||
|
@ -96,6 +115,31 @@ type WebhookURL struct {
|
||||||
URL string
|
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.
|
// Current loaded configuration.
|
||||||
var Current = DefaultConfig()
|
var Current = DefaultConfig()
|
||||||
|
|
||||||
|
@ -111,6 +155,7 @@ func DefaultConfig() Config {
|
||||||
"https://www.example.com",
|
"https://www.example.com",
|
||||||
},
|
},
|
||||||
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
|
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
|
||||||
|
WebSocketSendTimeout: 10, // seconds
|
||||||
MaxImageWidth: 1280,
|
MaxImageWidth: 1280,
|
||||||
PreviewImageWidth: 360,
|
PreviewImageWidth: 360,
|
||||||
PublicChannels: []Channel{
|
PublicChannels: []Channel{
|
||||||
|
@ -128,11 +173,13 @@ func DefaultConfig() Config {
|
||||||
WelcomeMessages: []string{
|
WelcomeMessages: []string{
|
||||||
"Welcome to the Off Topic channel!",
|
"Welcome to the Off Topic channel!",
|
||||||
},
|
},
|
||||||
|
PermitPhotos: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "vip",
|
ID: "vip",
|
||||||
Name: "VIPs Only",
|
Name: "VIPs Only",
|
||||||
VIP: true,
|
VIP: true,
|
||||||
|
PermitPhotos: true,
|
||||||
WelcomeMessages: []string{
|
WelcomeMessages: []string{
|
||||||
"This channel is only for operators and VIPs.",
|
"This channel is only for operators and VIPs.",
|
||||||
},
|
},
|
||||||
|
@ -148,12 +195,51 @@ func DefaultConfig() Config {
|
||||||
Name: "report",
|
Name: "report",
|
||||||
URL: "https://example.com/barertc/report",
|
URL: "https://example.com/barertc/report",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "profile",
|
||||||
|
URL: "https://example.com/barertc/user-profile",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
VIP: VIP{
|
VIP: VIP{
|
||||||
Name: "VIP",
|
Name: "VIP",
|
||||||
Branding: "<em>VIP Members</em>",
|
Branding: "<em>VIP Members</em>",
|
||||||
Icon: "fa fa-circle",
|
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
|
c.JWT.Strict = true
|
||||||
return c
|
return c
|
||||||
|
@ -199,3 +285,13 @@ func WriteSettings() error {
|
||||||
}
|
}
|
||||||
return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
|
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
|
@ -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
|
||||||
|
}
|
219
pkg/handlers.go
|
@ -11,6 +11,7 @@ import (
|
||||||
"git.kirsle.net/apps/barertc/pkg/jwt"
|
"git.kirsle.net/apps/barertc/pkg/jwt"
|
||||||
"git.kirsle.net/apps/barertc/pkg/log"
|
"git.kirsle.net/apps/barertc/pkg/log"
|
||||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||||
|
"git.kirsle.net/apps/barertc/pkg/models"
|
||||||
"git.kirsle.net/apps/barertc/pkg/util"
|
"git.kirsle.net/apps/barertc/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -62,7 +63,8 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
|
||||||
other.SendJSON(messages.Message{
|
other.SendJSON(messages.Message{
|
||||||
Action: messages.ActionKick,
|
Action: messages.ActionKick,
|
||||||
})
|
})
|
||||||
s.DeleteSubscriber(other)
|
other.authenticated = false
|
||||||
|
other.Username = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// They will take over their original 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{
|
sub.SendJSON(messages.Message{
|
||||||
Action: messages.ActionKick,
|
Action: messages.ActionKick,
|
||||||
})
|
})
|
||||||
s.DeleteSubscriber(sub)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -97,7 +98,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
|
||||||
s.Broadcast(messages.Message{
|
s.Broadcast(messages.Message{
|
||||||
Action: messages.ActionPresence,
|
Action: messages.ActionPresence,
|
||||||
Username: msg.Username,
|
Username: msg.Username,
|
||||||
Message: "has joined the room!",
|
Message: messages.PresenceJoined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send the user back their settings.
|
// 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)
|
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.")
|
sub.ChatServer("You must log in first.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -159,6 +160,38 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
|
||||||
MessageID: mid,
|
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?
|
// Is this a DM?
|
||||||
if strings.HasPrefix(msg.Channel, "@") {
|
if strings.HasPrefix(msg.Channel, "@") {
|
||||||
// Echo the message only to both parties.
|
// Echo the message only to both parties.
|
||||||
|
@ -168,13 +201,17 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
|
||||||
// Don't deliver it if the receiver has muted us. Note: admin users, even if muted,
|
// 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.
|
// can still deliver a DM to the one who muted them.
|
||||||
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
|
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)
|
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the sender already mutes the recipient, reply back with the error.
|
// 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)
|
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -184,28 +221,63 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
|
||||||
return
|
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 {
|
if err := s.SendTo(msg.Channel, message); err != nil {
|
||||||
sub.ChatServer("Your message could not be delivered: %s", err)
|
sub.ChatServer("Your message could not be delivered: %s", err)
|
||||||
}
|
}
|
||||||
return
|
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.
|
// Broadcast a chat message to the room.
|
||||||
s.Broadcast(message)
|
s.Broadcast(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnTakeback handles takebacks (delete your message for everybody)
|
// OnTakeback handles takebacks (delete your message for everybody)
|
||||||
func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
|
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.
|
// Permission check.
|
||||||
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
|
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
|
||||||
sub.midMu.Lock()
|
sub.midMu.Lock()
|
||||||
_, ok := sub.messageIDs[msg.MessageID]
|
_, ok := sub.messageIDs[msg.MessageID]
|
||||||
sub.midMu.Unlock()
|
sub.midMu.Unlock()
|
||||||
|
|
||||||
if !ok {
|
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.")
|
sub.ChatServer("That is not your message to take back.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast to everybody to remove this message.
|
// Broadcast to everybody to remove this message.
|
||||||
s.Broadcast(messages.Message{
|
s.Broadcast(messages.Message{
|
||||||
|
@ -232,6 +304,17 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
|
||||||
return
|
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.
|
// Detect image type and convert it into an <img src="data:"> tag.
|
||||||
var (
|
var (
|
||||||
filename = msg.Message
|
filename = msg.Message
|
||||||
|
@ -311,8 +394,30 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
|
||||||
|
|
||||||
// OnMe handles current user state updates.
|
// OnMe handles current user state updates.
|
||||||
func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
|
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 {
|
if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
|
||||||
log.Debug("User %s turns on their video feed", sub.Username)
|
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.
|
// Hidden status: for operators only, + fake a join/exit chat message.
|
||||||
|
@ -322,14 +427,14 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
|
||||||
s.Broadcast(messages.Message{
|
s.Broadcast(messages.Message{
|
||||||
Action: messages.ActionPresence,
|
Action: messages.ActionPresence,
|
||||||
Username: sub.Username,
|
Username: sub.Username,
|
||||||
Message: "has exited the room!",
|
Message: messages.PresenceExited,
|
||||||
})
|
})
|
||||||
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
|
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
|
||||||
// Leaving hidden - fake join message
|
// Leaving hidden - fake join message
|
||||||
s.Broadcast(messages.Message{
|
s.Broadcast(messages.Message{
|
||||||
Action: messages.ActionPresence,
|
Action: messages.ActionPresence,
|
||||||
Username: sub.Username,
|
Username: sub.Username,
|
||||||
Message: "has joined the room!",
|
Message: messages.PresenceJoined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if msg.ChatStatus == "hidden" {
|
} else if msg.ChatStatus == "hidden" {
|
||||||
|
@ -343,14 +448,37 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
|
||||||
|
|
||||||
// Sync the WhoList to everybody.
|
// Sync the WhoList to everybody.
|
||||||
s.SendWhoList()
|
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.
|
// 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) {
|
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.
|
// Look up the other subscriber.
|
||||||
other, err := s.GetSubscriber(msg.Username)
|
other, err := s.GetSubscriber(msg.Username)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,13 +510,56 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBoot is a user kicking you off their video stream.
|
// IsVideoNotAllowed verifies whether a viewer can open a broadcaster's camera.
|
||||||
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
|
//
|
||||||
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
|
// 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()
|
sub.muteMu.Lock()
|
||||||
|
|
||||||
|
if boot {
|
||||||
|
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
|
||||||
sub.booted[msg.Username] = struct{}{}
|
sub.booted[msg.Username] = struct{}{}
|
||||||
sub.muteMu.Unlock()
|
|
||||||
|
|
||||||
// If the subject of the boot is an admin, inform them they have been booted.
|
// 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() {
|
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
|
||||||
|
@ -397,6 +568,12 @@ func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
|
||||||
sub.Username,
|
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()
|
s.SendWhoList()
|
||||||
}
|
}
|
||||||
|
@ -429,7 +606,7 @@ func (s *Server) OnMute(sub *Subscriber, msg messages.Message, mute bool) {
|
||||||
|
|
||||||
// OnBlock is a user placing a hard block (hide from) another user.
|
// OnBlock is a user placing a hard block (hide from) another user.
|
||||||
func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
|
func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
|
||||||
log.Info("%s blocks %s: %v", sub.Username, msg.Username)
|
log.Info("%s blocks %s", sub.Username, msg.Username)
|
||||||
|
|
||||||
// If the subject of the block is an admin, return an error.
|
// If the subject of the block is an admin, return an error.
|
||||||
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
|
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
|
||||||
|
@ -449,7 +626,7 @@ func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
|
||||||
|
|
||||||
// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website.
|
// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website.
|
||||||
func (s *Server) OnBlocklist(sub *Subscriber, msg messages.Message) {
|
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()
|
sub.muteMu.Lock()
|
||||||
for _, username := range msg.Usernames {
|
for _, username := range msg.Usernames {
|
||||||
|
@ -470,8 +647,14 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
|
||||||
return
|
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.
|
// Post to the report webhook.
|
||||||
if err := PostWebhook(WebhookReport, WebhookRequest{
|
if _, err := PostWebhook(WebhookReport, WebhookRequest{
|
||||||
Action: WebhookReport,
|
Action: WebhookReport,
|
||||||
APIKey: config.Current.AdminAPIKey,
|
APIKey: config.Current.AdminAPIKey,
|
||||||
Report: WebhookRequestReport{
|
Report: WebhookRequestReport{
|
||||||
|
@ -495,7 +678,6 @@ func (s *Server) OnCandidate(sub *Subscriber, msg messages.Message) {
|
||||||
// Look up the other subscriber.
|
// Look up the other subscriber.
|
||||||
other, err := s.GetSubscriber(msg.Username)
|
other, err := s.GetSubscriber(msg.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -511,7 +693,6 @@ func (s *Server) OnSDP(sub *Subscriber, msg messages.Message) {
|
||||||
// Look up the other subscriber.
|
// Look up the other subscriber.
|
||||||
other, err := s.GetSubscriber(msg.Username)
|
other, err := s.GetSubscriber(msg.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -527,7 +708,6 @@ func (s *Server) OnWatch(sub *Subscriber, msg messages.Message) {
|
||||||
// Look up the other subscriber.
|
// Look up the other subscriber.
|
||||||
other, err := s.GetSubscriber(msg.Username)
|
other, err := s.GetSubscriber(msg.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -542,7 +722,6 @@ func (s *Server) OnUnwatch(sub *Subscriber, msg messages.Message) {
|
||||||
// Look up the other subscriber.
|
// Look up the other subscriber.
|
||||||
other, err := s.GetSubscriber(msg.Username)
|
other, err := s.GetSubscriber(msg.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ type Claims struct {
|
||||||
Nick string `json:"nick,omitempty"`
|
Nick string `json:"nick,omitempty"`
|
||||||
Emoji string `json:"emoji,omitempty"`
|
Emoji string `json:"emoji,omitempty"`
|
||||||
Gender string `json:"gender,omitempty"`
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Rules Rules `json:"rules,omitempty"`
|
||||||
|
|
||||||
// Standard claims. Notes:
|
// Standard claims. Notes:
|
||||||
// subject = username
|
// subject = username
|
||||||
|
|
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
|
@ -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
|
@ -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
|
package messages
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Auto incrementing Message ID for anything pushed out by the server.
|
// Auto incrementing Message ID for anything pushed out by the server.
|
||||||
var (
|
var (
|
||||||
messageID int
|
messageID = time.Now().Unix()
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// NextMessageID atomically increments and returns a new MessageID.
|
// NextMessageID atomically increments and returns a new MessageID.
|
||||||
func NextMessageID() int {
|
func NextMessageID() int64 {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
messageID++
|
messageID++
|
||||||
|
@ -42,7 +45,7 @@ type Message struct {
|
||||||
DND bool `json:"dnd,omitempty"` // Do Not Disturb, e.g. DMs are closed
|
DND bool `json:"dnd,omitempty"` // Do Not Disturb, e.g. DMs are closed
|
||||||
|
|
||||||
// Message ID to support takebacks/local deletions
|
// 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.
|
// Sent on `open` actions along with the (other) Username.
|
||||||
OpenSecret string `json:"openSecret,omitempty"`
|
OpenSecret string `json:"openSecret,omitempty"`
|
||||||
|
@ -68,6 +71,7 @@ const (
|
||||||
// Actions sent by the client side only
|
// Actions sent by the client side only
|
||||||
ActionLogin = "login" // post the username to backend
|
ActionLogin = "login" // post the username to backend
|
||||||
ActionBoot = "boot" // boot a user off your video feed
|
ActionBoot = "boot" // boot a user off your video feed
|
||||||
|
ActionUnboot = "unboot" // unboot a user
|
||||||
ActionMute = "mute" // mute a user's chat messages
|
ActionMute = "mute" // mute a user's chat messages
|
||||||
ActionUnmute = "unmute"
|
ActionUnmute = "unmute"
|
||||||
ActionBlock = "block" // hard block another user
|
ActionBlock = "block" // hard block another user
|
||||||
|
@ -84,11 +88,13 @@ const (
|
||||||
ActionFile = "file" // image sharing in chat
|
ActionFile = "file" // image sharing in chat
|
||||||
ActionTakeback = "takeback" // user takes back (deletes) their message for everybody
|
ActionTakeback = "takeback" // user takes back (deletes) their message for everybody
|
||||||
ActionReact = "react" // emoji reaction to a chat message
|
ActionReact = "react" // emoji reaction to a chat message
|
||||||
|
ActionTyping = "typing" // typing indicator for DM threads
|
||||||
|
|
||||||
// Actions sent by server only
|
// Actions sent by server only
|
||||||
ActionPing = "ping"
|
ActionPing = "ping"
|
||||||
ActionWhoList = "who" // server pushes the Who List
|
ActionWhoList = "who" // server pushes the Who List
|
||||||
ActionPresence = "presence" // a user joined or left the room
|
ActionPresence = "presence" // a user joined or left the room
|
||||||
|
ActionCut = "cut" // tell the client to turn off their webcam
|
||||||
ActionError = "error" // ChatServer errors
|
ActionError = "error" // ChatServer errors
|
||||||
ActionKick = "disconnect" // client should disconnect (e.g. have been kicked).
|
ActionKick = "disconnect" // client should disconnect (e.g. have been kicked).
|
||||||
|
|
||||||
|
@ -121,8 +127,17 @@ const (
|
||||||
VideoFlagActive int = 1 << iota // user's camera is enabled/broadcasting
|
VideoFlagActive int = 1 << iota // user's camera is enabled/broadcasting
|
||||||
VideoFlagNSFW // viewer's camera is marked as NSFW
|
VideoFlagNSFW // viewer's camera is marked as NSFW
|
||||||
VideoFlagMuted // user source microphone is muted
|
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
|
VideoFlagMutualRequired // video wants viewers to share their camera too
|
||||||
VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras
|
VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras
|
||||||
VideoFlagOnlyVIP // can only shows as active to VIP members
|
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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
||||||
|
}
|
14
pkg/pages.go
|
@ -110,6 +110,7 @@ func AboutPage() http.HandlerFunc {
|
||||||
|
|
||||||
// The current website settings.
|
// The current website settings.
|
||||||
"Config": config.Current,
|
"Config": config.Current,
|
||||||
|
"Hostname": r.Host,
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl.Funcs(template.FuncMap{
|
tmpl.Funcs(template.FuncMap{
|
||||||
|
@ -125,3 +126,16 @@ func AboutPage() http.HandlerFunc {
|
||||||
tmpl.ExecuteTemplate(w, "index", values)
|
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
|
@ -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
|
package barertc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"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
|
// Server is the primary back-end server struct for BareRTC, see main.go
|
||||||
|
@ -16,27 +21,44 @@ type Server struct {
|
||||||
// Connected WebSocket subscribers.
|
// Connected WebSocket subscribers.
|
||||||
subscribersMu sync.RWMutex
|
subscribersMu sync.RWMutex
|
||||||
subscribers map[*Subscriber]struct{}
|
subscribers map[*Subscriber]struct{}
|
||||||
|
|
||||||
|
// Cached filehandles for channel logging.
|
||||||
|
logfh map[string]io.WriteCloser
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer initializes the Server.
|
// NewServer initializes the Server.
|
||||||
func NewServer() *Server {
|
func NewServer() *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
subscriberMessageBuffer: 16,
|
subscriberMessageBuffer: 32,
|
||||||
subscribers: make(map[*Subscriber]struct{}),
|
subscribers: make(map[*Subscriber]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup the server: configure HTTP routes, etc.
|
// Setup the server: configure HTTP routes, etc.
|
||||||
func (s *Server) Setup() error {
|
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()
|
var mux = http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/", IndexPage())
|
mux.Handle("/", IndexPage())
|
||||||
mux.Handle("/about", AboutPage())
|
mux.Handle("/about", AboutPage())
|
||||||
|
mux.Handle("/logout", LogoutPage())
|
||||||
mux.Handle("/ws", s.WebSocket())
|
mux.Handle("/ws", s.WebSocket())
|
||||||
|
mux.Handle("/poll", s.PollingAPI())
|
||||||
mux.Handle("/api/statistics", s.Statistics())
|
mux.Handle("/api/statistics", s.Statistics())
|
||||||
mux.Handle("/api/blocklist", s.BlockList())
|
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/authenticate", s.Authenticate())
|
||||||
mux.Handle("/api/shutdown", s.ShutdownAPI())
|
mux.Handle("/api/shutdown", s.ShutdownAPI())
|
||||||
|
mux.Handle("/api/profile", s.UserProfile())
|
||||||
|
mux.Handle("/api/message/history", s.MessageHistory())
|
||||||
|
mux.Handle("/api/message/clear", s.ClearMessages())
|
||||||
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
|
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))
|
||||||
|
|
||||||
|
@ -47,5 +69,7 @@ func (s *Server) Setup() error {
|
||||||
|
|
||||||
// ListenAndServe starts the web server.
|
// ListenAndServe starts the web server.
|
||||||
func (s *Server) ListenAndServe(address string) error {
|
func (s *Server) ListenAndServe(address string) error {
|
||||||
|
// Run the polling user idle kicker.
|
||||||
|
go s.KickIdlePollUsers()
|
||||||
return http.ListenAndServe(address, s.mux)
|
return http.ListenAndServe(address, s.mux)
|
||||||
}
|
}
|
||||||
|
|
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.
|
// 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)
|
webhook, ok := GetWebhook(name)
|
||||||
if !ok {
|
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 {
|
} 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.
|
// JSON request body.
|
||||||
jsonStr, err := json.Marshal(payload)
|
jsonStr, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the API request to BareRTC.
|
// 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)
|
log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr)
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
@ -67,15 +69,15 @@ func PostWebhook(name string, payload any) error {
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
450
pkg/websocket.go
|
@ -2,166 +2,17 @@ package barertc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.kirsle.net/apps/barertc/pkg/config"
|
"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/log"
|
||||||
"git.kirsle.net/apps/barertc/pkg/messages"
|
"git.kirsle.net/apps/barertc/pkg/messages"
|
||||||
"git.kirsle.net/apps/barertc/pkg/util"
|
"git.kirsle.net/apps/barertc/pkg/util"
|
||||||
"nhooyr.io/websocket"
|
"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
|
|
||||||
blocked map[string]struct{} // usernames you have blocked
|
|
||||||
muted map[string]struct{} // usernames you muted
|
|
||||||
|
|
||||||
// Record which message IDs belong to this user.
|
|
||||||
midMu sync.Mutex
|
|
||||||
messageIDs map[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.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)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
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.
|
// WebSocket handles the /ws websocket connection endpoint.
|
||||||
func (s *Server) WebSocket() http.HandlerFunc {
|
func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -186,20 +37,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
// ctx := c.CloseRead(r.Context())
|
// ctx := c.CloseRead(r.Context())
|
||||||
ctx, cancel := context.WithCancel(r.Context())
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
|
||||||
sub := &Subscriber{
|
sub := s.NewWebSocketSubscriber(ctx, c, cancel)
|
||||||
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{}),
|
|
||||||
blocked: make(map[string]struct{}),
|
|
||||||
messageIDs: make(map[int]struct{}),
|
|
||||||
ChatStatus: "online",
|
|
||||||
}
|
|
||||||
|
|
||||||
s.AddSubscriber(sub)
|
s.AddSubscriber(sub)
|
||||||
defer s.DeleteSubscriber(sub)
|
defer s.DeleteSubscriber(sub)
|
||||||
|
@ -209,7 +47,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg := <-sub.messages:
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -218,7 +56,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
|
||||||
var token string
|
var token string
|
||||||
if sub.JWTClaims != nil {
|
if sub.JWTClaims != nil {
|
||||||
if jwt, err := sub.JWTClaims.ReSign(); err != 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 {
|
} else {
|
||||||
token = jwt
|
token = jwt
|
||||||
}
|
}
|
||||||
|
@ -237,288 +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 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)) && !sub.IsAdmin() {
|
|
||||||
who.Video = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this person's VideoFlag is set to VIP Only, force their camera to "off"
|
|
||||||
// except when the person looking has the VIP status.
|
|
||||||
if (user.VideoStatus&messages.VideoFlagOnlyVIP == messages.VideoFlagOnlyVIP) && (!sub.IsVIP() && !sub.IsAdmin()) {
|
|
||||||
who.Video = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.JWTClaims != nil {
|
|
||||||
who.Operator = user.JWTClaims.IsAdmin
|
|
||||||
who.Avatar = user.JWTClaims.Avatar
|
|
||||||
who.ProfileURL = user.JWTClaims.ProfileURL
|
|
||||||
who.Nickname = user.JWTClaims.Nick
|
|
||||||
who.Emoji = user.JWTClaims.Emoji
|
|
||||||
who.Gender = user.JWTClaims.Gender
|
|
||||||
|
|
||||||
// VIP flags: if we are in MutuallySecret mode, only VIPs can see
|
|
||||||
// other VIP flags on the Who List.
|
|
||||||
if config.Current.VIP.MutuallySecret {
|
|
||||||
if sub.IsVIP() || sub.IsAdmin() {
|
|
||||||
who.VIP = user.JWTClaims.VIP
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
who.VIP = user.JWTClaims.VIP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
users = append(users, who)
|
|
||||||
}
|
|
||||||
|
|
||||||
sub.SendJSON(messages.Message{
|
|
||||||
Action: messages.ActionWhoList,
|
|
||||||
WhoList: users,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boots checks whether the subscriber has blocked username from their camera.
|
|
||||||
func (s *Subscriber) Boots(username string) bool {
|
|
||||||
s.muteMu.RLock()
|
|
||||||
defer s.muteMu.RUnlock()
|
|
||||||
_, ok := s.booted[username]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mutes checks whether the subscriber has muted username.
|
|
||||||
func (s *Subscriber) Mutes(username string) bool {
|
|
||||||
s.muteMu.RLock()
|
|
||||||
defer s.muteMu.RUnlock()
|
|
||||||
_, ok := s.muted[username]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blocks checks whether the subscriber blocks the username, or vice versa (blocking goes both directions).
|
|
||||||
func (s *Subscriber) Blocks(other *Subscriber) bool {
|
|
||||||
if s == nil || other == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If either side is an admin, blocking is not allowed.
|
|
||||||
if s.IsAdmin() || other.IsAdmin() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
s.muteMu.RLock()
|
|
||||||
defer s.muteMu.RUnlock()
|
|
||||||
|
|
||||||
// Forward block?
|
|
||||||
if _, ok := s.blocked[other.Username]; ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse block?
|
|
||||||
other.muteMu.RLock()
|
|
||||||
defer other.muteMu.RUnlock()
|
|
||||||
_, ok := other.blocked[s.Username]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
|
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
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
1
public/static/css/bulma-no-dark-mode.min.css.map
Normal file
23970
public/static/css/bulma.css
vendored
4
public/static/css/bulma.min.css
vendored
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
|
@ -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;
|
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 {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
@ -17,10 +29,17 @@ body {
|
||||||
|
|
||||||
/* DM title and bg color */
|
/* DM title and bg color */
|
||||||
.has-background-private {
|
.has-background-private {
|
||||||
background-color: #b748c7;
|
background-color: #b748c7 !important;
|
||||||
}
|
}
|
||||||
.has-background-dm {
|
.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 */
|
/* Truncate long text, e.g. usernames in the who list */
|
||||||
|
@ -72,13 +91,6 @@ body {
|
||||||
grid-template-rows: auto 1fr auto;
|
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 */
|
/* Header row */
|
||||||
.chat-container > .chat-header {
|
.chat-container > .chat-header {
|
||||||
grid-column: 1 / 4;
|
grid-column: 1 / 4;
|
||||||
|
@ -107,6 +119,14 @@ body {
|
||||||
bottom: 4px;
|
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 */
|
/* Footer row: message entry box */
|
||||||
.chat-container > .chat-footer {
|
.chat-container > .chat-footer {
|
||||||
grid-column: 1 / 4;
|
grid-column: 1 / 4;
|
||||||
|
@ -122,7 +142,7 @@ body {
|
||||||
/* Responsive CSS styles */
|
/* Responsive CSS styles */
|
||||||
@media screen and (min-width: 1024px) {
|
@media screen and (min-width: 1024px) {
|
||||||
.mobile-only {
|
.mobile-only {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 1024px) {
|
@media screen and (max-width: 1024px) {
|
||||||
|
@ -221,20 +241,25 @@ body {
|
||||||
width: 168px;
|
width: 168px;
|
||||||
height: 112px;
|
height: 112px;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
|
border: 1px solid black;
|
||||||
margin: 3px;
|
margin: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A speaking webcam */
|
||||||
|
.feed.is-speaking {
|
||||||
|
border: 1px solid #09F !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* A popped-out video feed window */
|
/* A popped-out video feed window */
|
||||||
div.feed.popped-out {
|
div.feed.popped-out {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border: 1px solid #FFF;
|
|
||||||
cursor: move;
|
cursor: move;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1000;
|
|
||||||
resize: none;
|
resize: none;
|
||||||
|
z-index: 1; /* work around Safari video being on top when return from fullscreen */
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-feeds.x1 > .feed {
|
.video-feeds.x1 > .feed {
|
||||||
|
@ -270,12 +295,14 @@ div.feed.popped-out {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
|
z-index: 1; /* work around Safari video being on top when return from fullscreen */
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed > .close {
|
.feed > .close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
z-index: 1; /* work around Safari video being on top when return from fullscreen */
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed > .caption {
|
.feed > .caption {
|
||||||
|
@ -286,6 +313,7 @@ div.feed.popped-out {
|
||||||
left: 4px;
|
left: 4px;
|
||||||
font-size: small;
|
font-size: small;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
|
z-index: 1; /* work around Safari video being on top when return from fullscreen */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* YouTube embeds */
|
/* YouTube embeds */
|
||||||
|
|
BIN
public/static/img/connection-error.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 142 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 174 KiB |
BIN
screenshot.png
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 216 KiB |
2641
src/App.vue
89
src/components/AlertModal.vue
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
visible: Boolean,
|
||||||
|
isConfirm: Boolean,
|
||||||
|
title: String,
|
||||||
|
icon: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
if (!this.visible) return;
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
return this.callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key == 'Escape') {
|
||||||
|
return this.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
callback() {
|
||||||
|
this.$emit('close');
|
||||||
|
this.$emit('callback');
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.$emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="modal" :class="{ 'is-active': visible }">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header has-background-info">
|
||||||
|
<p class="card-header-title">
|
||||||
|
<i v-if="icon" :class="icon" class="mr-2"></i>
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<button class="delete mr-3 mt-3" aria-label="close" @click.prevent="close"></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<form @submit.prevent="callback()">
|
||||||
|
|
||||||
|
<p class="literal mb-4">{{ message }}</p>
|
||||||
|
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<button type="submit"
|
||||||
|
class="button is-success px-5">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button v-if="isConfirm"
|
||||||
|
type="button"
|
||||||
|
class="button is-link ml-3 px-5"
|
||||||
|
@click="close">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal {
|
||||||
|
/* a high priority modal over other modals. note: bulma's default z-index is 40 for modals */
|
||||||
|
z-index: 42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.literal {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -30,7 +30,7 @@ export default {
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header has-background-info">
|
<header class="card-header has-background-info">
|
||||||
<p class="card-header-title has-text-light">This camera may contain Explicit content</p>
|
<p class="card-header-title">This camera may contain Explicit content</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p class="block">
|
<p class="block">
|
||||||
|
|
|
@ -22,7 +22,7 @@ export default {
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header has-background-info">
|
<header class="card-header has-background-info">
|
||||||
<p class="card-header-title has-text-light">Sign In</p>
|
<p class="card-header-title">Sign In</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<form @submit.prevent="signIn()">
|
<form @submit.prevent="signIn()">
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
<script>
|
<script>
|
||||||
|
import EmojiPicker from 'vue3-emoji-picker';
|
||||||
|
import LocalStorage from '../lib/LocalStorage';
|
||||||
|
import 'vue3-emoji-picker/css';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
message: Object, // chat Message object
|
message: Object, // chat Message object
|
||||||
|
action: String, // presence, notification, or (default) normal chat message
|
||||||
|
appearance: String, // message style appearance (cards, compact, etc.)
|
||||||
user: Object, // User object of the Message author
|
user: Object, // User object of the Message author
|
||||||
|
isOffline: Boolean, // user is not currently online
|
||||||
username: String, // current username logged in
|
username: String, // current username logged in
|
||||||
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
|
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
|
||||||
isDnd: Boolean, // user is not accepting DMs
|
isDnd: Boolean, // user is not accepting DMs
|
||||||
|
@ -14,16 +21,42 @@ export default {
|
||||||
isOp: Boolean, // current user is Operator (always show takeback button)
|
isOp: Boolean, // current user is Operator (always show takeback button)
|
||||||
noButtons: Boolean, // hide all message buttons (e.g. for Report Modal)
|
noButtons: Boolean, // hide all message buttons (e.g. for Report Modal)
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
EmojiPicker,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
reactionsAvailable: [
|
// Emoji picker visible
|
||||||
['❤️', '👍', '😂', '😉', '😢', '😡', '🥰'],
|
showEmojiPicker: false,
|
||||||
['😘', '👎', '☹️', '😭', '🤔', '🙄', '🤩'],
|
|
||||||
['👋', '🔥', '😈', '🍑', '🍆', '💦', '🍌'],
|
// Message menu (compact displays)
|
||||||
['😋', '⭐', '😇', '😴', '😱', '👀', '🎃'],
|
menuVisible: false,
|
||||||
['🤮', '🥳', '🙏', '🤦', '💩', '🤯', '💯'],
|
|
||||||
['😏', '🙈', '🙉', '🙊', '☀️', '🌈', '🎂'],
|
// Favorite emojis
|
||||||
],
|
customEmojiGroups: {
|
||||||
|
frequently_used: [
|
||||||
|
{ n: ['heart'], u: '2764-fe0f' },
|
||||||
|
{ n: ['+1', 'thumbs_up'], u: '1f44d' },
|
||||||
|
{ n: ['-1', 'thumbs_down'], u: '1f44e' },
|
||||||
|
{ n: ['rolling_on_the_floor_laughing'], u: '1f923' },
|
||||||
|
{ n: ['wink'], u: '1f609' },
|
||||||
|
{ n: ['cry'], u: '1f622' },
|
||||||
|
{ n: ['angry'], u: '1f620' },
|
||||||
|
{ n: ['heart_eyes'], u: '1f60d' },
|
||||||
|
|
||||||
|
{ n: ['kissing_heart'], u: '1f618' },
|
||||||
|
{ n: ['wave'], u: '1f44b' },
|
||||||
|
{ n: ['fire'], u: '1f525' },
|
||||||
|
{ n: ['smiling_imp'], u: '1f608' },
|
||||||
|
{ n: ['peach'], u: '1f351' },
|
||||||
|
{ n: ['eggplant', 'aubergine'], u: '1f346' },
|
||||||
|
{ n: ['splash', 'sweat_drops'], u: '1f4a6' },
|
||||||
|
{ n: ['banana'], u: '1f34c' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Emoji reactions are toggled fully spelled out (for mobile)
|
||||||
|
showReactions: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -48,17 +81,23 @@ export default {
|
||||||
hasReactions() {
|
hasReactions() {
|
||||||
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
|
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
|
||||||
},
|
},
|
||||||
},
|
|
||||||
methods: {
|
// Compactify a message (remove paragraph breaks added by Markdown renderer)
|
||||||
signIn() {
|
compactMessage() {
|
||||||
this.$emit('signIn', this.username);
|
return this.message.message.replace(/<\/p>\s*<p>/g, "<br><br>").replace(/<\/?p>/g, "");
|
||||||
},
|
},
|
||||||
|
|
||||||
openProfile() {
|
emojiPickerTheme() {
|
||||||
let url = this.profileURL;
|
let theme = LocalStorage.get('theme');
|
||||||
if (url) {
|
if (theme === 'light' || theme === 'dark') {
|
||||||
window.open(url);
|
return theme;
|
||||||
}
|
}
|
||||||
|
return 'auto';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openProfile() {
|
||||||
|
this.$emit('open-profile', this.message.username);
|
||||||
},
|
},
|
||||||
|
|
||||||
openDMs() {
|
openDMs() {
|
||||||
|
@ -87,6 +126,20 @@ export default {
|
||||||
this.$emit('react', this.message, emoji);
|
this.$emit('react', this.message, emoji);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Vue3-emoji-picker callback
|
||||||
|
onSelectEmoji(emoji) {
|
||||||
|
this.sendReact(emoji.i);
|
||||||
|
this.hideEmojiPicker();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hide the emoji menu (after sending an emoji or clicking the react button again)
|
||||||
|
hideEmojiPicker() {
|
||||||
|
if (!this.showEmojiPicker) return;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.showEmojiPicker = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
urlFor(url) {
|
urlFor(url) {
|
||||||
// Prepend the base websiteUrl if the given URL is relative.
|
// Prepend the base websiteUrl if the given URL is relative.
|
||||||
if (url.match(/^https?:/i)) {
|
if (url.match(/^https?:/i)) {
|
||||||
|
@ -115,27 +168,67 @@ export default {
|
||||||
if (date == undefined) return '';
|
if (date == undefined) return '';
|
||||||
let hours = date.getHours(),
|
let hours = date.getHours(),
|
||||||
minutes = String(date.getMinutes()).padStart(2, '0'),
|
minutes = String(date.getMinutes()).padStart(2, '0'),
|
||||||
ampm = hours >= 11 ? "pm" : "am";
|
ampm = hours >= 12 ? "pm" : "am";
|
||||||
|
|
||||||
let hour = hours % 12 || 12;
|
let hour = hours % 12 || 12;
|
||||||
return `${(hour)}:${minutes} ${ampm}`;
|
return `${(hour)}:${minutes} ${ampm}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
prettyDateCompact(date) {
|
||||||
|
if (date == undefined) return '';
|
||||||
|
let hour = date.getHours(),
|
||||||
|
minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hour}:${minutes}`;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
<!-- Presence message banners -->
|
||||||
|
<div v-if="action === 'presence'" class="notification is-success is-light py-1 px-3 mb-2">
|
||||||
|
|
||||||
|
<!-- Tiny avatar next to name and action buttons -->
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column is-narrow pr-0 pt-4">
|
||||||
|
<a :href="profileURL" @click.prevent="openProfile()" :class="{ 'cursor-default': !profileURL }">
|
||||||
|
<figure class="image is-16x16">
|
||||||
|
<img v-if="avatarURL" :src="avatarURL">
|
||||||
|
<img v-else src="/static/img/shy.png">
|
||||||
|
</figure>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<!-- Timestamp on the right -->
|
||||||
|
<span class="float-right is-size-7" :title="message.at">
|
||||||
|
{{ prettyDate(message.at) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span @click="openProfile()" class="cursor-pointer">
|
||||||
|
<strong>{{ nickname }}</strong>
|
||||||
|
<span v-if="isOffline" class="ml-1">(offline)</span>
|
||||||
|
<small v-else class="ml-1">(@{{ message.username }})</small>
|
||||||
|
</span>
|
||||||
|
{{ message.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification message banners (e.g. DM disclaimer) -->
|
||||||
|
<div v-else-if="action === 'notification'" class="notification is-warning is-light mb-2">
|
||||||
|
<span v-html="message.message"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Style (default) -->
|
||||||
|
<div v-else-if="appearance === 'cards' || !appearance" class="box mb-2 px-4 pt-3 pb-1 position-relative">
|
||||||
<div class="media mb-0">
|
<div class="media mb-0">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
<a :href="profileURL"
|
<a :href="profileURL" @click.prevent="openProfile()">
|
||||||
@click.prevent="openProfile()"
|
|
||||||
:class="{ 'cursor-default': !profileURL }">
|
|
||||||
<figure class="image is-48x48">
|
<figure class="image is-48x48">
|
||||||
<img v-if="message.isChatServer" src="/static/img/server.png">
|
<img v-if="message.isChatServer" src="/static/img/server.png">
|
||||||
<img v-else-if="message.isChatClient" src="/static/img/client.png">
|
<img v-else-if="message.isChatClient" src="/static/img/client.png">
|
||||||
<img v-else-if="avatarURL"
|
<img v-else-if="avatarURL" :src="avatarURL">
|
||||||
:src="avatarURL">
|
|
||||||
<img v-else src="/static/img/shy.png">
|
<img v-else src="/static/img/shy.png">
|
||||||
</figure>
|
</figure>
|
||||||
</a>
|
</a>
|
||||||
|
@ -151,6 +244,9 @@ export default {
|
||||||
|
|
||||||
<!-- User nickname/display name -->
|
<!-- User nickname/display name -->
|
||||||
{{ nickname }}
|
{{ nickname }}
|
||||||
|
|
||||||
|
<!-- Offline now? -->
|
||||||
|
<span v-if="isOffline">(offline)</span>
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="column has-text-right pb-0">
|
<div class="column has-text-right pb-0">
|
||||||
|
@ -162,8 +258,7 @@ export default {
|
||||||
<div class="columns is-mobile pt-0" v-if="(message.isChatClient || message.isChatServer)">
|
<div class="columns is-mobile pt-0" v-if="(message.isChatClient || message.isChatServer)">
|
||||||
<div class="column is-narrow pt-0">
|
<div class="column is-narrow pt-0">
|
||||||
<small v-if="!(message.isChatClient || message.isChatServer)">
|
<small v-if="!(message.isChatClient || message.isChatServer)">
|
||||||
<a v-if="profileURL"
|
<a v-if="profileURL" :href="profileURL" target="_blank" @click.prevent="openProfile()"
|
||||||
:href="profileURL" target="_blank"
|
|
||||||
class="has-text-grey">
|
class="has-text-grey">
|
||||||
@{{ message.username }}
|
@{{ message.username }}
|
||||||
</a>
|
</a>
|
||||||
|
@ -175,22 +270,18 @@ export default {
|
||||||
<div v-else class="columns is-mobile pt-0">
|
<div v-else class="columns is-mobile pt-0">
|
||||||
<div class="column is-narrow pt-0">
|
<div class="column is-narrow pt-0">
|
||||||
<small v-if="!(message.isChatClient || message.isChatServer)">
|
<small v-if="!(message.isChatClient || message.isChatServer)">
|
||||||
<a v-if="profileURL"
|
<a :href="profileURL || '#'" target="_blank" @click.prevent="openProfile()"
|
||||||
:href="profileURL" target="_blank"
|
|
||||||
class="has-text-grey">
|
class="has-text-grey">
|
||||||
@{{ message.username }}
|
@{{ message.username }}
|
||||||
</a>
|
</a>
|
||||||
<span v-else class="has-text-grey">@{{ message.username }}</span>
|
|
||||||
</small>
|
</small>
|
||||||
<small v-else class="has-text-grey">internal</small>
|
<small v-else class="has-text-grey">internal</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-narrow pl-1 pt-0"
|
<div class="column is-narrow pl-1 pt-0" v-if="!noButtons">
|
||||||
v-if="!noButtons">
|
|
||||||
<!-- DMs button -->
|
<!-- DMs button -->
|
||||||
<button type="button" v-if="!(message.username === username || isDm)"
|
<button type="button" v-if="!(message.username === username || isDm)"
|
||||||
class="button is-grey is-outlined is-small px-2"
|
class="button is-small px-2" @click="openDMs()"
|
||||||
@click="openDMs()"
|
|
||||||
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
|
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
|
||||||
:disabled="isDnd">
|
:disabled="isDnd">
|
||||||
<i class="fa fa-comment"></i>
|
<i class="fa fa-comment"></i>
|
||||||
|
@ -198,8 +289,7 @@ export default {
|
||||||
|
|
||||||
<!-- Mute button -->
|
<!-- Mute button -->
|
||||||
<button type="button" v-if="!(message.username === username)"
|
<button type="button" v-if="!(message.username === username)"
|
||||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
class="button is-small px-2 ml-1" @click="muteUser()" title="Mute user">
|
||||||
@click="muteUser()" title="Mute user">
|
|
||||||
<i class="fa fa-comment-slash" :class="{
|
<i class="fa fa-comment-slash" :class="{
|
||||||
'has-text-success': isMuted,
|
'has-text-success': isMuted,
|
||||||
'has-text-danger': !isMuted
|
'has-text-danger': !isMuted
|
||||||
|
@ -208,17 +298,16 @@ export default {
|
||||||
|
|
||||||
<!-- Owner or admin: take back the message -->
|
<!-- Owner or admin: take back the message -->
|
||||||
<button type="button" v-if="message.username === username || isOp"
|
<button type="button" v-if="message.username === username || isOp"
|
||||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
class="button is-small px-2 ml-1"
|
||||||
title="Take back this message (delete it for everybody)"
|
title="Take back this message (delete it for everybody)" @click="takeback()"
|
||||||
@click="takeback()">
|
:data-msgid="message.msgID">
|
||||||
<i class="fa fa-rotate-left has-text-danger"></i>
|
<i class="fa fa-rotate-left has-text-danger"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Everyone else: can hide it locally -->
|
<!-- Everyone else: can hide it locally -->
|
||||||
<button type="button" v-if="message.username !== username"
|
<button type="button" v-if="message.username !== username"
|
||||||
class="button is-grey is-outlined is-small px-2 ml-1"
|
class="button is-small px-2 ml-1"
|
||||||
title="Hide this message (delete it only for your view)"
|
title="Hide this message (delete it only for your view)" @click="removeMessage()">
|
||||||
@click="removeMessage()">
|
|
||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -230,22 +319,21 @@ export default {
|
||||||
<div v-if="message.msgID && !noButtons" class="emoji-button columns is-mobile is-gapless mb-0">
|
<div v-if="message.msgID && !noButtons" class="emoji-button columns is-mobile is-gapless mb-0">
|
||||||
<!-- Report message button -->
|
<!-- Report message button -->
|
||||||
<div class="column" v-if="reportEnabled && message.username !== username">
|
<div class="column" v-if="reportEnabled && message.username !== username">
|
||||||
<button class="button is-small is-outlined mr-1" :class="{
|
<button class="button is-small is-outlined mr-1 py-2" :class="{
|
||||||
'is-danger': !message.reported,
|
'is-danger': !message.reported,
|
||||||
'has-text-grey': message.reported
|
'has-text-grey': message.reported
|
||||||
}" title="Report this message"
|
}" title="Report this message" @click="reportMessage()">
|
||||||
@click="reportMessage()">
|
|
||||||
<i class="fa fa-flag"></i>
|
<i class="fa fa-flag"></i>
|
||||||
<i class="fa fa-check ml-1" v-if="message.reported"></i>
|
<i class="fa fa-check ml-1" v-if="message.reported"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Emoji reactions menu -->
|
<div class="column dropdown is-right"
|
||||||
<div class="column dropdown is-right" :class="{ 'is-up': position >= 2 }"
|
:class="{ 'is-up': position >= 2, 'is-active': showEmojiPicker }"
|
||||||
onclick="this.classList.toggle('is-active')">
|
@click="showEmojiPicker = true">
|
||||||
<div class="dropdown-trigger">
|
<div class="dropdown-trigger">
|
||||||
<button class="button is-small px-2" aria-haspopup="true"
|
<button type="button" class="button is-small px-2" aria-haspopup="true"
|
||||||
:aria-controls="`react-menu-${message.msgID}`">
|
:aria-controls="`react-menu-${message.msgID}`" @click="hideEmojiPicker()">
|
||||||
<span>
|
<span>
|
||||||
<i class="fa fa-heart has-text-grey"></i>
|
<i class="fa fa-heart has-text-grey"></i>
|
||||||
<i class="fa fa-plus has-text-grey pl-1"></i>
|
<i class="fa fa-plus has-text-grey pl-1"></i>
|
||||||
|
@ -254,18 +342,10 @@ export default {
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-menu" :id="`react-menu-${message.msgID}`" role="menu">
|
<div class="dropdown-menu" :id="`react-menu-${message.msgID}`" role="menu">
|
||||||
<div class="dropdown-content p-0">
|
<div class="dropdown-content p-0">
|
||||||
<!-- Iterate over reactions in rows of emojis-->
|
<!-- Emoji reactions menu -->
|
||||||
<div class="columns is-mobile ml-0 mb-2 mr-1"
|
<EmojiPicker v-if="showEmojiPicker" :native="true" :display-recent="true" :disable-skin-tones="true"
|
||||||
v-for="row in reactionsAvailable">
|
:additional-groups="customEmojiGroups" :group-names="{ frequently_used: 'Frequently Used' }"
|
||||||
|
:theme="emojiPickerTheme" @select="onSelectEmoji"></EmojiPicker>
|
||||||
<!-- Loop over the icons -->
|
|
||||||
<div class="column p-0 is-narrow" v-for="i in row">
|
|
||||||
<button type="button" class="button px-2 mt-1 ml-1 mr-0 mb-1"
|
|
||||||
@click="sendReact(i)">
|
|
||||||
{{ i }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -278,17 +358,180 @@ export default {
|
||||||
|
|
||||||
<!-- Reactions so far? -->
|
<!-- Reactions so far? -->
|
||||||
<div v-if="hasReactions" class="mt-1">
|
<div v-if="hasReactions" class="mt-1">
|
||||||
<span v-for="(users, emoji) in reactions"
|
<span v-for="(users, emoji) in reactions" v-bind:key="emoji" class="tag mr-1 cursor-pointer"
|
||||||
class="tag is-secondary mr-1 cursor-pointer"
|
:class="{ 'has-text-weight-bold': iReacted(emoji), 'is-secondary': !iReacted(emoji) }"
|
||||||
:class="{ 'is-success is-light': iReacted(msg, emoji), 'is-secondary': !iReacted(msg, emoji) }"
|
|
||||||
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(emoji)">
|
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(emoji)">
|
||||||
{{ emoji }} <small class="ml-1">{{ users.length }}</small>
|
{{ emoji }}
|
||||||
|
|
||||||
|
<small v-if="showReactions" class="ml-1">
|
||||||
|
{{ users.join(', ') }}
|
||||||
|
</small>
|
||||||
|
<small v-else class="ml-1">{{ users.length }}</small>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Mobile helper to show all -->
|
||||||
|
<a href="#" class="tag is-secondary cursor-pointer" @click.prevent="showReactions = !showReactions">
|
||||||
|
<i class="fa mr-1"
|
||||||
|
:class="{'fa-angles-left': showReactions,
|
||||||
|
'fa-angles-right': !showReactions,
|
||||||
|
}"></i> {{ showReactions ? 'Less' : 'More' }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact styles (with or without usernames) -->
|
||||||
|
<div v-else-if="appearance.indexOf('compact') === 0" class="columns is-mobile">
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<div class="column is-narrow pr-0">
|
||||||
|
<small class="has-text-grey is-size-7" :title="message.at">{{ prettyDateCompact(message.at) }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avatar icon -->
|
||||||
|
<div class="column is-narrow px-1">
|
||||||
|
<a :href="profileURL" @click.prevent="openProfile()" class="p-0">
|
||||||
|
<img v-if="avatarURL" :src="avatarURL" width="16" height="16" alt="">
|
||||||
|
<img v-else src="/static/img/shy.png" width="16" height="16">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name/username/message -->
|
||||||
|
<div class="column px-1">
|
||||||
|
<div class="content mb-2">
|
||||||
|
<strong :class="{
|
||||||
|
'has-text-success is-dark': message.isChatServer,
|
||||||
|
'has-text-warning is-dark': message.isAdmin,
|
||||||
|
'has-text-danger': message.isChatClient
|
||||||
|
}">
|
||||||
|
[<a :href="profileURL" @click.prevent="openProfile()" class="has-text-dark"
|
||||||
|
:class="{ 'cursor-default': !profileURL }">
|
||||||
|
<!-- Display name? -->
|
||||||
|
<span v-if="(message.isChatServer || message.isChatClient || message.isAdmin)
|
||||||
|
|| (appearance === 'compact' && nickname !== message.username)" :class="{
|
||||||
|
'has-text-success is-dark': message.isChatServer,
|
||||||
|
'has-text-warning is-dark': message.isAdmin,
|
||||||
|
'has-text-danger': message.isChatClient
|
||||||
|
}">
|
||||||
|
{{ nickname }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small class="has-text-grey"
|
||||||
|
:class="{ 'ml-1': appearance === 'compact' && nickname !== message.username }"
|
||||||
|
v-if="!(message.isChatServer || message.isChatClient || message.isAdmin)">@{{ message.username
|
||||||
|
}}</small>
|
||||||
|
</a>]
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span v-html="compactMessage"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reactions so far? -->
|
||||||
|
<div v-if="hasReactions" class="mb-2">
|
||||||
|
<span v-for="(users, emoji) in reactions" v-bind:key="emoji" class="tag mr-1 cursor-pointer"
|
||||||
|
:class="{ 'has-text-weight-bold': iReacted(emoji), 'is-secondary': !iReacted(emoji) }"
|
||||||
|
:title="emoji + ' by: ' + users.join(', ')" @click="sendReact(emoji)">
|
||||||
|
{{ emoji }}
|
||||||
|
|
||||||
|
<small v-if="showReactions" class="ml-1">
|
||||||
|
{{ users.join(', ') }}
|
||||||
|
</small>
|
||||||
|
<small v-else class="ml-1">{{ users.length }}</small>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Mobile helper to show all -->
|
||||||
|
<a href="#" class="tag is-secondary cursor-pointer" @click.prevent="showReactions = !showReactions">
|
||||||
|
<i class="fa mr-1"
|
||||||
|
:class="{'fa-angles-left': showReactions,
|
||||||
|
'fa-angles-right': !showReactions,
|
||||||
|
}"></i> {{ showReactions ? 'Less' : 'More' }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emoji/Menu button -->
|
||||||
|
<div v-if="message.msgID && !noButtons" class="column is-narrow pl-1">
|
||||||
|
|
||||||
|
<div class="columns is-mobile is-gapless mb-0">
|
||||||
|
<!-- More buttons menu (DM, mute, report, etc.) -->
|
||||||
|
<div class="column dropdown is-right"
|
||||||
|
:class="{ 'is-up': position >= 2, 'is-active': menuVisible }"
|
||||||
|
@click="menuVisible = !menuVisible">
|
||||||
|
<div class="dropdown-trigger">
|
||||||
|
<button type="button" class="button is-small px-2 mr-1" aria-haspopup="true"
|
||||||
|
:aria-controls="`msg-menu-${message.msgID}`">
|
||||||
|
<small>
|
||||||
|
<i class="fa fa-ellipsis-vertical"></i>
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu" :id="`msg-menu-${message.msgID}`" role="menu">
|
||||||
|
<div class="dropdown-content">
|
||||||
|
<a href="#" class="dropdown-item" v-if="message.username !== username"
|
||||||
|
@click.prevent="openDMs()">
|
||||||
|
<i class="fa fa-comment mr-1"></i> Direct Message
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="dropdown-item" v-if="!(message.username === username)"
|
||||||
|
@click.prevent="muteUser()">
|
||||||
|
<i class="fa fa-comment-slash mr-1" :class="{
|
||||||
|
'has-text-success': isMuted,
|
||||||
|
'has-text-danger': !isMuted
|
||||||
|
}"></i>
|
||||||
|
<span v-if="isMuted">Unmute user</span>
|
||||||
|
<span v-else>Mute user</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="dropdown-item" v-if="message.username === username || isOp"
|
||||||
|
@click.prevent="takeback()" :data-msgid="message.msgID">
|
||||||
|
<i class="fa fa-rotate-left has-text-danger mr-1"></i>
|
||||||
|
Take back
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" class="dropdown-item" v-if="message.username !== username"
|
||||||
|
@click.prevent="removeMessage()">
|
||||||
|
<i class="fa fa-trash mr-1"></i>
|
||||||
|
Hide message
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Report button -->
|
||||||
|
<a href="#" class="dropdown-item" v-if="reportEnabled && message.username !== username"
|
||||||
|
@click.prevent="reportMessage()">
|
||||||
|
<i class="fa fa-flag mr-1" :class="{ 'has-text-danger': !message.reported }"></i>
|
||||||
|
<span v-if="message.reported">Reported</span>
|
||||||
|
<span v-else>Report</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emoji reactions -->
|
||||||
|
<div class="column dropdown is-right"
|
||||||
|
:class="{ 'is-up': position >= 2, 'is-active': showEmojiPicker }"
|
||||||
|
@click="showEmojiPicker = true">
|
||||||
|
<div class="dropdown-trigger">
|
||||||
|
<button type="button" class="button is-small px-2" aria-haspopup="true"
|
||||||
|
:aria-controls="`react-menu-${message.msgID}`" @click="hideEmojiPicker()">
|
||||||
|
<small>
|
||||||
|
<i class="fa fa-heart has-text-grey"></i>
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu" :id="`react-menu-${message.msgID}`" role="menu">
|
||||||
|
<div class="dropdown-content p-0">
|
||||||
|
<!-- Emoji reactions menu -->
|
||||||
|
<EmojiPicker v-if="showEmojiPicker" :native="true" :display-recent="true"
|
||||||
|
:disable-skin-tones="true" :additional-groups="customEmojiGroups"
|
||||||
|
:group-names="{ frequently_used: 'Frequently Used' }" theme="auto" @select="onSelectEmoji">
|
||||||
|
</EmojiPicker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
</style>
|
|
||||||
|
|
415
src/components/ProfileModal.vue
Normal file
|
@ -0,0 +1,415 @@
|
||||||
|
<script>
|
||||||
|
import VideoFlag from '../lib/VideoFlag';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
visible: Boolean,
|
||||||
|
jwt: String, // caller's JWT token for authorization
|
||||||
|
user: Object, // the user we are viewing
|
||||||
|
username: String, // the local user
|
||||||
|
isViewerOp: Boolean, // the viewer is an operator (show buttons)
|
||||||
|
websiteUrl: String,
|
||||||
|
isDnd: Boolean,
|
||||||
|
isMuted: Boolean,
|
||||||
|
isBooted: Boolean,
|
||||||
|
profileWebhookEnabled: Boolean,
|
||||||
|
vipConfig: Object, // VIP config settings for BareRTC
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
busy: false,
|
||||||
|
|
||||||
|
// Profile data
|
||||||
|
profileFields: [],
|
||||||
|
|
||||||
|
// Ban account data
|
||||||
|
banModalVisible: false,
|
||||||
|
banReason: "",
|
||||||
|
banDuration: 24,
|
||||||
|
|
||||||
|
// Error messaging from backend
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visible() {
|
||||||
|
if (this.visible) {
|
||||||
|
this.refresh();
|
||||||
|
} else {
|
||||||
|
this.profileFields = [];
|
||||||
|
this.error = null;
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
profileURL() {
|
||||||
|
if (this.user.profileURL) {
|
||||||
|
return this.urlFor(this.user.profileURL);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
avatarURL() {
|
||||||
|
if (this.user.avatar) {
|
||||||
|
return this.urlFor(this.user.avatar);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
nickname() {
|
||||||
|
if (this.user.nickname) {
|
||||||
|
return this.user.nickname;
|
||||||
|
}
|
||||||
|
return this.user.username;
|
||||||
|
},
|
||||||
|
isOnBlueCam() {
|
||||||
|
// User is broadcasting a cam and is not NSFW.
|
||||||
|
if ((this.user.video & VideoFlag.Active) && !(this.user.video & VideoFlag.NSFW)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
isOnCamera() {
|
||||||
|
// User's camera is enabled.
|
||||||
|
return (this.user.video & VideoFlag.Active);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
refresh() {
|
||||||
|
if (!this.profileWebhookEnabled) return;
|
||||||
|
if (!this.user || !this.user?.username) return;
|
||||||
|
this.busy = true;
|
||||||
|
return fetch("/api/profile", {
|
||||||
|
method: "POST",
|
||||||
|
mode: "same-origin",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
"JWTToken": this.jwt,
|
||||||
|
"Username": this.user?.username,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.Error) {
|
||||||
|
this.error = data.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.ProfileFields != undefined) {
|
||||||
|
this.profileFields = data.ProfileFields;
|
||||||
|
}
|
||||||
|
}).catch(resp => {
|
||||||
|
this.error = resp;
|
||||||
|
}).finally(() => {
|
||||||
|
this.busy = false;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.$emit("cancel");
|
||||||
|
},
|
||||||
|
|
||||||
|
openProfile() {
|
||||||
|
let url = this.profileURL;
|
||||||
|
if (url) {
|
||||||
|
window.open(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openDMs() {
|
||||||
|
this.cancel();
|
||||||
|
this.$emit('send-dm', {
|
||||||
|
username: this.user.username,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
muteUser() {
|
||||||
|
this.$emit('mute-user', this.user.username);
|
||||||
|
},
|
||||||
|
|
||||||
|
bootUser() {
|
||||||
|
this.$emit('boot-user', this.user.username);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Operator commands (may be rejected by server if not really Op)
|
||||||
|
markNsfw() {
|
||||||
|
if (!window.confirm("Mark this user's webcam as 'Explicit'?")) return;
|
||||||
|
this.$emit('send-command', `/nsfw ${this.user.username}`);
|
||||||
|
|
||||||
|
// Close the modal immediately: our view of the user's cam data is a copy
|
||||||
|
// and we can't follow the current value.
|
||||||
|
this.cancel();
|
||||||
|
},
|
||||||
|
cutCamera() {
|
||||||
|
if (!window.confirm("Make this user stop broadcasting their camera?")) return;
|
||||||
|
this.$emit('send-command', `/cut ${this.user.username}`);
|
||||||
|
this.cancel();
|
||||||
|
},
|
||||||
|
kickUser() {
|
||||||
|
if (!window.confirm("Really kick this user from the chat room?")) return;
|
||||||
|
this.$emit('send-command', `/kick ${this.user.username}`);
|
||||||
|
},
|
||||||
|
banUser() {
|
||||||
|
this.banModalVisible = true;
|
||||||
|
this.banReason = "";
|
||||||
|
this.banDuration = 24;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
let reason = document.querySelector("#ban_reason");
|
||||||
|
if (reason) {
|
||||||
|
reason.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
confirmBan() {
|
||||||
|
// Send the ban command.
|
||||||
|
this.$emit('send-command', `/ban ${this.user.username} ${this.banDuration}`);
|
||||||
|
|
||||||
|
// Also send an admin report to the main website.
|
||||||
|
this.$emit('report', {
|
||||||
|
message: {
|
||||||
|
channel: `n/a`,
|
||||||
|
username: this.user.username,
|
||||||
|
at: new Date(),
|
||||||
|
message: 'Ban reason: ' + this.banReason,
|
||||||
|
},
|
||||||
|
classification: 'User banned by admin',
|
||||||
|
comment: `A chat admin has banned ${this.user.username} from the chat room!\n\n` +
|
||||||
|
`* Chat admin: <a href="/u/${this.username}">${this.username}</a>\n` +
|
||||||
|
`* Reason: ${this.banReason}\n` +
|
||||||
|
`* Duration: ${this.banDuration} hours`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.banModalVisible = false;
|
||||||
|
this.cancel();
|
||||||
|
},
|
||||||
|
|
||||||
|
urlFor(url) {
|
||||||
|
// Prepend the base websiteUrl if the given URL is relative.
|
||||||
|
if (url.match(/^https?:/i)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return this.websiteUrl.replace(/\/+$/, "") + url;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Profile Card Modal -->
|
||||||
|
<div class="modal" :class="{ 'is-active': visible }">
|
||||||
|
<div class="modal-background" @click="cancel()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header has-background-success">
|
||||||
|
<p class="card-header-title">Profile Card</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
|
||||||
|
<!-- Avatar and name/username media -->
|
||||||
|
<div class="media mb-0">
|
||||||
|
<div class="media-left">
|
||||||
|
<a :href="profileURL"
|
||||||
|
@click.prevent="openProfile()"
|
||||||
|
:class="{ 'cursor-default': !profileURL }">
|
||||||
|
<figure class="image is-96x96">
|
||||||
|
<img v-if="avatarURL"
|
||||||
|
:src="avatarURL">
|
||||||
|
<img v-else src="/static/img/shy.png">
|
||||||
|
</figure>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="media-content">
|
||||||
|
<strong>
|
||||||
|
<!-- User nickname/display name -->
|
||||||
|
{{ nickname }}
|
||||||
|
</strong>
|
||||||
|
<div>
|
||||||
|
<small>
|
||||||
|
<a v-if="profileURL"
|
||||||
|
:href="profileURL" target="_blank"
|
||||||
|
class="has-text-grey">
|
||||||
|
@{{ user.username }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="has-text-grey">@{{ user.username }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User badges -->
|
||||||
|
<div v-if="user.op || user.vip || user.emoji" class="mt-4">
|
||||||
|
<!-- Emoji icon -->
|
||||||
|
<span v-if="user.emoji" class="mr-2">
|
||||||
|
{{ user.emoji }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Operator? -->
|
||||||
|
<span v-if="user.op" class="tag is-warning is-light mr-2">
|
||||||
|
<i class="fa fa-peace mr-1"></i> Operator
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- VIP? -->
|
||||||
|
<span v-if="vipConfig && user.vip" class="tag is-success is-light mr-2"
|
||||||
|
:title="vipConfig.Name">
|
||||||
|
<i class="mr-1" :class="vipConfig.Icon"></i>
|
||||||
|
{{ vipConfig.Name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div v-if="user.username !== username" class="mt-4">
|
||||||
|
<!-- DMs button -->
|
||||||
|
<button type="button"
|
||||||
|
class="button is-small px-2 mb-1"
|
||||||
|
@click="openDMs()"
|
||||||
|
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
|
||||||
|
:disabled="isDnd">
|
||||||
|
<i class="fa mr-1" :class="{'fa-comment': !isDnd, 'fa-comment-slash': isDnd}"></i>
|
||||||
|
Direct Message
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mute button -->
|
||||||
|
<button type="button"
|
||||||
|
class="button is-small px-2 ml-1 mb-1"
|
||||||
|
@click="muteUser()" title="Mute user">
|
||||||
|
<i class="fa fa-comment-slash mr-1" :class="{
|
||||||
|
'has-text-success': isMuted,
|
||||||
|
'has-text-danger': !isMuted
|
||||||
|
}"></i>
|
||||||
|
{{ isMuted ? "Unmute" : "Mute" }} Messages
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Boot button -->
|
||||||
|
<button type="button"
|
||||||
|
class="button is-small px-2 ml-1 mb-1"
|
||||||
|
@click="bootUser()" title="Boot user off your webcam">
|
||||||
|
<i class="fa fa-user-xmark mr-1" :class="{
|
||||||
|
'has-text-danger': !isBooted,
|
||||||
|
'has-text-success': isBooted,
|
||||||
|
}"></i>
|
||||||
|
{{ isBooted ? 'Allow to watch my webcam' : "Don't allow to watch my webcam" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Admin actions -->
|
||||||
|
<div v-if="isViewerOp" class="mt-1">
|
||||||
|
<!-- Mark camera NSFW -->
|
||||||
|
<button v-if="isOnBlueCam"
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
|
||||||
|
@click="markNsfw()" title="Mark their camera as Explicit (red).">
|
||||||
|
<i class="fa fa-video mr-1 has-text-danger"></i>
|
||||||
|
Mark camera as Explicit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Cut camera -->
|
||||||
|
<button v-if="isOnCamera"
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
|
||||||
|
@click="cutCamera()" title="Turn their camera off.">
|
||||||
|
<i class="fa fa-stop mr-1 has-text-danger"></i>
|
||||||
|
Cut camera
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Kick user -->
|
||||||
|
<button type="button"
|
||||||
|
class="button is-small is-outlined is-danger has-text-dark px-2 mr-1 mb-1"
|
||||||
|
@click="kickUser()" title="Kick this user from the chat room.">
|
||||||
|
<i class="fa fa-shoe-prints mr-1 has-text-danger"></i>
|
||||||
|
Kick from the room
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Ban user -->
|
||||||
|
<button type="button"
|
||||||
|
class="button is-small is-outlined is-danger has-text-dark px-2 mb-1"
|
||||||
|
@click="banUser()" title="Ban this user from the chat room for 24 hours.">
|
||||||
|
<i class="fa fa-clock mr-1 has-text-danger"></i>
|
||||||
|
Ban from chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Fields spinner/error -->
|
||||||
|
<div class="notification is-info is-light p-2 my-2" v-if="busy">
|
||||||
|
<i class="fa fa-spinner fa-spin mr-2"></i>
|
||||||
|
Loading profile details...
|
||||||
|
</div>
|
||||||
|
<div class="notification is-danger is-light p-2 my-2" v-else-if="error">
|
||||||
|
<i class="fa fa-exclamation-triangle mr-2"></i>
|
||||||
|
Error loading profile details:
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Fields -->
|
||||||
|
<div class="columns is-multiline is-mobile mt-3"
|
||||||
|
v-else-if="profileFields.length > 0">
|
||||||
|
<div class="column py-1"
|
||||||
|
v-for="(field, i) in profileFields"
|
||||||
|
v-bind:key="field.Name"
|
||||||
|
:class="{'is-half': i < profileFields.length-1}">
|
||||||
|
<strong>{{ field.Name }}:</strong>
|
||||||
|
{{ field.Value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="card-footer">
|
||||||
|
<a :href="profileURL" target="_blank"
|
||||||
|
v-if="profileURL" class="card-footer-item"
|
||||||
|
@click="cancel()">
|
||||||
|
Full profile <i class="fa fa-external-link ml-2"></i>
|
||||||
|
</a>
|
||||||
|
<a href="#" @click.prevent="cancel()" class="card-footer-item">
|
||||||
|
Close
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ban User Modal (for chat admins) -->
|
||||||
|
<div class="modal" :class="{ 'is-active': banModalVisible }">
|
||||||
|
<div class="modal-background" @click="banModalVisible = false"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<form @submit.prevent="confirmBan">
|
||||||
|
<div class="card">
|
||||||
|
<header class="card-header has-background-danger">
|
||||||
|
<p class="card-header-title">Ban User</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="ban_reason">Reason for the ban:</label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
id="ban_reason"
|
||||||
|
placeholder="Please describe why this user will be banned."
|
||||||
|
v-model="banReason"
|
||||||
|
required>
|
||||||
|
<p class="help">
|
||||||
|
This reason is NOT shown to the banned user, but will be sent to the main website
|
||||||
|
in an admin report so that it may be documented in this user's history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="ban_duration">How long for the ban? (1-96 hours)</label>
|
||||||
|
<input type="number" min="1" max="96" v-model="banDuration" class="input">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field has-text-centered">
|
||||||
|
<button type="submit" class="button is-danger">
|
||||||
|
Confirm Ban
|
||||||
|
</button>
|
||||||
|
<a href="#" @click.prevent="banModalVisible = false" class="button ml-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -71,7 +71,7 @@ export default {
|
||||||
<label class="label" for="classification">Report classification:</label>
|
<label class="label" for="classification">Report classification:</label>
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="classification" v-model="classification" :disabled="busy">
|
<select id="classification" v-model="classification" :disabled="busy">
|
||||||
<option v-for="i in reportClassifications" :value="i">{{ i }}</option>
|
<option v-for="i in reportClassifications" v-bind:key="i" :value="i">{{ i }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
250
src/components/VideoFeed.vue
Normal file
|
@ -0,0 +1,250 @@
|
||||||
|
<script>
|
||||||
|
import Slider from 'vue3-slider';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
localVideo: Boolean, // is our local webcam (not other's camera)
|
||||||
|
poppedOut: Boolean, // Video is popped-out and draggable
|
||||||
|
username: String, // username related to this video
|
||||||
|
isExplicit: Boolean, // camera is marked Explicit
|
||||||
|
isMuted: Boolean, // camera is muted on our end
|
||||||
|
isSourceMuted: Boolean, // camera is muted on the broadcaster's end
|
||||||
|
isWatchingMe: Boolean, // other video is watching us back
|
||||||
|
isFrozen: Boolean, // video is detected as frozen
|
||||||
|
isSpeaking: Boolean, // video is registering audio
|
||||||
|
watermarkImage: Image, // watermark image to overlay (nullable)
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Slider,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// Volume slider
|
||||||
|
volume: 100,
|
||||||
|
|
||||||
|
// Volume change debounce
|
||||||
|
volumeDebounce: null,
|
||||||
|
|
||||||
|
// Mouse over status
|
||||||
|
mouseOver: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
containerID() {
|
||||||
|
return this.videoID + '-container';
|
||||||
|
},
|
||||||
|
videoID() {
|
||||||
|
return this.localVideo ? 'localVideo' : `videofeed-${this.username}`;
|
||||||
|
},
|
||||||
|
textColorClass() {
|
||||||
|
return this.isExplicit ? 'has-text-camera-red' : 'has-text-camera-blue';
|
||||||
|
},
|
||||||
|
muteButtonClass() {
|
||||||
|
let classList = [
|
||||||
|
'button is-small ml-1 px-2',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this.isMuted) {
|
||||||
|
classList.push('is-danger');
|
||||||
|
} else {
|
||||||
|
classList.push('is-success is-outlined');
|
||||||
|
}
|
||||||
|
return classList.join(' ');
|
||||||
|
},
|
||||||
|
muteIconClass() {
|
||||||
|
if (this.localVideo) {
|
||||||
|
return this.isMuted ? 'fa-microphone-slash' : 'fa-microphone';
|
||||||
|
}
|
||||||
|
return this.isMuted ? 'fa-volume-xmark' : 'fa-volume-high';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeVideo() {
|
||||||
|
// Note: closeVideo only available for OTHER peoples cameras.
|
||||||
|
// Closes the WebRTC connection as the offerer.
|
||||||
|
this.$emit('close-video', this.username, 'offerer');
|
||||||
|
},
|
||||||
|
|
||||||
|
reopenVideo() {
|
||||||
|
// Note: goes into openVideo(username, force)
|
||||||
|
this.$emit('reopen-video', this.username, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
openProfile() {
|
||||||
|
this.$emit('open-profile', this.username);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle the Mute button
|
||||||
|
muteVideo() {
|
||||||
|
this.$emit('mute-video', this.username);
|
||||||
|
},
|
||||||
|
|
||||||
|
popoutVideo() {
|
||||||
|
this.$emit('popout', this.username);
|
||||||
|
},
|
||||||
|
|
||||||
|
fullscreen(force=false) {
|
||||||
|
// If we are popped-out, pop back in before full screen.
|
||||||
|
if (this.poppedOut && !force) {
|
||||||
|
this.popoutVideo();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.fullscreen(true);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let $elem = document.getElementById(this.containerID);
|
||||||
|
if ($elem) {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else if ($elem.requestFullscreen) {
|
||||||
|
$elem.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
window.alert("Fullscreen not supported by your browser.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
volumeChanged() {
|
||||||
|
if (this.volumeDebounce !== null) {
|
||||||
|
clearTimeout(this.volumeDebounce);
|
||||||
|
}
|
||||||
|
this.volumeDebounce = setTimeout(() => {
|
||||||
|
this.$emit('set-volume', this.username, this.volume);
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="feed" :id="containerID" :class="{
|
||||||
|
'popped-out': poppedOut,
|
||||||
|
'popped-in': !poppedOut,
|
||||||
|
}" @mouseover="mouseOver = true" @mouseleave="mouseOver = false">
|
||||||
|
<video
|
||||||
|
:id="videoID"
|
||||||
|
autoplay
|
||||||
|
disablepictureinpicture
|
||||||
|
playsinline
|
||||||
|
oncontextmenu="return false;"
|
||||||
|
:muted="localVideo"></video>
|
||||||
|
|
||||||
|
<!-- Watermark layer -->
|
||||||
|
<div v-if="watermarkImage">
|
||||||
|
<img :src="watermarkImage" class="watermark">
|
||||||
|
<img :src="watermarkImage" class="corner-watermark seethru invert-color">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caption -->
|
||||||
|
<div class="caption" :class="textColorClass">
|
||||||
|
<i class="fa fa-microphone-slash mr-1 has-text-grey" v-if="isSourceMuted"></i>
|
||||||
|
<a href="#" @click.prevent="openProfile" :class="textColorClass">{{ username }}</a>
|
||||||
|
<i class="fa fa-people-arrows ml-1 has-text-grey is-size-7" :title="username + ' is watching your camera too'"
|
||||||
|
v-if="isWatchingMe"></i>
|
||||||
|
|
||||||
|
<!-- Frozen stream detection -->
|
||||||
|
<a class="fa fa-mountain ml-1" href="#" v-if="!localVideo && isFrozen" style="color: #00FFFF"
|
||||||
|
@click.prevent="reopenVideo()" title="Frozen video detected!"></a>
|
||||||
|
|
||||||
|
<!-- Is speaking -->
|
||||||
|
<span v-if="isSpeaking" class="ml-1" title="Speaking">
|
||||||
|
<i class="fa fa-volume-high has-text-info"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close button (others' videos only) -->
|
||||||
|
<div class="close" v-if="!localVideo" :class="{'seethru': !mouseOver}">
|
||||||
|
<a href="#" class="button is-small is-danger is-outlined px-2" title="Close video" @click.prevent="closeVideo()">
|
||||||
|
<i class="fa fa-close"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="controls">
|
||||||
|
<!-- Mute Button -->
|
||||||
|
<button type="button" :class="muteButtonClass"
|
||||||
|
@click="muteVideo()">
|
||||||
|
<i class="fa" :class="muteIconClass"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Pop-out Video -->
|
||||||
|
<button type="button" class="button is-small is-light is-outlined p-2 ml-2" title="Pop out"
|
||||||
|
:class="{'seethru': !mouseOver}"
|
||||||
|
@click="popoutVideo()">
|
||||||
|
<i class="fa fa-up-right-from-square"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Full screen. -->
|
||||||
|
<button type="button" class="button is-small is-light is-outlined p-2 ml-2" title="Go full screen"
|
||||||
|
:class="{'seethru': !mouseOver}"
|
||||||
|
@click="fullscreen()">
|
||||||
|
<i class="fa fa-expand"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume slider -->
|
||||||
|
<div class="volume-slider" v-show="!localVideo && !isMuted && mouseOver">
|
||||||
|
<Slider v-model="volume" color="#00FF00" track-color="#006600" :min="0" :max="100" :step="1" :height="7"
|
||||||
|
orientation="vertical" @change="volumeChanged">
|
||||||
|
|
||||||
|
</Slider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.volume-slider {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
top: 30px;
|
||||||
|
bottom: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A background image behind video elements in case they don't load properly */
|
||||||
|
video {
|
||||||
|
background-image: url(/static/img/connection-error.png);
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Translucent controls until mouse over */
|
||||||
|
.seethru {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Watermark image */
|
||||||
|
.watermark {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 40%;
|
||||||
|
height: 40%;
|
||||||
|
opacity: 0.02;
|
||||||
|
animation-name: subtle-pulsate;
|
||||||
|
animation-duration: 10s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
.corner-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 20%;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
max-height: 20%;
|
||||||
|
}
|
||||||
|
.invert-color {
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animate the primary watermark to pulsate in opacity */
|
||||||
|
@keyframes subtle-pulsate {
|
||||||
|
0% { opacity: 0.02; }
|
||||||
|
50% { opacity: 0.04; }
|
||||||
|
100% { opacity: 0.02; }
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -8,11 +8,14 @@ export default {
|
||||||
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
|
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
|
||||||
isDnd: Boolean, // user is not accepting DMs
|
isDnd: Boolean, // user is not accepting DMs
|
||||||
isMuted: Boolean, // user is muted by current user
|
isMuted: Boolean, // user is muted by current user
|
||||||
|
isBlocked: Boolean, // user is blocked on your main website (can't be unmuted)
|
||||||
|
isBooted: Boolean, // user is booted by current user
|
||||||
vipConfig: Object, // VIP config settings for BareRTC
|
vipConfig: Object, // VIP config settings for BareRTC
|
||||||
isOp: Boolean, // current user is operator (can always DM)
|
isOp: Boolean, // current user is operator (can always DM)
|
||||||
isVideoNotAllowed: Boolean, // whether opening this camera is not allowed
|
isVideoNotAllowed: Boolean, // whether opening this camera is not allowed
|
||||||
videoIconClass: String, // CSS class for the open video icon
|
videoIconClass: String, // CSS class for the open video icon
|
||||||
isWatchingTab: Boolean, // is the "Watching" tab (replace video button w/ boot)
|
isWatchingTab: Boolean, // is the "Watching" tab (replace video button w/ boot)
|
||||||
|
statusMessage: Object, // StatusMessage controller
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -56,7 +59,7 @@ export default {
|
||||||
if ((this.user.video & VideoFlag.Active) && (this.user.video & VideoFlag.NSFW)) {
|
if ((this.user.video & VideoFlag.Active) && (this.user.video & VideoFlag.NSFW)) {
|
||||||
result += "is-danger is-outlined";
|
result += "is-danger is-outlined";
|
||||||
} else if ((this.user.video & VideoFlag.Active) && !(this.user.video & VideoFlag.NSFW)) {
|
} else if ((this.user.video & VideoFlag.Active) && !(this.user.video & VideoFlag.NSFW)) {
|
||||||
result += "is-info is-outlined";
|
result += "is-link is-outlined";
|
||||||
} else if (this.isVideoNotAllowed) {
|
} else if (this.isVideoNotAllowed) {
|
||||||
result += "cursor-notallowed";
|
result += "cursor-notallowed";
|
||||||
}
|
}
|
||||||
|
@ -79,6 +82,10 @@ export default {
|
||||||
parts.push(`${this.vipConfig.Name} only`);
|
parts.push(`${this.vipConfig.Name} only`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.user.video & VideoFlag.NonExplicit) {
|
||||||
|
parts.push("prefers non-explicit video");
|
||||||
|
}
|
||||||
|
|
||||||
return parts.join("; ");
|
return parts.join("; ");
|
||||||
},
|
},
|
||||||
avatarURL() {
|
avatarURL() {
|
||||||
|
@ -96,12 +103,31 @@ export default {
|
||||||
hasReactions() {
|
hasReactions() {
|
||||||
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
|
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Status icons
|
||||||
|
hasStatusIcon() {
|
||||||
|
return this.user.status !== 'online' && this.statusMessage != undefined;
|
||||||
|
},
|
||||||
|
statusIconClass() {
|
||||||
|
let status = this.statusMessage.getStatus(this.user.status);
|
||||||
|
return status.icon;
|
||||||
|
},
|
||||||
|
statusLabel() {
|
||||||
|
let status = this.statusMessage.getStatus(this.user.status);
|
||||||
|
return `${status.emoji} ${status.label}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openProfile() {
|
openProfile() {
|
||||||
let url = this.profileURL;
|
this.$emit('open-profile', this.user.username);
|
||||||
if (url) {
|
},
|
||||||
window.open(url);
|
|
||||||
|
// Directly open the profile page.
|
||||||
|
openProfilePage() {
|
||||||
|
if (this.profileURL) {
|
||||||
|
window.open(this.profileURL);
|
||||||
|
} else {
|
||||||
|
this.openProfile();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -141,49 +167,23 @@ export default {
|
||||||
<div class="column is-narrow pr-0" style="position: relative">
|
<div class="column is-narrow pr-0" style="position: relative">
|
||||||
<a :href="profileURL"
|
<a :href="profileURL"
|
||||||
@click.prevent="openProfile()"
|
@click.prevent="openProfile()"
|
||||||
:class="{ 'cursor-default': !profileURL }" class="p-0">
|
class="p-0">
|
||||||
<img v-if="avatarURL" :src="avatarURL" width="24" height="24" alt="">
|
<img v-if="avatarURL" :src="avatarURL" width="24" height="24" alt="">
|
||||||
<img v-else src="/static/img/shy.png" width="24" height="24">
|
<img v-else src="/static/img/shy.png" width="24" height="24">
|
||||||
|
|
||||||
<!-- Away symbol -->
|
<!-- Away symbol -->
|
||||||
<div v-if="user.status !== 'online'" class="status-away-icon">
|
<div v-if="hasStatusIcon" class="status-away-icon">
|
||||||
<i v-if="user.status === 'away'" class="fa fa-clock has-text-light"
|
<i :class="statusIconClass" class="has-text-light"
|
||||||
title="Status: Away"></i>
|
:title="'Status: ' + statusLabel"></i>
|
||||||
<i v-else-if="user.status === 'lunch'" class="fa fa-utensils has-text-light"
|
|
||||||
title="Status: Out to lunch"></i>
|
|
||||||
<i v-else-if="user.status === 'call'" class="fa fa-phone-volume has-text-light"
|
|
||||||
title="Status: On the phone"></i>
|
|
||||||
<i v-else-if="user.status === 'brb'" class="fa fa-stopwatch-20 has-text-light"
|
|
||||||
title="Status: Be right back"></i>
|
|
||||||
<i v-else-if="user.status === 'busy'" class="fa fa-briefcase has-text-light"
|
|
||||||
title="Status: Working"></i>
|
|
||||||
<i v-else-if="user.status === 'book'" class="fa fa-book has-text-light"
|
|
||||||
title="Status: Studying"></i>
|
|
||||||
<i v-else-if="user.status === 'gaming'"
|
|
||||||
class="fa fa-gamepad who-status-wide-icon-2 has-text-light"
|
|
||||||
title="Status: Gaming"></i>
|
|
||||||
<i v-else-if="user.status === 'idle'" class="fa-regular fa-moon has-text-light"
|
|
||||||
title="Status: Idle"></i>
|
|
||||||
<i v-else-if="user.status === 'horny'" class="fa fa-fire has-text-light"
|
|
||||||
title="Status: Horny"></i>
|
|
||||||
<i v-else-if="user.status === 'chatty'" class="fa fa-comment has-text-light"
|
|
||||||
title="Status: Chatty and sociable"></i>
|
|
||||||
<i v-else-if="user.status === 'introverted'" class="fa fa-spoon has-text-light"
|
|
||||||
title="Status: Introverted and quiet"></i>
|
|
||||||
<i v-else-if="user.status === 'exhibitionist'"
|
|
||||||
class="fa-regular fa-eye who-status-wide-icon-1 has-text-light"
|
|
||||||
title="Status: Watch me"></i>
|
|
||||||
<i v-else class="fa fa-clock has-text-light" :title="'Status: ' + user.status"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column pr-0 is-clipped" :class="{ 'pl-1': avatarURL }">
|
<div class="column pr-0 is-clipped" :class="{ 'pl-1': avatarURL }">
|
||||||
<strong class="truncate-text-line is-size-7"
|
<strong class="truncate-text-line is-size-7 cursor-pointer"
|
||||||
@click="openProfile()"
|
@click="openProfile()">
|
||||||
:class="{'cursor-pointer': profileURL}">
|
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</strong>
|
</strong>
|
||||||
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1" v-if="user.op"
|
<sup class="fa fa-peace has-text-warning is-size-7 ml-1" v-if="user.op"
|
||||||
title="Operator"></sup>
|
title="Operator"></sup>
|
||||||
<sup class="is-size-7 ml-1" :class="vipConfig.Icon" v-else-if="user.vip"
|
<sup class="is-size-7 ml-1" :class="vipConfig.Icon" v-else-if="user.vip"
|
||||||
:title="vipConfig.Name"></sup>
|
:title="vipConfig.Name"></sup>
|
||||||
|
@ -195,23 +195,23 @@ export default {
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Profile button -->
|
<!-- Profile button -->
|
||||||
<button type="button" v-if="profileURL" class="button is-small px-2 py-1"
|
<button type="button" class="button is-small px-2 py-1"
|
||||||
:class="profileButtonClass" @click="openProfile()"
|
:class="profileButtonClass" @click="openProfilePage()"
|
||||||
:title="'Open profile page' + (user.gender ? ` (gender: ${user.gender})` : '') + (user.vip ? ` (${vipConfig.Name})` : '')">
|
:title="'Open profile page' + (user.gender ? ` (gender: ${user.gender})` : '') + (user.vip ? ` (${vipConfig.Name})` : '')">
|
||||||
<i class="fa fa-user"></i>
|
<i class="fa fa-user"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Unmute User button (if muted) -->
|
<!-- Unmute User button (if muted) -->
|
||||||
<button type="button" v-if="isMuted" class="button is-small px-2 py-1"
|
<button type="button" v-if="isMuted && !isBlocked" class="button is-small px-2 py-1"
|
||||||
@click="muteUser()" title="This user is muted. Click to unmute them.">
|
@click="muteUser()" title="This user is muted. Click to unmute them.">
|
||||||
<i class="fa fa-comment-slash has-text-danger"></i>
|
<i class="fa fa-comment-slash has-text-danger"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- DM button (if not muted) -->
|
<!-- DM button (if not muted) -->
|
||||||
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
|
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
|
||||||
:disabled="user.username === username || (user.dnd && !isOp)"
|
:disabled="user.username === username || (user.dnd && !isOp) || (isBlocked && !isOp)"
|
||||||
:title="user.dnd ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
|
:title="(user.dnd || isBlocked) ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
|
||||||
<i class="fa" :class="{ 'fa-comment': !user.dnd, 'fa-comment-slash': user.dnd }"></i>
|
<i class="fa" :class="{ 'fa-comment': !(user.dnd || isBlocked), 'fa-comment-slash': user.dnd || isBlocked }"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Video button -->
|
<!-- Video button -->
|
||||||
|
|
356
src/lib/ChatClient.js
Normal file
|
@ -0,0 +1,356 @@
|
||||||
|
// WebSocket chat client handler.
|
||||||
|
class ChatClient {
|
||||||
|
/**
|
||||||
|
* Constructor for the client.
|
||||||
|
*
|
||||||
|
* @param usePolling: instead of WebSocket use the ajax polling API.
|
||||||
|
* @param onClientError: function to receive 'ChatClient' messages to
|
||||||
|
* add to the chat room (this.ChatClient())
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
usePolling=false,
|
||||||
|
onClientError,
|
||||||
|
|
||||||
|
username,
|
||||||
|
jwt, // JWT token for authorization
|
||||||
|
prefs, // User preferences for 'me' action (close DMs, etc)
|
||||||
|
|
||||||
|
// Chat Protocol handler functions for the caller.
|
||||||
|
onWho,
|
||||||
|
onMe,
|
||||||
|
onMessage,
|
||||||
|
onTakeback,
|
||||||
|
onReact,
|
||||||
|
onPresence,
|
||||||
|
onRing,
|
||||||
|
onOpen,
|
||||||
|
onCandidate,
|
||||||
|
onSDP,
|
||||||
|
onWatch,
|
||||||
|
onUnwatch,
|
||||||
|
onBlock,
|
||||||
|
onCut,
|
||||||
|
|
||||||
|
// Misc function registrations for callback.
|
||||||
|
onLoggedIn, // connection is fully established (first 'me' echo from server).
|
||||||
|
onNewJWT, // new JWT token from ping response
|
||||||
|
bulkMuteUsers, // Upload our blocklist on connect.
|
||||||
|
focusMessageBox, // Tell caller to focus the message entry box.
|
||||||
|
pushHistory,
|
||||||
|
}) {
|
||||||
|
this.usePolling = usePolling;
|
||||||
|
|
||||||
|
// Pointer to the 'ChatClient(message)' command from the main app.
|
||||||
|
this.ChatClient = onClientError;
|
||||||
|
|
||||||
|
this.username = username;
|
||||||
|
this.jwt = jwt;
|
||||||
|
this.prefs = prefs;
|
||||||
|
|
||||||
|
// Register the handler functions.
|
||||||
|
this.onWho = onWho;
|
||||||
|
this.onMe = onMe;
|
||||||
|
this.onMessage = onMessage;
|
||||||
|
this.onTakeback = onTakeback;
|
||||||
|
this.onReact = onReact;
|
||||||
|
this.onPresence = onPresence;
|
||||||
|
this.onRing = onRing;
|
||||||
|
this.onOpen = onOpen;
|
||||||
|
this.onCandidate = onCandidate;
|
||||||
|
this.onSDP = onSDP;
|
||||||
|
this.onWatch = onWatch;
|
||||||
|
this.onUnwatch = onUnwatch;
|
||||||
|
this.onBlock = onBlock;
|
||||||
|
this.onCut = onCut;
|
||||||
|
|
||||||
|
this.onLoggedIn = onLoggedIn;
|
||||||
|
this.onNewJWT = onNewJWT;
|
||||||
|
this.bulkMuteUsers = bulkMuteUsers;
|
||||||
|
this.focusMessageBox = focusMessageBox;
|
||||||
|
this.pushHistory = pushHistory;
|
||||||
|
|
||||||
|
// Received the first 'me' echo from server (to call onLoggedIn once per connection)
|
||||||
|
this.firstMe = false;
|
||||||
|
|
||||||
|
// WebSocket connection.
|
||||||
|
this.ws = {
|
||||||
|
conn: null,
|
||||||
|
connected: false,
|
||||||
|
|
||||||
|
// Disconnect spamming: don't retry too many times.
|
||||||
|
reconnect: true, // unless told to go away
|
||||||
|
disconnectLimit: 2,
|
||||||
|
disconnectCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Polling connection.
|
||||||
|
this.polling = {
|
||||||
|
username: "",
|
||||||
|
sessionID: "",
|
||||||
|
timeout: null, // setTimeout for next poll.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected polls if the client is connected.
|
||||||
|
connected() {
|
||||||
|
if (this.usePolling) {
|
||||||
|
return this.polling.timeout != null && this.polling.sessionID != "";
|
||||||
|
}
|
||||||
|
return this.ws.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from the server.
|
||||||
|
disconnect() {
|
||||||
|
if (this.usePolling) {
|
||||||
|
this.polling.sessionID = "";
|
||||||
|
this.polling.username = "";
|
||||||
|
this.stopPolling();
|
||||||
|
this.ChatClient("You have disconnected from the server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.connected = false;
|
||||||
|
this.ws.conn.close(1000, "server asked to close the connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common function to send a message to the server. The message
|
||||||
|
// is a JSON object before stringify.
|
||||||
|
send(message) {
|
||||||
|
if (this.usePolling) {
|
||||||
|
fetch("/poll", {
|
||||||
|
method: "POST",
|
||||||
|
mode: "same-origin",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: this.polling.username,
|
||||||
|
session_id: this.polling.sessionID,
|
||||||
|
msg: message,
|
||||||
|
})
|
||||||
|
}).then(resp => resp.json()).then(resp => {
|
||||||
|
console.log(resp);
|
||||||
|
|
||||||
|
// Store sessionID information.
|
||||||
|
this.polling.sessionID = resp.session_id;
|
||||||
|
this.polling.username = resp.username;
|
||||||
|
for (let msg of resp.messages) {
|
||||||
|
this.handle(msg);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
this.ChatClient("Error from polling API: " + err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ws.connected) {
|
||||||
|
this.ChatClient("Couldn't send WebSocket message: not connected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(message) !== "string") {
|
||||||
|
message = JSON.stringify(message);
|
||||||
|
}
|
||||||
|
this.ws.conn.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common function to handle a message from the server.
|
||||||
|
handle(msg) {
|
||||||
|
switch (msg.action) {
|
||||||
|
case "who":
|
||||||
|
this.onWho(msg);
|
||||||
|
break;
|
||||||
|
case "me":
|
||||||
|
this.onMe(msg);
|
||||||
|
|
||||||
|
// The first me?
|
||||||
|
if (!this.firstMe) {
|
||||||
|
this.firstMe = true;
|
||||||
|
this.onLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "message":
|
||||||
|
this.onMessage(msg);
|
||||||
|
break;
|
||||||
|
case "takeback":
|
||||||
|
this.onTakeback(msg);
|
||||||
|
break;
|
||||||
|
case "react":
|
||||||
|
this.onReact(msg);
|
||||||
|
break;
|
||||||
|
case "presence":
|
||||||
|
this.onPresence(msg);
|
||||||
|
break;
|
||||||
|
case "ring":
|
||||||
|
this.onRing(msg);
|
||||||
|
break;
|
||||||
|
case "open":
|
||||||
|
this.onOpen(msg);
|
||||||
|
break;
|
||||||
|
case "candidate":
|
||||||
|
this.onCandidate(msg);
|
||||||
|
break;
|
||||||
|
case "sdp":
|
||||||
|
this.onSDP(msg);
|
||||||
|
break;
|
||||||
|
case "watch":
|
||||||
|
this.onWatch(msg);
|
||||||
|
break;
|
||||||
|
case "unwatch":
|
||||||
|
this.onUnwatch(msg);
|
||||||
|
break;
|
||||||
|
case "block":
|
||||||
|
this.onBlock(msg);
|
||||||
|
break;
|
||||||
|
case "cut":
|
||||||
|
this.onCut(msg);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
this.pushHistory({
|
||||||
|
channel: msg.channel,
|
||||||
|
username: msg.username || 'Internal Server Error',
|
||||||
|
message: msg.message,
|
||||||
|
isChatServer: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
this.onWho({ whoList: [] });
|
||||||
|
this.ws.reconnect = false;
|
||||||
|
this.disconnect();
|
||||||
|
break;
|
||||||
|
case "ping":
|
||||||
|
// New JWT token?
|
||||||
|
if (msg.jwt) {
|
||||||
|
this.onNewJWT(msg.jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset disconnect retry counter: if we were on long enough to get
|
||||||
|
// a ping, we're well connected and can reconnect no matter how many
|
||||||
|
// times the chat server is rebooted.
|
||||||
|
this.ws.disconnectCount = 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("Unexpected action: %s", JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial the WebSocket.
|
||||||
|
dial() {
|
||||||
|
// Polling API?
|
||||||
|
if (this.usePolling) {
|
||||||
|
this.ChatClient("Connecting to the server via polling API...");
|
||||||
|
this.startPolling();
|
||||||
|
|
||||||
|
// Log in now.
|
||||||
|
this.send({
|
||||||
|
action: "login",
|
||||||
|
username: this.username,
|
||||||
|
jwt: this.jwt.token,
|
||||||
|
dnd: this.prefs.closeDMs,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ChatClient("Establishing connection to server...");
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const conn = new WebSocket(`${proto}://${location.host}/ws`);
|
||||||
|
|
||||||
|
conn.addEventListener("close", ev => {
|
||||||
|
// Lost connection to server - scrub who list.
|
||||||
|
this.onWho({ whoList: [] });
|
||||||
|
|
||||||
|
this.ws.connected = false;
|
||||||
|
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
|
||||||
|
|
||||||
|
this.ws.disconnectCount++;
|
||||||
|
if (this.ws.disconnectCount > this.ws.disconnectLimit) {
|
||||||
|
this.ChatClient(
|
||||||
|
`It seems there's a problem connecting to the server. Please try some other time.<br><br>` +
|
||||||
|
`If you experience this problem frequently, try going into the Chat Settings 'Misc' tab ` +
|
||||||
|
`and switch to the 'Polling' Server Connection method.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ws.reconnect) {
|
||||||
|
if (ev.code !== 1001 && ev.code !== 1000) {
|
||||||
|
this.ChatClient("Reconnecting in 5s");
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dial();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.addEventListener("open", () => {
|
||||||
|
this.ws.connected = true;
|
||||||
|
this.ChatClient("Websocket connected!");
|
||||||
|
|
||||||
|
// Upload our blocklist to the server before login. This resolves a bug where if a block
|
||||||
|
// was added recently (other user still online in chat), that user would briefly see your
|
||||||
|
// "has entered the room" message followed by you immediately not being online.
|
||||||
|
this.bulkMuteUsers();
|
||||||
|
|
||||||
|
// Tell the server our username.
|
||||||
|
this.send({
|
||||||
|
action: "login",
|
||||||
|
username: this.username,
|
||||||
|
jwt: this.jwt.token,
|
||||||
|
dnd: this.prefs.closeDMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus the message entry box.
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.focusMessageBox();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.addEventListener("message", ev => {
|
||||||
|
if (typeof ev.data !== "string") {
|
||||||
|
console.error("unexpected message type", typeof ev.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = JSON.parse(ev.data);
|
||||||
|
this.handle(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.conn = conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the polling interval.
|
||||||
|
startPolling() {
|
||||||
|
if (!this.usePolling) return;
|
||||||
|
this.stopPolling();
|
||||||
|
|
||||||
|
this.polling.timeout = setTimeout(() => {
|
||||||
|
this.poll();
|
||||||
|
this.startPolling();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll the API.
|
||||||
|
poll() {
|
||||||
|
if (!this.usePolling) {
|
||||||
|
this.stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.send({
|
||||||
|
action: "ping",
|
||||||
|
});
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop polling.
|
||||||
|
stopPolling() {
|
||||||
|
if (this.polling.timeout != null) {
|
||||||
|
clearTimeout(this.polling.timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatClient;
|
|
@ -2,10 +2,12 @@
|
||||||
const keys = {
|
const keys = {
|
||||||
'fontSizeClass': String, // Text magnification
|
'fontSizeClass': String, // Text magnification
|
||||||
'videoScale': String, // Video magnification (CSS classnames)
|
'videoScale': String, // Video magnification (CSS classnames)
|
||||||
|
'messageStyle': String, // Message display style (cards, compact, etc.)
|
||||||
'imageDisplaySetting': String, // Show/hide/expand image preference
|
'imageDisplaySetting': String, // Show/hide/expand image preference
|
||||||
'scrollback': Number, // Scrollback buffer (int)
|
'scrollback': Number, // Scrollback buffer (int)
|
||||||
'preferredDeviceNames': Object, // Webcam/mic device names (object, keys video,audio)
|
'preferredDeviceNames': Object, // Webcam/mic device names (object, keys video,audio)
|
||||||
'whoSort': String, // user's preferred sort order for the Who List
|
'whoSort': String, // user's preferred sort order for the Who List
|
||||||
|
'theme': String, // light, dark, or auto theme
|
||||||
|
|
||||||
// Webcam settings (booleans)
|
// Webcam settings (booleans)
|
||||||
'videoMutual': Boolean,
|
'videoMutual': Boolean,
|
||||||
|
@ -13,13 +15,19 @@ const keys = {
|
||||||
'videoAutoMute': Boolean,
|
'videoAutoMute': Boolean,
|
||||||
'videoVipOnly': Boolean,
|
'videoVipOnly': Boolean,
|
||||||
'videoExplicit': Boolean, // whether the user turns explicit on by default
|
'videoExplicit': Boolean, // whether the user turns explicit on by default
|
||||||
|
'videoNonExplicit': Boolean, // user prefers not to see explicit
|
||||||
|
'rememberExpresslyClosed': Boolean,
|
||||||
|
'autoMuteWebcams': Boolean, // automatically mute other peoples' webcam audio feeds
|
||||||
|
'videoAutoShare': Boolean, // automatically share your webcam on page load
|
||||||
|
|
||||||
// Booleans
|
// Booleans
|
||||||
|
'usePolling': Boolean, // use the polling API instead of WebSocket
|
||||||
'joinMessages': Boolean,
|
'joinMessages': Boolean,
|
||||||
'exitMessages': Boolean,
|
'exitMessages': Boolean,
|
||||||
'watchNotif': Boolean,
|
'watchNotif': Boolean,
|
||||||
'muteSounds': Boolean,
|
'muteSounds': Boolean,
|
||||||
'closeDMs': Boolean, // close unsolicited DMs
|
'closeDMs': Boolean, // close unsolicited DMs
|
||||||
|
'debug': Boolean, // Debug views enabled (admin only)
|
||||||
|
|
||||||
// Don't Show Again on NSFW modals.
|
// Don't Show Again on NSFW modals.
|
||||||
'skip-nsfw-modal': Boolean,
|
'skip-nsfw-modal': Boolean,
|
||||||
|
@ -32,14 +40,6 @@ class UserSettings {
|
||||||
// found in localStorage on page load.
|
// found in localStorage on page load.
|
||||||
for (let key of Object.keys(keys)) {
|
for (let key of Object.keys(keys)) {
|
||||||
if (localStorage[key] != undefined) {
|
if (localStorage[key] != undefined) {
|
||||||
switch (keys[key]) {
|
|
||||||
case String:
|
|
||||||
this[key] = localStorage[key];
|
|
||||||
case Number:
|
|
||||||
this[key] = parseInt(localStorage[key]);
|
|
||||||
case Boolean:
|
|
||||||
this[key] = localStorage[key] === "true";
|
|
||||||
case Object:
|
|
||||||
try {
|
try {
|
||||||
this[key] = JSON.parse(localStorage[key]);
|
this[key] = JSON.parse(localStorage[key]);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
@ -48,7 +48,6 @@ class UserSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log("LocalStorage: Loaded settings", this);
|
console.log("LocalStorage: Loaded settings", this);
|
||||||
}
|
}
|
||||||
|
|
212
src/lib/StatusMessage.js
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
// Available status options.
|
||||||
|
const StatusOptions = [
|
||||||
|
{
|
||||||
|
category: "Status",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "online",
|
||||||
|
label: "Active",
|
||||||
|
emoji: "☀️",
|
||||||
|
icon: "fa fa-clock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "away",
|
||||||
|
label: "Away",
|
||||||
|
emoji: "🕒",
|
||||||
|
icon: "fa fa-clock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "brb",
|
||||||
|
label: "Be right back",
|
||||||
|
emoji: "⏰",
|
||||||
|
icon: "fa fa-stopwatch-20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "afk",
|
||||||
|
label: "Away from keyboard",
|
||||||
|
emoji: "⌨️",
|
||||||
|
icon: "fa fa-keyboard who-status-wide-icon-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lunch",
|
||||||
|
label: "Out to lunch",
|
||||||
|
emoji: "🍴",
|
||||||
|
icon: "fa fa-utensils"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "call",
|
||||||
|
label: "On the phone",
|
||||||
|
emoji: "📞",
|
||||||
|
icon: "fa fa-phone-volume"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "busy",
|
||||||
|
label: "Working",
|
||||||
|
emoji: "💼",
|
||||||
|
icon: "fa fa-briefcase"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "book",
|
||||||
|
label: "Studying",
|
||||||
|
emoji: "📖",
|
||||||
|
icon: "fa fa-book"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gaming",
|
||||||
|
label: "Gaming",
|
||||||
|
emoji: "🎮",
|
||||||
|
icon: "fa fa-gamepad who-status-wide-icon-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "movie",
|
||||||
|
label: "Watching a movie",
|
||||||
|
emoji: "🎞️",
|
||||||
|
icon: "fa fa-film"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "travel",
|
||||||
|
label: "Traveling",
|
||||||
|
emoji: "✈️",
|
||||||
|
icon: "fa fa-plane"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hidden/special statuses
|
||||||
|
{
|
||||||
|
name: "idle",
|
||||||
|
label: "Idle",
|
||||||
|
emoji: "🕒",
|
||||||
|
icon: "fa-regular fa-moon",
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hidden",
|
||||||
|
label: "Hidden",
|
||||||
|
emoji: "🕵️",
|
||||||
|
icon: "",
|
||||||
|
adminOnly: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "Mood",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "chatty",
|
||||||
|
label: "Chatty and sociable",
|
||||||
|
emoji: "🗨️",
|
||||||
|
icon: "fa fa-comment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "introverted",
|
||||||
|
label: "Introverted and quiet",
|
||||||
|
emoji: "🥄",
|
||||||
|
icon: "fa fa-spoon"
|
||||||
|
},
|
||||||
|
|
||||||
|
// If NSFW enabled
|
||||||
|
{
|
||||||
|
name: "horny",
|
||||||
|
label: "Horny",
|
||||||
|
emoji: "🔥",
|
||||||
|
icon: "fa fa-fire",
|
||||||
|
nsfw: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exhibitionist",
|
||||||
|
label: "Watch me",
|
||||||
|
emoji: "👀",
|
||||||
|
icon: "fa-regular fa-eye who-status-wide-icon-1",
|
||||||
|
nsfw: true,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Flatten the set of all status options.
|
||||||
|
const StatusFlattened = (function() {
|
||||||
|
let result = [];
|
||||||
|
for (let category of StatusOptions) {
|
||||||
|
for (let option of category.options) {
|
||||||
|
result.push(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Hash map lookup of status by name.
|
||||||
|
const StatusByName = (function() {
|
||||||
|
let result = {};
|
||||||
|
for (let item of StatusFlattened) {
|
||||||
|
result[item.name] = item;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// An API surface layer of functions.
|
||||||
|
class StatusMessageController {
|
||||||
|
// The caller configures:
|
||||||
|
// - nsfw (bool): the BareRTC PermitNSFW setting, which controls some status options.
|
||||||
|
// - isAdmin (func): return a boolean if the current user is operator.
|
||||||
|
// - currentStatus (func): return the name of the user's current status.
|
||||||
|
constructor() {
|
||||||
|
this.nsfw = false;
|
||||||
|
this.isAdmin = function() { return false };
|
||||||
|
this.currentStatus = function() { return StatusFlattened[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate the category <optgroup> for the Status dropdown menu.
|
||||||
|
iterSelectOptGroups() {
|
||||||
|
return StatusOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate the <option> for a category of statuses.
|
||||||
|
iterSelectOptions(category) {
|
||||||
|
let current = this.currentStatus(),
|
||||||
|
isAdmin = this.isAdmin();
|
||||||
|
|
||||||
|
for (let group of StatusOptions) {
|
||||||
|
if (group.category === category) {
|
||||||
|
// Return the filtered options.
|
||||||
|
let result = group.options.filter(option => {
|
||||||
|
if ((option.hidden && current !== option.name) ||
|
||||||
|
(option.adminOnly && !isAdmin) ||
|
||||||
|
(option.nsfw && !this.nsfw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get details on a status message.
|
||||||
|
getStatus(name) {
|
||||||
|
if (StatusByName[name] != undefined) {
|
||||||
|
return StatusByName[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a dummy status object.
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
label: name,
|
||||||
|
icon: "fa fa-clock",
|
||||||
|
emoji: "🕒"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline status.
|
||||||
|
offline() {
|
||||||
|
return {
|
||||||
|
name: "offline",
|
||||||
|
label: "Offline",
|
||||||
|
icon: "fa fa-house-circle-xmark",
|
||||||
|
emoji: "🌜",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusMessage = new StatusMessageController();
|
||||||
|
export default StatusMessage;
|
|
@ -3,7 +3,7 @@ const VideoFlag = {
|
||||||
Active: 1 << 0,
|
Active: 1 << 0,
|
||||||
NSFW: 1 << 1,
|
NSFW: 1 << 1,
|
||||||
Muted: 1 << 2,
|
Muted: 1 << 2,
|
||||||
IsTalking: 1 << 3,
|
NonExplicit: 1 << 3,
|
||||||
MutualRequired: 1 << 4,
|
MutualRequired: 1 << 4,
|
||||||
MutualOpen: 1 << 5,
|
MutualOpen: 1 << 5,
|
||||||
VipOnly: 1 << 6,
|
VipOnly: 1 << 6,
|
||||||
|
|
|
@ -2,8 +2,16 @@
|
||||||
// special nuances in their WebRTC video sharing support. This is intended to
|
// special nuances in their WebRTC video sharing support. This is intended to
|
||||||
// detect: iPads, iPhones, and Safari on macOS.
|
// detect: iPads, iPhones, and Safari on macOS.
|
||||||
function isAppleWebkit() {
|
function isAppleWebkit() {
|
||||||
// By User-Agent.
|
const ua = navigator.userAgent;
|
||||||
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
|
|
||||||
|
// By User-Agent: Apple mobiles.
|
||||||
|
if (/iPad|iPhone|iPod/.test(ua)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari browser: claims to be Safari but not Chrome
|
||||||
|
// (Google Chrome claims to be both)
|
||||||
|
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@ var DefaultSounds = {
|
||||||
Leave: "Quiet",
|
Leave: "Quiet",
|
||||||
Watch: "Quiet",
|
Watch: "Quiet",
|
||||||
Unwatch: "Quiet",
|
Unwatch: "Quiet",
|
||||||
|
Mentioned: "Ping",
|
||||||
};
|
};
|
||||||
|
|
||||||
export { SoundEffects, DefaultSounds };
|
export { SoundEffects, DefaultSounds };
|
||||||
|
|
27
src/lib/watermark.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import QrCode from 'qrcodejs';
|
||||||
|
|
||||||
|
// WatermarkImage outputs a QR code containing watermark data about the current user.
|
||||||
|
//
|
||||||
|
// To help detect when someone has screen recorded and shared it, and being able to know who/when/etc.
|
||||||
|
function WatermarkImage(username) {
|
||||||
|
let now = new Date();
|
||||||
|
let dateString = [
|
||||||
|
now.getFullYear(),
|
||||||
|
('0' + (now.getMonth()+1)).slice(-2),
|
||||||
|
('0' + (now.getDate())).slice(-2),
|
||||||
|
].join('-');
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
window.location.hostname,
|
||||||
|
username,
|
||||||
|
dateString,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
console.error("watermark message:", fields);
|
||||||
|
|
||||||
|
const matrix = QrCode.generate(fields);
|
||||||
|
const uri = QrCode.render('svg-uri', matrix);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WatermarkImage;
|
|
@ -12,7 +12,7 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="container is-fullhd">
|
<div class="container is-fullhd p-2">
|
||||||
<div class="content my-5">
|
<div class="content my-5">
|
||||||
<h1>About BareRTC</h1>
|
<h1>About BareRTC</h1>
|
||||||
|
|
||||||
|
@ -43,22 +43,29 @@
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<h1>Help & Support</h1>
|
<h1 id="help">
|
||||||
|
Help & Support
|
||||||
|
<a href="#help" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="#tour">Tour of the user interface</a></li>
|
<li><a href="#tour">Tour of the user interface</a></li>
|
||||||
<li><a href="#browsers">Supported browsers</a></li>
|
<li><a href="#video-sharing">Video Sharing How-To's</a></li>
|
||||||
<li><a href="#webcam">Webcam sharing</a></li>
|
<li><a href="#features">Feature Highlights</a></li>
|
||||||
<li><a href="#markdown">Styling your messages</a></li>
|
<li><a href="#browsers">Supported Browsers</a></li>
|
||||||
<li><a href="#privacy">Privacy</a></li>
|
<li><a href="#privacy">Privacy</a></li>
|
||||||
|
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h1 id="tour">Tour of the user interface</h1>
|
<h1 id="tour">
|
||||||
|
Tour of the user interface
|
||||||
|
<a href="#tour" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div class="has-text-centered">
|
<div class="has-text-centered mb-4">
|
||||||
<img src="/static/img/screenshot.png" alt="Screenshot of the user interface on desktop">
|
<img src="/static/img/screenshot.png" width="1269" height="582" alt="Screenshot of the user interface on desktop">
|
||||||
<br><em>Pictured: Screenshot of the user interface on tablet or desktop-sized screens.</em><br><br>
|
<br><em>Pictured: Screenshot of the user interface on tablet or desktop-sized screens.</em><br><br>
|
||||||
<img src="/static/img/mobile.png" alt="Screenshot of the user interface on mobile">
|
<img src="/static/img/mobile.png" width="720" height="703" alt="Screenshot of the user interface on mobile">
|
||||||
<br><em>Pictured: Screenshot of the mobile interface</em>
|
<br><em>Pictured: Screenshot of the mobile interface</em>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -82,9 +89,29 @@
|
||||||
0
|
0
|
||||||
</button>
|
</button>
|
||||||
button, which will show the number of watchers inside the button itself.
|
button, which will show the number of watchers inside the button itself.
|
||||||
|
<ul>
|
||||||
|
{{if .Config.PermitNSFW}}
|
||||||
|
<li>
|
||||||
|
The <button type="button" class="button is-small is-outlined is-dark px-1">
|
||||||
|
<i class="fa fa-fire has-text-danger mr-1"></i> Explicit
|
||||||
|
</button>
|
||||||
|
button will toggle the setting on your camera. If you are going to be
|
||||||
|
behaving sexually on cam, we ask that you please mark your own camera
|
||||||
|
as 'Explicit' by using this button. The button will illuminate in red
|
||||||
|
when active, and look like:
|
||||||
|
<button type="button" class="button is-small is-danger px-1">
|
||||||
|
<i class="fa fa-fire mr-1"></i> Explicit
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
<li>
|
||||||
|
You can also see who is Watching your camera by clicking on the "Watching"
|
||||||
|
tab on the Who's Online list.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The <strong>Who List</strong> shows all of the people who are currently connected
|
The <strong>Who Is Online</strong> list shows all of the people who are currently connected
|
||||||
to the chat room. The Who List is global to the entire chat - it is the same list
|
to the chat room. The Who List is global to the entire chat - it is the same list
|
||||||
of people no matter which Channel you are currently looking at. In the Who List
|
of people no matter which Channel you are currently looking at. In the Who List
|
||||||
you may also find buttons to open a Direct Message (DM) or tune in to somebody's
|
you may also find buttons to open a Direct Message (DM) or tune in to somebody's
|
||||||
|
@ -94,7 +121,10 @@
|
||||||
<button type="button" class="button is-small px-2 py-1">
|
<button type="button" class="button is-small px-2 py-1">
|
||||||
<i class="fa fa-user"></i>
|
<i class="fa fa-user"></i>
|
||||||
</button>
|
</button>
|
||||||
This button will open their profile page, if they have one.
|
This button will open their profile page, if they have one. The icon may
|
||||||
|
be color-coded for the user's gender, if available: <i class="fa fa-user has-text-gender-male"></i> male,
|
||||||
|
<i class="fa fa-user has-text-gender-female"></i> female, and
|
||||||
|
<i class="fa fa-user has-text-gender-other"></i> non-binary/trans/other.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="button is-small px-2 py-1">
|
<button type="button" class="button is-small px-2 py-1">
|
||||||
|
@ -117,16 +147,40 @@
|
||||||
<li>
|
<li>
|
||||||
The <strong>Channels & DMs</strong> panel lists the available public chat channels
|
The <strong>Channels & DMs</strong> panel lists the available public chat channels
|
||||||
you may participate in as well as any currently open DMs for private one-on-one chats
|
you may participate in as well as any currently open DMs for private one-on-one chats
|
||||||
with other people. Some channels may have red notifications beside them to indicate
|
with other people. Some channels may have notification counters beside them to indicate
|
||||||
unread messages have appeared.
|
unread messages have appeared.
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Note:</strong> the Chat Rooms are just public channels that <em>everybody</em>
|
||||||
|
is "in" and is designed to organize public conversations into various topics. For example,
|
||||||
|
one chat room may be a dedicated space for people to share photos on chat so that the
|
||||||
|
main conversation channels don't get spammed too much with those photos.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The <strong>Message entry box</strong> at the bottom of the screen is how you type a
|
The <strong>Message entry box</strong> at the bottom of the screen is how you type a
|
||||||
message and send it to the currently selected Channel or DM conversation.
|
message and send it to the currently selected Channel or DM conversation.
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
In public channels you may <strong>send a picture</strong> by clicking on the picture
|
||||||
|
button to the left of the message box.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
In the upper right, the <i class="fa fa-gear"></i> <strong>Settings</strong> button
|
You may <strong>add an emoji</strong> to your message by clicking on the smile icon
|
||||||
will show some chat settings. Some interesting features in there include:
|
to the right of the message box.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
You may <strong>at-mention</strong> people in chat by typing the "@" symbol, which
|
||||||
|
should prompt an auto-complete of users currently online. You may hit the Tab key
|
||||||
|
to insert the currently selected name into your message.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
In the upper right, the <i class="fa fa-bars"></i> <strong>Menu</strong> button houses
|
||||||
|
some options such as the <i class="fa fa-gears"></i> <strong>Chat Settings</strong>.
|
||||||
|
Some interesting features in there include:
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Video scaling:</strong> you can make the webcam videos larger on
|
<strong>Video scaling:</strong> you can make the webcam videos larger on
|
||||||
|
@ -150,80 +204,93 @@
|
||||||
in to their webcams, respectively).
|
in to their webcams, respectively).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 id="browsers">Supported browsers</h1>
|
<hr>
|
||||||
|
|
||||||
|
<h1 id="features">
|
||||||
|
Feature Highlights
|
||||||
|
<a href="#features" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h3 id="emoji-reactions">
|
||||||
|
Emoji Reactions
|
||||||
|
<a href="#emoji-reactions" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Currently, BareRTC works best on <i class="fab fa-chrome"></i> Chromium browsers (including Google
|
You may add emoji reactions to chat messages by clicking on the
|
||||||
Chrome, Microsoft Edge, Opera, Brave, and other Chromium derivatives) as well as
|
<button type="button" class="button is-small has-text-grey is-outlined px-2">
|
||||||
<i class="fab fa-firefox"></i> Mozilla Firefox.
|
<i class="fa fa-plus mr-2"></i> <i class="fa fa-heart"></i>
|
||||||
|
</button>
|
||||||
|
button at the bottom right corner.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
It works in these browsers on desktop operating systems (including <i class="fab fa-windows"></i> Windows,
|
Reactions added by yourself or others will appear at the bottom of their message. You can
|
||||||
<i class="fab fa-apple"></i> Mac OS and <i class="fab fa-linux"></i> GNU/Linux) as well as on
|
see who reacted by hovering your mouse cursor over the button, or you can 'upvote' their
|
||||||
<i class="fab fa-android"></i> Android devices.
|
reaction by clicking on it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="mute">
|
||||||
|
Muting spammy users
|
||||||
|
<a href="#mute" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If somebody on chat is bothering you, you may <strong>mute</strong> their messages by clicking
|
||||||
|
on the red <i class="fa fa-comment-slash has-text-danger"></i> button on their message.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Unfortunately, the chat <strong class="has-text-danger">does not work</strong> on Apple's mobile
|
While they are muted:
|
||||||
devices such as the iPad and iPhone -- but research on this is underway and hopefully iOS devices
|
</p>
|
||||||
will be supported soon!
|
|
||||||
|
<ul>
|
||||||
|
<li>You will no longer see their future messages in public chat rooms.</li>
|
||||||
|
<li>You will no longer receive any of their future Direct Messages (DMs).</li>
|
||||||
|
<li>
|
||||||
|
They will not be allowed to watch your webcam if you are sharing. The chat
|
||||||
|
server will lie and tell them your camera <em>isn't even online</em> so they
|
||||||
|
will not know they had been muted.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The mute is <strong>temporary</strong> and will last for the remainder of your chat
|
||||||
|
session (until you log off).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="markdown">
|
||||||
|
Styling Your Messages
|
||||||
|
<a href="#markdown" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
BareRTC supports Markdown syntax for your chat messages. You can make text <strong>bold</strong>
|
||||||
|
by putting asterisks around part of it, <strong>**like this**</strong> or make text italic with
|
||||||
|
<em>*single asterisks*</em>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Feature support matrix of browsers currently tested:
|
Hyperlinks you paste that begin with https:// will be clickable as-is, or you can create a custom
|
||||||
|
label for it by typing e.g. <code>[click my link](https://www.wikipedia.org/)</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table class="table table-striped">
|
<p>
|
||||||
<thead>
|
To learn more about Markdown, please check out the <a href="https://www.markdownguide.org" target="_blank">Markdown Guide</a>
|
||||||
<tr>
|
website.
|
||||||
<th>Device Type</th>
|
</p>
|
||||||
<th>Web Browsers</th>
|
|
||||||
<th>Chat works?</th>
|
|
||||||
<th>Video works?</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fa fa-computer"></i> All Desktops</td>
|
|
||||||
<td><i class="fab fa-firefox"></i> Mozilla Firefox</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fa fa-computer"></i> All Desktops</td>
|
|
||||||
<td><i class="fab fa-chrome"></i> Chromium (Chrome, Edge)</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fab fa-apple"></i> Mac OS Desktop</td>
|
|
||||||
<td><i class="fab fa-chrome"></i> Safari</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
<td>❌</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fab fa-android"></i> Android</td>
|
|
||||||
<td><i class="fab fa-firefox"></i> Mozilla Firefox</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fab fa-android"></i> Android</td>
|
|
||||||
<td><i class="fab fa-chrome"></i> Chromium (Vanadium)</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
<td>✔️</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><i class="fab fa-apple"></i> iPhone & iPad</td>
|
|
||||||
<td><i class="fa fa-globe"></i> All browsers</td>
|
|
||||||
<td>❌</td>
|
|
||||||
<td>❌</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h1 id="webcam">Webcam sharing</h1>
|
<hr>
|
||||||
|
|
||||||
|
<h1 id="video-sharing">
|
||||||
|
Video Sharing How-To's
|
||||||
|
<a href="#video-sharing" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h3 id="webcam">
|
||||||
|
About webcam sharing
|
||||||
|
<a href="#webcam" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
The WebRTC technology used by this chat room allows for direct, <strong>peer to peer</strong>
|
The WebRTC technology used by this chat room allows for direct, <strong>peer to peer</strong>
|
||||||
|
@ -250,25 +317,178 @@
|
||||||
way so that they may be banned from the chat room.
|
way so that they may be banned from the chat room.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 id="markdown">Styling Your Messages</h1>
|
<h3 id="broadcast">
|
||||||
|
How do I go on video?
|
||||||
|
<a href="#broadcast" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
BareRTC supports Markdown syntax for your chat messages. You can make text <strong>bold</strong>
|
To share your webcam, click on the green
|
||||||
by putting asterisks around part of it, <strong>**like this**</strong> or make text italic with
|
<button type="button" class="button is-small is-success px-1">
|
||||||
<em>*single asterisks*</em>.
|
<i class="fa fa-video mr-2"></i>
|
||||||
|
Share webcam
|
||||||
|
</button>
|
||||||
|
button at the top left of the web page. This will open a modal with some options for
|
||||||
|
your camera:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
You may start with your microphone muted by default.
|
||||||
|
</li>
|
||||||
|
{{if .Config.PermitNSFW}}
|
||||||
|
<li>
|
||||||
|
You may mark your camera as "Explicit" to begin with. If you are intending immediately to
|
||||||
|
behave sexually on camera, please mark this box so that your camera will appear 'red' by
|
||||||
|
default and other people on chat can know what they're getting into when they click to
|
||||||
|
watch your camera.
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
<li>
|
||||||
|
You may opt-in to some "mutual webcam settings":
|
||||||
|
<ul>
|
||||||
|
<li>You may require your viewers to also be sharing their own camera first before they can open yours.</li>
|
||||||
|
<li>You may automatically open your viewer's cameras when they open yours (and they are also broadcasting).</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 id="stop-webcam">
|
||||||
|
How do I stop my camera?
|
||||||
|
<a href="#stop-webcam" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
When you are broadcasting, the red "<i class="fa fa-stop"></i> Stop" button at the top of the
|
||||||
|
page will turn your camera off.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="mute-microphone">
|
||||||
|
How do I mute my camera's microphone?
|
||||||
|
<a href="#mute-microphone" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The button to <i class="fa fa-mute"></i> Mute will be at the top of the page next to the
|
||||||
|
<i class="fa fa-stop"></i> Stop button. Alternatively, you can click on the microphone inside
|
||||||
|
your own webcam video preview to toggle the mute that way. Either button works!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="watching">
|
||||||
|
How do I see who is watching me?
|
||||||
|
<a href="#watching" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You may click on the <button type="button" class="button is-small is-info is-outlined ml-1 px-1">
|
||||||
|
<i class="fa fa-eye mr-2"></i>
|
||||||
|
0
|
||||||
|
</button> button at the top of the page to see who is watching you. The number inside the
|
||||||
|
button will be your current number of viewers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Hyperlinks you paste that begin with https:// will be clickable as-is, or you can create a custom
|
You may also click on the <i class="fa fa-eye"></i> Watching tab in the Who's Online list.
|
||||||
label for it by typing e.g. <code>[click my link](https://www.wikipedia.org/)</code>.
|
</p>
|
||||||
|
|
||||||
|
<h3 id="boot">
|
||||||
|
How do I stop somebody from watching me?
|
||||||
|
<a href="#boot" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
On the Who's Online list, click on the <i class="fa fa-eye"></i> Watching tab to see who
|
||||||
|
is currently watching your camera.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
To learn more about Markdown, please check out the <a href="https://www.markdownguide.org" target="_blank">Markdown Guide</a>
|
To 'boot' somebody off your camera, click on the
|
||||||
website.
|
<button type="button" class="button is-small px-2 py-1"
|
||||||
|
title="Kick this person off your cam">
|
||||||
|
<i class="fa fa-user-xmark has-text-danger"></i>
|
||||||
|
</button>
|
||||||
|
button next to their name. This will kick them off your camera and they will not
|
||||||
|
be allowed to re-open your cam for the remainder of <em>your</em> chat session.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 id="privacy">Privacy</h1>
|
<p>
|
||||||
|
To people who have been booted from your camera, the chat server will tell them that
|
||||||
|
your camera <em>isn't even broadcasting</em> at all! They will see a greyed-out video
|
||||||
|
button and may think you have simply turned off your camera, and they won't know for
|
||||||
|
sure that you have booted them!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="video-size">
|
||||||
|
How do I make videos larger on my screen?
|
||||||
|
<a href="#video-size" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
There are a few methods available to change the size of videos on the chat room,
|
||||||
|
depending on whether your device has a mouse cursor or is a touch screen (such as a tablet).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Options that may work with all devices (with mouse or touch screen):
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Chat Settings: by clicking on the blue menu button on the top-right corner of the page, the
|
||||||
|
Display tab has a "Video size" dropdown with some options to change the size of all videos
|
||||||
|
open on your screen.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
On wide screens (such as an iPad in landscape orientation): when you have videos open, look
|
||||||
|
for the <i class="fa fa-magnifying-glass-plus"></i> <i class="fa fa-magnifying-glass-minus"></i>
|
||||||
|
buttons in the header just above the video dock. Clicking on these buttons will scale the docked
|
||||||
|
videos larger or smaller (in similar increments as the Chat Settings dropdown).
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you are on a device with a mouse cursor, such as a desktop or a laptop with a touchpad, there
|
||||||
|
are a couple of ways to resize individual videos how you see fit:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
When videos are docked in the top of the page, hover your mouse over the bottom-right corner
|
||||||
|
of a video. It should show a 'resize' cursor, and you can click and drag from the bottom-right
|
||||||
|
corner to resize the video.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Videos can also be "popped out" of the dock at the top of the page. The pop-out button appears
|
||||||
|
on top of videos, next to the mute audio button. When popped out, you can drag videos anywhere
|
||||||
|
on the page and you can resize them by dragging from any edge except the top.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
And finally: you can put a video "full screen" too. In the control buttons at the bottom of each
|
||||||
|
video (beginning with the mute audio button), the one on the right will toggle the video into a
|
||||||
|
full screen view.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1 id="browsers">
|
||||||
|
Supported Browsers
|
||||||
|
<a href="#browsers" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
All features of the chat room, including webcam sharing, should generally work on all current
|
||||||
|
web browsers and devices (Firefox, all Chromium browsers including Google Chrome or Microsoft
|
||||||
|
Edge, Safari, Androids and iPads).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1 id="privacy">
|
||||||
|
Privacy
|
||||||
|
<a href="#privacy" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Some of the privacy considerations with this chat room include:
|
Some of the privacy considerations with this chat room include:
|
||||||
|
@ -277,7 +497,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
The server does not maintain long-term state of chat history. Messages are pushed out as
|
The server does not maintain long-term state of chat history. Messages are pushed out as
|
||||||
soon as they come in.
|
soon as they come in, and users are forgotten when they log off.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
However, the server does keep temporary <strong>log files</strong> to help the server admin
|
However, the server does keep temporary <strong>log files</strong> to help the server admin
|
||||||
|
@ -287,6 +507,13 @@
|
||||||
the contents of DMs may be logged since debug mode will record the low-level chat protocol
|
the contents of DMs may be logged since debug mode will record the low-level chat protocol
|
||||||
messages sent between the server and clients.
|
messages sent between the server and clients.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
If the administrator has enabled it, your <strong>recent direct message</strong> histories
|
||||||
|
may be recorded (for your convenience) in a database. Old messages expire off after about 90
|
||||||
|
days, and you can clear all your DMs history from the Chat Settings -> Misc tab. DMs are only
|
||||||
|
logged when the associate website logs you in to the chat room, so that only you can access
|
||||||
|
them to remember where you left off with a chat partner.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Chat moderators <strong>DO NOT</strong> get to see the contents of your Direct Message
|
Chat moderators <strong>DO NOT</strong> get to see the contents of your Direct Message
|
||||||
conversations - that feature is not programmed in to this chat room. The server logs which
|
conversations - that feature is not programmed in to this chat room. The server logs which
|
||||||
|
@ -305,6 +532,229 @@
|
||||||
so choose your risk tolerance accordingly.
|
so choose your risk tolerance accordingly.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h1 id="troubleshooting">
|
||||||
|
Troubleshooting
|
||||||
|
<a href="#troubleshooting" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This section contains some troubleshooting advice for issues commonly experienced in the chat room.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 id="webcam-troubleshooting">
|
||||||
|
Webcam Sharing
|
||||||
|
<a href="#webcam-troubleshooting" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h3 id="NotAllowedError">
|
||||||
|
Permission Denied, or NotAllowedError
|
||||||
|
<a href="#NotAllowedError" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you see an error message from <strong class="has-text-danger">ChatClient</strong> that says
|
||||||
|
something like "Webcam error: Permission denied" or a "NotAllowedError," this section is for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The reason for this error is that your web browser did not grant access to your Camera and Microphone
|
||||||
|
for the chat room to use. This may be because you accidentally clicked on the "Deny" button when your
|
||||||
|
browser asked you for permission, or because your web browser <em>itself</em> does not have permission for
|
||||||
|
these devices on your computer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
When you encounter this error, there are two places to look to resolve this:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
In your web browser's settings (e.g. in Google Chrome or Firefox), make sure that you are granting
|
||||||
|
permission for your Camera and Microphone to the chat room's website ({{.Hostname}}).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
In your operating system's settings, ensure that your web browser itself has permission to use
|
||||||
|
your Camera and Microphone. <strong>Notice:</strong> on recent Mac OS and Windows systems, your
|
||||||
|
web browser might not have permission by default to access these devices!
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please see the following sections for in-depth guidance on where to look.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Notice:</strong> webcam sharing will require both your Camera <em>and</em> your Microphone
|
||||||
|
permission - if either one is denied, webcam sharing will fail.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>1. Check your web browser's permissions for {{.Hostname}}</h4>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Go into your web browser's settings, to the "Privacy" or "Permissions" section and verify that
|
||||||
|
<code>{{.Hostname}}</code> has permissions to use your Camera <strong>and</strong> your Microphone.
|
||||||
|
Or at the very least: make sure that these settings are not set to "Deny" for {{.Hostname}}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
For example, on <i class="fab fa-chrome"></i> <strong>Google Chrome:</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Go into your browser Settings -> "Privacy and security"</li>
|
||||||
|
<li>Click on "Site settings" <small>("Controls what information sites can use and show (location, camera, pop-ups, and more")</small></li>
|
||||||
|
<li>From here you can check the permissions in two ways:
|
||||||
|
<ul>
|
||||||
|
<li>Under the "Recent activity" section, look for {{.Hostname}} or click "View permissions and data stored across sites" to look for it there.</li>
|
||||||
|
<li>Or: under the "Permissions" section, click into the "Camera" and "Microphone" settings to see the list of sites you've given/denied
|
||||||
|
permission for, and change {{.Hostname}} to "Allow" or remove {{.Hostname}} from the list of sites.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Or for example, on <i class="fab fa-firefox"></i> <strong>Mozilla Firefox:</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Go into your browser Settings -> "Privacy & Security"</li>
|
||||||
|
<li>Scroll down to the "Permissions" section where you see buttons for Camera and Microphone.</li>
|
||||||
|
<li>Click on the "Settings..." button for Camera and Microphone to see the list of websites you've given/denied permission for.</li>
|
||||||
|
<li>Find <code>{{.Hostname}}</code> and remove it from the list.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
After changing your browser settings, <strong>exit and restart your web browser</strong> and log on to the chat room.
|
||||||
|
When going on webcam, hopefully your browser should ask you for permission for your webcam and microphone: be sure to
|
||||||
|
click on "Allow" when prompted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If the change in your web browser's settings doesn't resolve the permission error, then check in your operating system's settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>2. Check in your operating system's settings</h4>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
For example, <strong>if you are on <i class="fab fa-apple"></i> Mac OS:</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Click on the Apple icon in your top menu bar and open "System Settings"</li>
|
||||||
|
<li>Click on "Privacy & Security"</li>
|
||||||
|
<li>Look for the Camera and Microphone settings and ensure that they are <strong>allowed</strong> for your web browser.</li>
|
||||||
|
<li>For more information, try: <a href="https://support.apple.com/guide/mac-help/control-access-to-your-camera-mchlf6d108da/mac">https://support.apple.com/guide/mac-help/control-access-to-your-camera-mchlf6d108da/mac</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Or for example, <strong>if you are on <i class="fab fa-windows"></i> Windows 10 or Windows 11:</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Click on the Start button > Settings > Privacy & Security > Camera, and make sure Camera access is turned <strong>on.</strong></li>
|
||||||
|
<li>Make sure that "Let apps access your camera" is also turned on.</li>
|
||||||
|
<li>Ensure that your web browser (e.g. Google Chrome) has permission to access your camera and microphone.</li>
|
||||||
|
<li>For more information, try: <a href="https://support.microsoft.com/en-us/windows/manage-app-permissions-for-your-camera-in-windows-87ebc757-1f87-7bbf-84b5-0686afb6ca6b">https://support.microsoft.com/en-us/windows/manage-app-permissions-for-your-camera-in-windows-87ebc757-1f87-7bbf-84b5-0686afb6ca6b</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 id="other-webcam-errors">
|
||||||
|
Other Webcam Errors
|
||||||
|
<a href="#other-webcam-errors" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The most common error is a Permission Error as described above, but there are some less common
|
||||||
|
error messages you may experience. Here is a short list of some of them:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>AbortError:</strong> e.g. "Starting videoinput failed"
|
||||||
|
<br><br>
|
||||||
|
A common cause for this error may be that your webcam device is already in use by a different program,
|
||||||
|
so that your web browser was not able to open it. Please make sure that you are not running another video
|
||||||
|
chat program (such as Skype or Zoom) which has your camera active, and try again.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>NotFoundError</strong> or <strong>NotReadableError</strong>
|
||||||
|
<br><br>
|
||||||
|
This error may indicate that your camera device was not available to your web browser. For example, your
|
||||||
|
webcam might not be plugged in to USB or you may be missing hardware drivers for it. To begin diagnosing
|
||||||
|
this problem, check whether <other>other</other> websites or apps are able to use your camera: if none of
|
||||||
|
them can either, it heavily points toward a hardware or driver error.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Other possible errors should be uncommon. If this troubleshooting guide has not been helpful, try copying
|
||||||
|
the error message into a search engine and find information online: all of the text following "Webcam error:"
|
||||||
|
will be coming from your web browser, so relevant results may be found online. A possible place to start may
|
||||||
|
be the <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia">Mozilla documentation for getUserMedia.</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="webcam-not-loading">
|
||||||
|
Other peoples' webcams don't load
|
||||||
|
<a href="#webcam-not-loading" class="fa fa-paragraph is-size-6"></a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
It may sometimes happen that you clicked to watch somebody else's webcam on chat, and their video doesn't
|
||||||
|
load. If it only happens with <em>some</em> cameras (but other cameras load correctly), then the problem is
|
||||||
|
most likely on <em>that person's</em> side and not your own: their camera likely doesn't work for anybody
|
||||||
|
else who is trying to watch it, either.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Webcam sharing on the chat room works by "peer to peer" direct connections between chatters. Usually, this
|
||||||
|
tends to "just work" for most people, but sometimes your network connection or firewall can get in the way
|
||||||
|
and prevent a connection from being established.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The most common kinds of network conditions that cause problems connecting to webcams include:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
If you are on a heavily firewalled network, such as in a school or workplace.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If you are on a cellular network, e.g. from a smartphone or tablet. Some cell phone carriers
|
||||||
|
add firewalls to their network to prevent direct peer-to-peer connections with mobile devices.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If you are using a Virtual Private Network, or VPN: some VPN providers don't forward the kind
|
||||||
|
of network messages that are needed to establish a peer-to-peer connection.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
For some specific advice:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
If you can not connect to <strong>any</strong> webcam at all (e.g. many people are on webcam but you
|
||||||
|
can not get <strong>any</strong> of them to work): most likely it is because you are on a firewalled
|
||||||
|
network, like those listed above. If you are on a cell network, try switching to WiFi; or if you are
|
||||||
|
using a VPN, try turning off your VPN.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If <strong>only one or two webcams</strong> won't open for you, but others on the chat room are working:
|
||||||
|
the problem is likely not on your end! It is likely that the people whose cameras are not working are
|
||||||
|
on a firewalled network, and they are likely having worse problems than you are!
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If somebody's webcam button has a slash \ through it, like <i class="fa fa-video-slash has-text-info"></i>
|
||||||
|
then it <em>probably</em> means that this person requires <strong>you</strong> to share your webcam first
|
||||||
|
before you can open theirs. You can click on their camera button to see an exact reason why their camera
|
||||||
|
has the \ slash through it.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1431,7 +1431,7 @@
|
||||||
<div class="column pr-0 is-clipped"
|
<div class="column pr-0 is-clipped"
|
||||||
:class="{'pl-1': u.avatar}">
|
:class="{'pl-1': u.avatar}">
|
||||||
<strong class="truncate-text-line is-size-7">[[ u.username ]]</strong>
|
<strong class="truncate-text-line is-size-7">[[ u.username ]]</strong>
|
||||||
<sup class="fa fa-peace has-text-warning-dark is-size-7 ml-1"
|
<sup class="fa fa-peace has-text-warning is-size-7 ml-1"
|
||||||
v-if="u.op"
|
v-if="u.op"
|
||||||
title="Operator"></sup>
|
title="Operator"></sup>
|
||||||
<sup class="is-size-7 ml-1"
|
<sup class="is-size-7 ml-1"
|
||||||
|
|
30
web/templates/logout.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{{define "index"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css">
|
||||||
|
<title>Logged Out</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container is-fullhd">
|
||||||
|
<div class="content my-5">
|
||||||
|
<h1>Logged Out</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You are now logged out of the chat room. You may now close this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close();
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|