Compare commits

..

No commits in common. "master" and "ipad-testing" have entirely different histories.

2215 changed files with 21365 additions and 37336 deletions

View File

@ -1,11 +0,0 @@
/* eslint-env node */
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

28
.gitignore vendored
View File

@ -1,30 +1,2 @@
settings.toml
chatbot/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint"
]
}

View File

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

View File

@ -40,7 +40,7 @@ VideoFlag: {
Active: 1 << 0, // or 00000001 in binary
NSFW: 1 << 1, // or 00000010
Muted: 1 << 2, // or 00000100, etc.
NonExplicit: 1 << 3,
IsTalking: 1 << 3,
MutualRequired: 1 << 4,
MutualOpen: 1 << 5,
}
@ -313,19 +313,6 @@ The server passes the watch/unwatch message to the broadcaster.
}
```
## Cut
Sent by: Server.
The server tells the client to turn off their camera. This is done in response to a `/cut` command being sent by an admin user: to remotely cause another user on chat to turn off their camera and stop broadcasting.
```javascript
// Server Cut
{
"action": "cut"
}
```
## Mute, Unmute
Sent by: Client.
@ -356,36 +343,11 @@ The `unmute` action does the opposite and removes the mute status:
}
```
## Block
Sent by: Client, Server.
The block command places a hard block between the current user and the target.
When either user blocks the other:
* They do not see each other in the Who's Online list at all.
* They can not see each other's messages, including presence messages.
**Note:** the chat page currently does not have a front-end button to block a user. This feature is currently used by the Blocklist feature to apply a block to a set of users at once upon join.
```javascript
// Client Block
{
"action": "block",
"username": "target"
}
```
The server may send a "block" message to the client in response to the BlockNow API endpoint: your main website can communicate that a block was just added, so if either user is currently in chat the block can apply immediately instead of at either user's next re-join of the room.
The server "block" message follows the same format, having the username of the other party.
## Blocklist
Sent by: Client.
The blocklist command is basically a bulk block for (potentially) many usernames at once.
The blocklist command is basically a bulk mute for (potentially) many usernames at once.
```javascript
// Client blocklist
@ -397,11 +359,11 @@ The blocklist command is basically a bulk block for (potentially) many usernames
How this works: if you have an existing website and use JWT authentication to sign users into chat, your site can pre-emptively sync the user's block list **before** the user enters the room, using the `/api/blocklist` endpoint (see the README.md for BareRTC).
The chat server holds onto blocklists temporarily in memory: when that user loads the chat room (with a JWT token!), the front-end page receives the cached blocklist. As part of the "on connected" handler, the chat page sends the `blocklist` command over WebSocket to perform a mass block on these users in one go.
The chat server holds onto blocklists temporarily in memory: when that user loads the chat room (with a JWT token!), the front-end page receives the cached blocklist. As part of the "on connected" handler, the chat page sends the `blocklist` command over WebSocket to perform a mass mute on these users in one go.
The reason for this workflow is in case the chat server is rebooted _while_ the user is in the room. The cached blocklist pushed by your website is forgotten by the chat server back-end, but the client's page was still open with the cached blocklist already, and it will send the `blocklist` command to the server when it reconnects, eliminating any gaps.
## Boot, Unboot
## Boot
Sent by: Client.
@ -422,16 +384,6 @@ When a user is booted:
Note: it is designed that the person being booted off can not detect that they have been booted. They will see your RTC PeerConnection close + get a Who List that says you are not sharing video - exactly the same as if you had simply turned off your camera completely.
There is also a client side Unboot command, to undo the effects of a boot:
```javascript
// Client Unboot
{
"action": "unboot",
"username": "target"
}
```
## WebRTC Signaling
Sent by: Client, Server.

View File

@ -8,25 +8,17 @@ 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.
- [BareRTC](#barertc)
- [Installation](#installation)
- [Features](#features)
- [Configuration](#configuration)
- [Authentication](#authentication)
- [Moderator Commands](#moderator-commands)
- [JSON APIs](#json-apis)
- [Webhook URLs](#webhook-urls)
- [Chatbot](#chatbot)
- [Tour of the Codebase](#tour-of-the-codebase)
- [Backend files](#backend-files)
- [Frontend files](#frontend-files)
- [Deploying This App](#deploying-this-app)
- [Developing This App](#developing-this-app)
- [License](#license)
# Installation
See the [Install.md](./Install.md) for installation help.
* [Features](#features)
* [Configuration](#configuration)
* [Authentication](#authentication)
* [JWT Strict Mode](#jwt-strict-mode)
* [Running Without Authentication](#running-without-authentication)
* [Known Bugs Running Without Authentication](#known-bugs-running-without-authentication)
* [Moderator Commands](#moderator-commands)
* [JSON APIs](#json-apis)
* [Tour of the Codebase](#tour-of-the-codebase)
* [Deploying This App](#deploying-this-app)
* [License](#license)
# Features
@ -42,7 +34,14 @@ See the [Install.md](./Install.md) for installation help.
* WebRTC means peer-to-peer video streaming so cheap on hosting costs!
* Simple integration with your existing userbase via signed JWT tokens.
* User configurable sound effects to be notified of DMs or users entering/exiting the room.
* Operator commands to kick, ban users, mark cameras NSFW, etc.
* Operator commands
* [x] /kick users
* [x] /ban users (and /unban, /bans to list)
* [x] /nsfw to tag a user's camera as explicit
* [x] /shutdown to gracefully reboot the server
* [x] /kickall to kick EVERYBODY off the server (e.g., for mandatory front-end reload for new features)
* [x] /op and /deop users (give temporary mod control)
* [x] /help to get in-chat help for moderator commands
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!
@ -92,27 +91,7 @@ See [Authentication](docs/Authentication.md) for more information.
If you authenticate an Op user via JWT they can enter IRC-style chat commands to moderate the server. Current commands include:
* `/kick <username>` to disconnect a user's chat session.
* `/ban <username> [hours]` to ban a user from chat (temporary - time-based or until the next server reboot, default 24 hours)
* `/nsfw <username>` to tag a user's video feed as NSFW (if your settings.toml has PermitNSFW enabled).
* `/cut <username>` to 'cut' their webcam feed (instruct their web page to turn off their camera automatically)
There are easy buttons for the above commonly used actions in a user's pop-up "profile card" on the chat room.
Additional operator commands include:
* `/unban <username>` to lift the ban on a user.
* `/bans` to list all of the currently banned users.
* `/op <username>` to grant operator controls to a user (temporary, until they log off)
* `/deop <username>` to remove operator controls
* `/unmute-all` removes the mute flag on all users for the current operator (intended especially for the [Chatbot](docs/Chatbot.md) so it can still moderate public chat messages from users who have blocked it from your main website).
And there are some advanced commands intended for the server system administrator (these can be 'dangerous' and disruptive to users in the chat room):
* `/shutdown` will shut down the chat server (and hopefully, reboot it if your process supervisor is configured as such)
* `/reconfigure` will reload the server config file without needing to reboot.
* `/kickall` will kick ALL users from the room, with a message asking them to refresh the page (useful to deploy backwards-incompatible server updates where the new front-end is required to be loaded).
In case your operators forget, the `/help` command will list the common moderator commands and `/help-advanced` will list the more advanced/dangerous ones. **Note:** there is only one level of admin rights currently, so it will be a matter of policy to instruct your moderators not to play with the advanced commands.
# JSON APIs
@ -205,25 +184,6 @@ user = user
Then `sudo supervisorctl reread && sudo supervisorctl add barertc` to start the app.
# Developing This App
In local development you'll probably run two processes in your terminal: one to `npm run watch` the Vue.js app and the other to run the Go server.
Building and running the front-end app:
```bash
# Install dependencies
npm install
# Build the front-end
npm run build
# Run the front-end in watch mode for local dev
npm run watch
```
And `make run` to run the Go server.
# License
GPLv3.

View File

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

View File

@ -31,13 +31,11 @@ func (h *BotHandlers) watchForDeadlock() {
for {
time.Sleep(15 * time.Second)
go func() {
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Channel: "@" + h.client.Username(),
Message: fmt.Sprintf("deadlock ping %s", time.Now().Format(time.RFC3339)),
})
}()
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Channel: "@" + h.client.Username(),
Message: "deadlock ping",
})
// Has it been a while since our last ping?
if time.Since(h.deadlockLastOK) > deadlockTTL {
@ -52,7 +50,6 @@ func (h *BotHandlers) watchForDeadlock() {
func (h *BotHandlers) onMessageFromSelf(msg messages.Message) {
// If it is our own DM channel thread, it's for deadlock detection.
if msg.Channel == "@"+h.client.Username() {
log.Info("(Deadlock test) got echo from self, server still seems OK: %s", msg.Message)
h.deadlockLastOK = time.Now()
}
}

View File

@ -10,7 +10,6 @@ import (
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"github.com/aichaos/rivescript-go"
"github.com/aichaos/rivescript-go/lang/javascript"
)
const (
@ -61,7 +60,7 @@ type BotHandlers struct {
// Store the reactions we have previously sent by messageID,
// so we don't accidentally take back our own reactions.
reactions map[int64]map[string]interface{}
reactions map[int]map[string]interface{}
reactionsMu sync.Mutex
// Deadlock detection (deadlock_watch.go): record time of last successful
@ -82,15 +81,9 @@ func (c *Client) SetupChatbot() error {
}),
autoGreet: map[string]time.Time{},
messageBuf: []messages.Message{},
reactions: map[int64]map[string]interface{}{},
reactions: map[int]map[string]interface{}{},
}
// Add JavaScript support.
handler.rs.SetHandler("javascript", javascript.New(handler.rs))
// Attach RiveScript object macros.
handler.setObjectMacros()
log.Info("Initializing RiveScript brain")
if err := handler.rs.LoadDirectory("./brain"); err != nil {
return fmt.Errorf("RiveScript LoadDirectory: %s", err)
@ -99,6 +92,9 @@ func (c *Client) SetupChatbot() error {
return fmt.Errorf("RiveScript SortReplies: %s", err)
}
// Attach RiveScript object macros.
handler.setObjectMacros()
// Set all the handler funcs.
c.OnWho = handler.OnWho
c.OnMe = handler.OnMe
@ -109,7 +105,6 @@ func (c *Client) SetupChatbot() error {
c.OnOpen = handler.OnOpen
c.OnWatch = handler.OnWatch
c.OnUnwatch = handler.OnUnwatch
c.OnCut = handler.OnCut
c.OnError = handler.OnError
c.OnDisconnect = handler.OnDisconnect
c.OnPing = handler.OnPing
@ -135,12 +130,6 @@ func (h *BotHandlers) OnMe(msg messages.Message) {
log.Error("OnMe: the server has renamed us to '%s'", msg.Username)
h.client.claims.Subject = msg.Username
}
// Send the /unmute-all command to lift any mutes imposed by users blocking the chatbot.
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: "/unmute-all",
})
}
// Buffer a message seen on chat for a while.
@ -156,7 +145,7 @@ func (h *BotHandlers) cacheMessage(msg messages.Message) {
}
// Get a message by ID from the recent message buffer.
func (h *BotHandlers) getMessageByID(msgID int64) (messages.Message, bool) {
func (h *BotHandlers) getMessageByID(msgID int) (messages.Message, bool) {
h.messageBufMu.RLock()
defer h.messageBufMu.RUnlock()
for _, msg := range h.messageBuf {
@ -240,6 +229,7 @@ func (h *BotHandlers) OnMessage(msg messages.Message) {
// Set their user variables.
h.SetUserVariables(msg)
reply, err := h.rs.Reply(msg.Username, msg.Message)
log.Error("REPLY: %s", reply)
if NoReply(reply) {
return
}
@ -390,11 +380,6 @@ func (h *BotHandlers) OnUnwatch(msg messages.Message) {
}
// OnCut handles an admin telling us to cut our camera.
func (h *BotHandlers) OnCut(msg messages.Message) {
}
// OnError handles ChatServer messages from the backend.
func (h *BotHandlers) OnError(msg messages.Message) {
log.Error("[%s] %s", msg.Username, msg.Message)
@ -407,9 +392,5 @@ func (h *BotHandlers) OnDisconnect(msg messages.Message) {
// OnPing handles server keepalive pings.
func (h *BotHandlers) OnPing(msg messages.Message) {
// Send the /unmute-all command to lift any mutes imposed by users blocking the chatbot.
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: "/unmute-all",
})
}

View File

@ -9,7 +9,6 @@ import (
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"github.com/aichaos/rivescript-go"
"github.com/aichaos/rivescript-go/lang/javascript"
)
// Set up object macros for RiveScript.
@ -20,8 +19,6 @@ func (h *BotHandlers) setObjectMacros() {
UTF8: true,
Debug: rs.Debug,
})
bot.SetHandler("javascript", javascript.New(bot))
if err := bot.LoadDirectory("brain"); err != nil {
return fmt.Sprintf("Error on LoadDirectory: %s", err)
}
@ -45,7 +42,7 @@ func (h *BotHandlers) setObjectMacros() {
time.Sleep(2500 * time.Millisecond)
h.client.Send(messages.Message{
Action: messages.ActionReact,
MessageID: int64(msgID),
MessageID: msgID,
Message: args[1],
})
}()
@ -57,19 +54,6 @@ func (h *BotHandlers) setObjectMacros() {
return "[react: invalid number of parameters]"
})
// Mark a camera NSFW for a username.
h.rs.SetSubroutine("nsfw", func(rs *rivescript.RiveScript, args []string) string {
if len(args) >= 1 {
var username = strings.TrimPrefix(args[0], "@")
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Message: fmt.Sprintf("/nsfw %s", username),
})
return ""
}
return "[nsfw: invalid number of parameters]"
})
// Takeback a message (admin action especially)
h.rs.SetSubroutine("takeback", func(rs *rivescript.RiveScript, args []string) string {
if len(args) >= 1 {
@ -77,7 +61,7 @@ func (h *BotHandlers) setObjectMacros() {
// Take it back.
h.client.Send(messages.Message{
Action: messages.ActionTakeback,
MessageID: int64(msgID),
MessageID: msgID,
})
} else {
return fmt.Sprintf("[takeback: %s]", err)
@ -94,7 +78,7 @@ func (h *BotHandlers) setObjectMacros() {
var comment = strings.Join(args[1:], " ")
// Look up this message.
if msg, ok := h.getMessageByID(int64(msgID)); ok {
if msg, ok := h.getMessageByID(msgID); ok {
// Report it with the custom comment.
h.client.Send(messages.Message{
Action: messages.ActionReport,
@ -136,25 +120,4 @@ func (h *BotHandlers) setObjectMacros() {
}
return ""
})
// Send a public chat message to a channel name.
h.rs.SetSubroutine("send-message", func(rs *rivescript.RiveScript, args []string) string {
if len(args) >= 2 {
var (
channel = args[0]
message = strings.Join(args[1:], " ")
)
// Slide into their DMs.
log.Error("Send chat to [%s]: %s", channel, message)
h.client.Send(messages.Message{
Action: messages.ActionMessage,
Channel: channel,
Message: message,
})
} else {
return "[send-message: invalid number of parameters]"
}
return ""
})
}

View File

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

View File

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

View File

@ -171,25 +171,6 @@ Example: say you have a global keyword trigger on public rooms and want to DM a
< topic
```
**Note:** the `dm` command will auto insert the @ prefix for the channel name, so it can only send to DM threads. Use `send-message` for the ability to send a message to a public channel (or DM thread) by having more control over the exact spelling of the channel name.
## Send Message
Send a chat message to a public channel. Like the `dm` macro but doesn't assume it to be a DM thread.
Usage: `send-message <channel> <message to send>`
Example:
```rivescript
+ to * send the message *
* <get isAdmin> <> true => This command is only available to operators.
- I will send that to the <star1> channel.
^ <call>send-message <star1> "{sentence}<star2>{/sentence}"</call>
```
Then you could say: "to lobby send the message hey everyone" to send to a public channel (by its internal ID), or "to @soandso send the message hey there" to send it to a DM thread.
## Takeback
Take back a message by its ID. This may be useful if you have a global moderator trigger set up so you can remove a user's message.
@ -230,17 +211,3 @@ Example:
```
Note: the `report` command returns no text (except on error).
## NSFW
Send a BareRTC `/nsfw` operator command to mark a user's camera as Explicit.
Usage: `nsfw <username>`
Example:
```rivescript
+ please mark the camera red for *
- I will issue the NSFW camera command for: <star>
^ <call>nsfw <star></call>
```

View File

@ -3,28 +3,21 @@
On first run it will create the default settings.toml file for you which you may then customize to your liking:
```toml
Version = 7
Version = 2
Title = "BareRTC"
Branding = "BareRTC"
WebsiteURL = "http://www.example.com"
CORSHosts = ["http://www.example.com"]
AdminAPIKey = "e635e463-7987-4788-94f3-671a5c2a589f"
WebsiteURL = "https://www.example.com"
CORSHosts = ["https://www.example.com"]
PermitNSFW = true
UseXForwardedFor = false
WebSocketReadLimit = 40971520
UseXForwardedFor = true
WebSocketReadLimit = 41943040
MaxImageWidth = 1280
PreviewImageWidth = 360
[JWT]
Enabled = true
Enabled = false
Strict = true
SecretKey = "05c45344-1c52-430b-beb9-c3f64ff7ed12"
LandingPageURL = "https://www.example.com/enter-chat"
[TURN]
URLs = ["stun:stun.l.google.com:19302"]
Username = ""
Credential = ""
SecretKey = ""
[[PublicChannels]]
ID = "lobby"
@ -36,174 +29,28 @@ PreviewImageWidth = 360
ID = "offtopic"
Name = "Off Topic"
WelcomeMessages = ["Welcome to the Off Topic channel!"]
[[WebhookURLs]]
Name = "report"
Enabled = true
URL = "https://www.example.com/v1/barertc/report"
[[WebhookURLs]]
Name = "profile"
Enabled = true
URL = "https://www.example.com/v1/barertc/profile"
[VIP]
Name = "VIP"
Branding = "<em>VIP Members</em>"
Icon = "fa fa-circle"
MutuallySecret = false
[[MessageFilters]]
Enabled = true
PublicChannels = true
PrivateChannels = true
KeywordPhrases = [
"\\bswear words\\b",
"\\b(swearing|cursing)\\b",
"suck my ([^\\s]+)"
]
CensorMessage = true
ForwardMessage = false
ReportMessage = false
ChatServerResponse = "Watch your language."
[[ModerationRule]]
Username = "example"
CameraAlwaysNSFW = true
NoBroadcast = false
NoVideo = false
NoImage = false
[DirectMessageHistory]
Enabled = true
SQLiteDatabase = "database.sqlite"
RetentionDays = 90
DisclaimerMessage = "Reminder: please conduct yourself honorable in DMs."
[Logging]
Enabled = true
Directory = "./logs"
Channels = ["lobby"]
Usernames = []
```
A description of the config directives includes:
## Website Settings
* **Version** number for the settings file itself. When new features are added, the Version will increment and your settings.toml will be written to disk with sensible defaults filled in for the new options.
* **Title** goes in the title bar of the chat page.
* **Branding** is the title shown in the corner of the page. HTML is permitted here! You may write an `<img>` tag to embed an image or use custom markup to color and prettify your logo.
* **WebsiteURL** is the base URL of your actual website which is used in a couple of places:
* The About page will link to your website.
* If using [JWT authentication](#authentication), avatar and profile URLs may be relative (beginning with a "/") and will append to your website URL to safe space on the JWT token size!
* **CORSHosts** names HTTP hosts for Cross Origin Resource Sharing. Usually, this will be the same as your WebsiteURL. This feature is used with the [Web API](API.md) if your front-end page needs to call e.g. the /api/statistics endpoint on BareRTC.
* **AdminAPIKey** is a shared secret authentication key for the admin API endpoints.
* **PermitNSFW**: for user webcam streams, expressly permit "NSFW" content if the user opts in to mark their feed as such. Setting this will enable pop-up modals regarding NSFW video and give broadcasters an opt-in button, which will warn other users before they click in to watch.
* **UseXForwardedFor**: set it to true and (for logging) the user's remote IP will use the X-Real-IP header or the first address in X-Forwarded-For. Set this if you run the app behind a proxy like nginx if you want IPs not to be all localhost.
* **WebSocketReadLimit**: sets a size limit for WebSocket messages - it essentially also caps the max upload size for shared images (add a buffer as images will be base64 encoded on upload).
* **MaxImageWidth**: for pictures shared in chat the server will resize them down to no larger than this width for the full size view.
* **PreviewImageWidth**: to not flood the chat, the image in chat is this wide and users can click it to see the MaxImageWidth in a lightbox modal.
## JWT Authentication
Settings for JWT [Authentication](#authentication):
* **Enabled** (bool): activate the JWT token authentication feature.
* **Strict** (bool): if true, **only** valid signed JWT tokens may log in. If false, users with no/invalid token can enter their own username without authentication.
* **SecretKey** (string): the JWT signing secret shared with your back-end app.
## Public Channels
Settings for the default public text channels of your room.
* **ID** (string): an arbitrary 'username' for the chat channel, like "lobby".
* **Name** (string): the user friendly name for the channel, like "Off Topic"
* **Icon** (string, optional): CSS class names for FontAwesome icon for the channel, like "fa fa-message"
* **WelcomeMessages** ([]string, optional): messages that are delivered by ChatServer to the user when they connect to the server. Useful to give an introduction to each channel, list its rules, etc.
## VIP Status
If using JWT authentication, your website can mark some users as VIPs when sending them over to the chat. The `[VIP]` section of settings.toml lets you customize the branding and behavior in BareRTC:
* **Name** (string): what you call your VIP users, used in mouse-over tooltips.
* **Branding** (string): HTML supported, this will appear in webcam sharing modals to "make my cam only visible to fellow VIP users"
* **Icon** (string): icon CSS name from Font Awesome.
* **MutuallySecret** (bool): if true, the VIP features are hidden and only visible to people who are, themselves, VIP. For example, the icon on the Who List will only show to VIP users but non-VIP will not see the icon.
## Message Filters
BareRTC supports optional server-side filtering of messages. These can be applied to monitor public channels, Direct Messages, or both; and provide a variety of options how you want to handle filtered messages.
You can configure multiple sets of filters to treat different sets of keywords with different behaviors.
Options for the `[[MessageFilters]]` section include:
* **Enabled** (bool): whether to enable this filter. The default settings.toml has a filter template example by default, but it's not enabled.
* **PublicChannels** (bool): whether to apply the filter to public channel messages.
* **PrivateChannels** (bool): whether to apply the filter to private (Direct Message) channels.
* **KeywordPhrases** ([]string): a listing of regular expression compatible strings to search the user's message again.
* Tip: use word-boundary `\b` metacharacters to detect whole words and reduce false positives from partial word matches.
* **CensorMessage** (bool): if true, the matching keywords will be substituted with asterisks in the user's message when it appears in chat.
* **ForwardMessage** (bool): whether to repeat the message to the other chatters. If false, the sender will see their own message echo (possibly censored) but other chatters will not get their message at all.
* **ReportMessage** (bool): if true, report the message along with the recent context (previous 10 messages in that conversation) to your website's report webhook (if configured).
* **ChatServerResponse** (str): optional - you can have ChatServer send a message to the sender (in the same channel) after the filter has been run. An empty string will not send a ChatServer message.
## Moderation Rules
This section of the config file allows you to place certain moderation rules on specific users of your chat room. For example: if somebody perpetually needs to be reminded to label their camera as NSFW, you can enforce a moderation rule on that user which _always_ forces their camera to be NSFW.
Settings in the `[[ModerationRule]]` array include:
* **Username** (string): the username on chat to apply the rule to.
* **CameraAlwaysNSFW** (bool): if true, the user's camera is forced to NSFW and they will receive a ChatServer message when they try and remove the flag themselves.
* **NoBroadcast** (bool): if true, the user is not allowed to share their webcam and the server will send them a 'cut' message any time they go live, along with a ChatServer message informing them of this.
* **NoVideo** (bool): if true, the user is not allowed to broadcast their camera OR watch any camera on chat.
* **NoImage** (bool): if true, the user is not allowed to share images or see images shared by others on chat.
### JWT Moderation Rules
Rather than in the server-side settings.toml, you can enable these moderation rules from your website's side as well by including them in the "rules" custom key of your JWT token.
The "rules" key is a string array with short labels representing each of the rules:
| Moderation Rule | JWT "Rules" Value |
|------------------|-------------------|
| CameraAlwaysNSFW | redcam |
| NoBroadcast | nobroadcast |
| NoVideo | novideo |
| NoImage | noimage |
An example JWT token claims object may look like:
```javascript
{
"sub": "username", // Username for chat
"nick": "Display name", // Friendly name
"img": "/static/photos/username.jpg", // user picture URL
"url": "/u/username", // user profile URL
"rules": ["redcam", "noimage"], // moderation rules
}
```
## Direct Message History
You can allow BareRTC to retain temporary DM history for your users so they can remember where they left off with people.
Settings for this include:
* **Enabled** (bool): set to true to log chat DMs history.
* **SQLiteDatabase** (string): the name of the .sqlite DB file to store their DMs in.
* **RetentionDays** (int): how many days of history to record before old chats are erased. Set to zero for no limit.
* **DisclaimerMessage** (string): a custom banner message to show at the top of DM threads. HTML is supported. A good use is to remind your users of your local site rules.
## Logging
This feature can enable logging of public channels and user DMs to text files on disk. It is useful to keep a log of your public channels so you can look back at the context of a reported public chat if you weren't available when it happened, or to selectively log the DMs of specific users to investigate a problematic user.
Settings include:
* **Enabled** (bool): to enable or disable the logging feature.
* **Directory** (string): a folder on disk to save logs into. Public channels will save directly as text files here (e.g. "lobby.txt"), while DMs will create a subfolder for the monitored user.
* **Channels** ([]string): array of public channel IDs to monitor.
* **Usernames** ([]string): array of chat usernames to monitor.
* Website settings:
* **Title** goes in the title bar of the chat page.
* **Branding** is the title shown in the corner of the page. HTML is permitted here! You may write an `<img>` tag to embed an image or use custom markup to color and prettify your logo.
* **WebsiteURL** is the base URL of your actual website which is used in a couple of places:
* The About page will link to your website.
* If using [JWT authentication](#authentication), avatar and profile URLs may be relative (beginning with a "/") and will append to your website URL to safe space on the JWT token size!
* **UseXForwardedFor**: set it to true and (for logging) the user's remote IP will use the X-Real-IP header or the first address in X-Forwarded-For. Set this if you run the app behind a proxy like nginx if you want IPs not to be all localhost.
* **CORSHosts**: your website's domain names that will be allowed to access [JSON APIs](#JSON APIs), like `/api/statistics`.
* **PermitNSFW**: for user webcam streams, expressly permit "NSFW" content if the user opts in to mark their feed as such. Setting this will enable pop-up modals regarding NSFW video and give broadcasters an opt-in button, which will warn other users before they click in to watch.
* **WebSocketReadLimit**: sets a size limit for WebSocket messages - it essentially also caps the max upload size for shared images (add a buffer as images will be base64 encoded on upload).
* **MaxImageWidth**: for pictures shared in chat the server will resize them down to no larger than this width for the full size view.
* **PreviewImageWidth**: to not flood the chat, the image in chat is this wide and users can click it to see the MaxImageWidth in a lightbox modal.
* **JWT**: settings for JWT [Authentication](#authentication).
* Enabled (bool): activate the JWT token authentication feature.
* Strict (bool): if true, **only** valid signed JWT tokens may log in. If false, users with no/invalid token can enter their own username without authentication.
* SecretKey (string): the JWT signing secret shared with your back-end app.
* **PublicChannels**: list the public channels and their configuration. The default channel will be the first one listed.
* ID (string): an arbitrary 'username' for the chat channel, like "lobby".
* Name (string): the user friendly name for the channel, like "Off Topic"
* Icon (string, optional): CSS class names for FontAwesome icon for the channel, like "fa fa-message"
* WelcomeMessages ([]string, optional): messages that are delivered by ChatServer to the user when they connect to the server. Useful to give an introduction to each channel, list its rules, etc.

View File

@ -9,11 +9,6 @@ Webhooks are configured in your settings.toml file and look like so:
Name = "report"
Enabled = true
URL = "http://localhost:8080/v1/barertc/report"
[[WebhookURLs]]
Name = "profile"
Enabled = true
URL = "http://localhost:8080/v1/barertc/profile"
```
All Webhooks will be called as **POST** requests and will contain a JSON payload that will always have the following two keys:
@ -48,40 +43,3 @@ Example JSON payload posted to the webhook:
```
BareRTC expects your webhook URL to return a 200 OK status code or it will surface an error in chat to the reporter.
## Profile Webhook
Enabling this webhook will allow your site to deliver more detailed profile information on demand for your users. This is used in chat when somebody opens the Profile Card modal for a user in chat.
BareRTC will call your webhook URL with the following payload:
```javascript
{
"Action": "profile",
"APIKey": "shared secret from settings.toml#AdminAPIKey",
"Username": "soandso"
}
```
The expected response from your endpoint should follow this format:
```javascript
{
"StatusCode": 200,
"Data": {
"OK": true,
"Error": "any error messaging (omittable if no errors)",
"ProfileFields": [
{
"Name": "Age",
"Value": "30yo",
},
{
"Name": "Gender",
"Value": "Man",
},
// etc.
]
}
}
```

27
go.mod
View File

@ -7,14 +7,13 @@ require (
github.com/BurntSushi/toml v1.3.2
github.com/aichaos/rivescript-go v0.4.0
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/google/uuid v1.5.0
github.com/google/uuid v1.3.0
github.com/mattn/go-shellwords v1.0.12
github.com/microcosm-cc/bluemonday v1.0.25
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
github.com/urfave/cli/v2 v2.25.7
golang.org/x/image v0.12.0
golang.org/x/image v0.11.0
nhooyr.io/websocket v1.8.7
)
@ -22,15 +21,8 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/russross/blackfriday v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
@ -45,13 +37,8 @@ require (
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
golang.org/x/crypto v0.12.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/term v0.11.0 // indirect
)

66
go.sum
View File

@ -20,17 +20,13 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20230812105242-81d76064690d h1:9aaGwVf4q+kknu+mROAXUApJ1DoOwhE8dGj/XLBYzWg=
github.com/dop251/goja v0.0.0-20230812105242-81d76064690d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de h1:lA38Xtzr1Wo+iQdkN2E11ziKXJYRxLlzK/e2/fdxoEI=
github.com/dop251/goja v0.0.0-20230919151941-fc55792775de/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@ -39,8 +35,6 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@ -51,12 +45,12 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -77,11 +71,10 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 h1:pUa4ghanp6q4IJHwE9RwLgmVFfReJN+KbQ8ExNEUUoQ=
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
@ -91,8 +84,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
@ -103,9 +96,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
@ -124,8 +116,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.15.0/go.mod h1:cIuvLEne0aoVhAgh/O6ac0Op8WWw9H6eYCriF+tEHG0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@ -182,11 +172,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -199,8 +189,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -223,14 +213,13 @@ golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -239,9 +228,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -251,8 +239,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -281,13 +269,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -1,74 +0,0 @@
{{define "index"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css?{{.CacheHash}}">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
<title>{{.Config.Title}}</title>
</head>
<body>
<!-- Photo Detail Modal -->
<div class="modal" id="photo-modal">
<div class="modal-background" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></div>
<div class="modal-content photo-modal">
<div class="image is-fullwidth">
<img id="modalImage" oncontextmenu="return false">
</div>
</div>
<button class="modal-close is-large" aria-label="close" onclick="document.querySelector('#photo-modal').classList.remove('is-active')"></button>
</div>
<div id="app"></div>
<!-- BareRTC constants injected by IndexPage route -->
<script type="text/javascript">
const Branding = {{.Config.Branding}};
const BareRTCStrings = {{.Config.Strings}};
const PublicChannels = {{.Config.GetChannels}};
const DMDisclaimer = {{.Config.DirectMessageHistory.DisclaimerMessage}};
const WebsiteURL = "{{.Config.WebsiteURL}}";
const PermitNSFW = {{AsJS .Config.PermitNSFW}};
const TURN = {{.Config.TURN}};
const WebhookURLs = {{.Config.WebhookURLs}};
const VIP = {{.Config.VIP}};
const UserJWTToken = {{.JWTTokenString}};
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
const UserJWTClaims = {{.JWTClaims.ToJSON}};
const UserJWTRules = {{.JWTClaims.Rules.ToDict}};
const CachedBlocklist = {{.CachedBlocklist}};
const CacheHash = {{.CacheHash}};
// Show the photo detail modal.
function setModalImage(url) {
let $modalImg = document.querySelector("#modalImage"),
$modal = document.querySelector("#photo-modal");
$modalImg.src = url;
$modal.classList.add("is-active");
return false;
}
document.addEventListener('DOMContentLoaded', () => {
// Add global body click to hide the hamburger menu for chat settings.
const settingsMenu = document.querySelector("#chat-settings-hamburger-menu");
settingsMenu.addEventListener('click', (e) => {
settingsMenu.classList.toggle('is-active');
e.stopPropagation();
});
document.body.addEventListener('click', () => {
if (settingsMenu != undefined && settingsMenu.classList.contains("is-active")) {
settingsMenu.classList.remove('is-active');
}
})
});
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>
{{end}}

2107
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,28 +0,0 @@
{
"name": "barertc",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build -w --sourcemap=true --minify=false",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"floating-vue": "^2.0.0-beta.24",
"hark": "^1.2.3",
"interactjs": "^1.10.18",
"qrcodejs": "github:danielgjackson/qrcodejs",
"vue": "^3.3.4",
"vue-mention": "^2.0.0-alpha.3",
"vue3-emoji-picker": "^1.1.7",
"vue3-slider": "^1.9.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.3.1",
"eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1",
"vite": "^4.4.9"
}
}

View File

@ -2,7 +2,6 @@ package barertc
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
@ -13,7 +12,6 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Statistics (/api/statistics) returns info about the users currently logged onto the chat,
@ -356,653 +354,6 @@ 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(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Check if any of these users are online, and 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(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Validate the API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
// Check if any of these users are online, and disconnect them from the chat.
var removed int
for _, username := range params.Usernames {
if sub, err := s.GetSubscriber(username); err == nil {
// Broadcast to everybody that the user left the chat.
message := messages.PresenceExited
if params.Kick {
message = messages.PresenceKicked
}
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: message,
})
// Custom message to send to them?
if params.Message != "" {
sub.ChatServer(params.Message)
}
// Disconnect them.
sub.SendJSON(messages.Message{
Action: messages.ActionKick,
})
sub.authenticated = false
sub.Username = ""
removed++
}
}
// If any changes to blocklists were made: send the Who List.
if removed > 0 {
s.SendWhoList()
}
enc.Encode(result{
OK: true,
Removed: removed,
})
})
}
// UserProfile (/api/profile) fetches profile information about a user.
//
// This endpoint will proxy to your WebhookURL for the "profile" endpoint.
// 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(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
_, _, 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(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
claims, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Get the user from the chat roster.
sub, err := s.GetSubscriber(claims.Subject)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "You are not logged into the chat room.",
})
return
}
// Fetch a page of message history.
messages, remaining, err := models.PaginateDirectMessages(sub.Username, params.Username, params.BeforeID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
enc.Encode(result{
Error: err.Error(),
})
return
}
enc.Encode(result{
OK: true,
Messages: messages,
Remaining: remaining,
})
})
}
// ClearMessages (/api/message/clear) deletes all the stored direct messages for a user.
//
// It can be called by the authenticated user themself (with JWTToken), or from your website
// (with APIKey) in which case you can remotely clear history for a user.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "JWTToken": "the caller's jwt token",
// "APIKey": "your website's admin API key"
// "Username": "if using your APIKey to specify a user to delete",
// }
//
// The response JSON will look like the following:
//
// {
// "OK": true,
// "Error": "only on error responses",
// "MessagesErased": 123,
// }
//
// The Remaining value is how many older messages still exist to be loaded.
func (s *Server) ClearMessages() http.HandlerFunc {
type request struct {
JWTToken string
APIKey string
Username string
}
type result struct {
OK bool
Error string `json:",omitempty"`
MessagesErased int `json:""`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&params); err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Authenticate this request.
if params.APIKey != "" {
// By admin API key.
if params.APIKey != config.Current.AdminAPIKey {
w.WriteHeader(http.StatusUnauthorized)
enc.Encode(result{
Error: "Authentication denied.",
})
return
}
} else {
// Are JWT tokens enabled on the server?
if !config.Current.JWT.Enabled || params.JWTToken == "" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "JWT authentication is not available.",
})
return
}
// Validate the user's JWT token.
claims, _, err := jwt.ParseAndValidate(params.JWTToken)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: err.Error(),
})
return
}
// Set the username to clear.
params.Username = claims.Subject
}
// Erase their message history.
count, err := (models.DirectMessage{}).ClearMessages(params.Username)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
enc.Encode(result{
Error: err.Error(),
})
return
}
enc.Encode(result{
OK: true,
MessagesErased: count,
})
})
}
// Blocklist cache sent over from your website.
var (
// Map of username to the list of usernames they block.

View File

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

View File

@ -13,7 +13,7 @@ import (
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 15
var currentVersion = 6
// Config for your BareRTC app.
type Config struct {
@ -30,34 +30,21 @@ type Config struct {
Branding string
WebsiteURL string
CORSHosts []string
AdminAPIKey string
PermitNSFW bool
BlockableAdmins bool
CORSHosts []string
AdminAPIKey string
PermitNSFW bool
UseXForwardedFor bool
WebSocketReadLimit int64
WebSocketSendTimeout int
MaxImageWidth int
PreviewImageWidth int
WebSocketReadLimit int64
MaxImageWidth int
PreviewImageWidth int
TURN TurnConfig
PublicChannels []Channel
WebhookURLs []WebhookURL
VIP VIP
MessageFilters []*MessageFilter
ModerationRule []*ModerationRule
DirectMessageHistory DirectMessageHistory
Strings Strings
Logging Logging
}
type TurnConfig struct {
@ -66,43 +53,17 @@ type TurnConfig struct {
Credential string
}
type VIP struct {
Name string
Branding string
Icon string
MutuallySecret bool
}
type DirectMessageHistory struct {
Enabled bool
SQLiteDatabase string
RetentionDays int
DisclaimerMessage string
}
// GetChannels returns a JavaScript safe array of the default PublicChannels.
func (c Config) GetChannels() template.JS {
data, _ := json.Marshal(c.PublicChannels)
return template.JS(data)
}
// GetChannel looks up and returns a channel by ID.
func (c Config) GetChannel(id string) (Channel, bool) {
for _, ch := range c.PublicChannels {
if ch.ID == id {
return ch, true
}
}
return Channel{}, false
}
// Channel config for a default public room.
type Channel struct {
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
VIP bool // For VIP users only
PermitPhotos bool // photos are allowed to be shared
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
// ChatServer messages to send to the user immediately upon connecting.
WelcomeMessages []string
@ -115,31 +76,6 @@ type WebhookURL struct {
URL string
}
// Strings config for customizing certain user-facing messaging around the app.
type Strings struct {
ModRuleErrorCameraAlwaysNSFW string
ModRuleErrorNoBroadcast string
ModRuleErrorNoVideo string
ModRuleErrorNoImage string
}
// Logging configs to monitor channels or usernames.
type Logging struct {
Enabled bool
Directory string
Channels []string
Usernames []string
}
// ModerationRule applies certain rules to moderate specific users.
type ModerationRule struct {
Username string
CameraAlwaysNSFW bool
NoBroadcast bool
NoVideo bool
NoImage bool
}
// Current loaded configuration.
var Current = DefaultConfig()
@ -154,10 +90,9 @@ func DefaultConfig() Config {
CORSHosts: []string{
"https://www.example.com",
},
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
WebSocketSendTimeout: 10, // seconds
MaxImageWidth: 1280,
PreviewImageWidth: 360,
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
MaxImageWidth: 1280,
PreviewImageWidth: 360,
PublicChannels: []Channel{
{
ID: "lobby",
@ -173,16 +108,6 @@ func DefaultConfig() Config {
WelcomeMessages: []string{
"Welcome to the Off Topic channel!",
},
PermitPhotos: true,
},
{
ID: "vip",
Name: "VIPs Only",
VIP: true,
PermitPhotos: true,
WelcomeMessages: []string{
"This channel is only for operators and VIPs.",
},
},
},
TURN: TurnConfig{
@ -195,50 +120,6 @@ func DefaultConfig() Config {
Name: "report",
URL: "https://example.com/barertc/report",
},
{
Name: "profile",
URL: "https://example.com/barertc/user-profile",
},
},
VIP: VIP{
Name: "VIP",
Branding: "<em>VIP Members</em>",
Icon: "fa fa-circle",
},
MessageFilters: []*MessageFilter{
{
PublicChannels: true,
PrivateChannels: true,
KeywordPhrases: []string{
`\bswear words\b`,
`\b(swearing|cursing)\b`,
`suck my ([^\s]+)`,
},
CensorMessage: true,
ChatServerResponse: "Watch your language.",
},
},
ModerationRule: []*ModerationRule{
{
Username: "example",
},
},
Strings: Strings{
ModRuleErrorCameraAlwaysNSFW: "A chat server moderation rule is currently in place which forces your camera to stay marked as Explicit. Please contact a chat moderator if you have any questions about this.",
ModRuleErrorNoBroadcast: "A chat server moderation rule is currently in place which restricts your ability to share your webcam. Please contact a chat operator for more information.",
ModRuleErrorNoVideo: "A chat server moderation rule is currently in place which restricts your ability to watch webcams. Please contact a chat operator for more information.",
ModRuleErrorNoImage: "A chat server moderation rule is currently in place which restricts your ability to share images. Please contact a chat operator for more information.",
},
DirectMessageHistory: DirectMessageHistory{
Enabled: false,
SQLiteDatabase: "database.sqlite",
RetentionDays: 90,
DisclaimerMessage: `<i class="fa fa-info-circle mr-1"></i> <strong>Reminder:</strong> please conduct yourself honorably in Direct Messages.`,
},
Logging: Logging{
Directory: "./logs",
Channels: []string{"lobby", "offtopic"},
Usernames: []string{},
},
}
c.JWT.Strict = true
@ -285,13 +166,3 @@ func WriteSettings() error {
}
return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
}
// GetModerationRule returns a matching ModerationRule for the given user, or nil if no rule is found.
func (c Config) GetModerationRule(username string) *ModerationRule {
for _, rule := range c.ModerationRule {
if rule.Username == username {
return rule
}
}
return nil
}

View File

@ -1,47 +0,0 @@
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
}

View File

@ -11,7 +11,6 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
"git.kirsle.net/apps/barertc/pkg/util"
)
@ -63,8 +62,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
other.authenticated = false
other.Username = ""
s.DeleteSubscriber(other)
}
// They will take over their original username.
@ -84,6 +82,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
sub.SendJSON(messages.Message{
Action: messages.ActionKick,
})
s.DeleteSubscriber(sub)
return
}
@ -98,7 +97,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: msg.Username,
Message: messages.PresenceJoined,
Message: "has joined the room!",
})
// Send the user back their settings.
@ -126,7 +125,7 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
log.Info("[%s to #%s] %s", sub.Username, msg.Channel, msg.Message)
}
if sub.Username == "" || !sub.authenticated {
if sub.Username == "" {
sub.ChatServer("You must log in first.")
return
}
@ -160,38 +159,6 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
MessageID: mid,
}
// Run message filters.
if filter, ok := s.filterMessage(sub, msg, &message); ok {
// What do we do with the matched filter?
// If we will not send this message out, do echo it back to
// the sender (possibly with censors applied).
if !filter.ForwardMessage {
s.SendTo(sub.Username, message)
}
// Is ChatServer to say something?
if filter.ChatServerResponse != "" {
sub.ChatServer(filter.ChatServerResponse)
}
// Are we to report the message to the site admin?
if filter.ReportMessage {
// If the user is OP, just tell them we would.
if sub.IsAdmin() {
sub.ChatServer("Your recent chat context would have been reported to your main website.")
} else if err := s.reportFilteredMessage(sub, msg); err != nil {
// Send the report to the main website.
log.Error("Reporting filtered message: %s", err)
}
}
// If we are not forwarding this message, stop here.
if !filter.ForwardMessage {
return
}
}
// Is this a DM?
if strings.HasPrefix(msg.Channel, "@") {
// Echo the message only to both parties.
@ -201,81 +168,37 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
// Don't deliver it if the receiver has muted us. Note: admin users, even if muted,
// can still deliver a DM to the one who muted them.
rcpt, err := s.GetSubscriber(strings.TrimPrefix(msg.Channel, "@"))
if err != nil {
// 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() {
if err == nil && rcpt.Mutes(sub.Username) && !sub.IsAdmin() {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
}
// If the sender already mutes the recipient, reply back with the error.
if sub.Mutes(rcpt.Username) && !sub.IsAdmin() {
if err == nil && sub.Mutes(rcpt.Username) {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
// If there is blocking happening, do not send.
if sub.Blocks(rcpt) {
return
}
// Log this conversation?
if IsLoggingUsername(sub) && IsLoggingUsername(rcpt) {
// Both sides are logged, copy it to both logs.
LogMessage(sub, rcpt.Username, sub.Username, msg)
LogMessage(rcpt, sub.Username, sub.Username, msg)
} else if IsLoggingUsername(sub) {
// The sender of this message is being logged.
LogMessage(sub, rcpt.Username, sub.Username, msg)
} else if IsLoggingUsername(rcpt) {
// The recipient of this message is being logged.
LogMessage(rcpt, sub.Username, sub.Username, msg)
}
// Add it to the DM history SQLite database.
if err := (models.DirectMessage{}).LogMessage(sub.Username, rcpt.Username, message); err != nil && err != models.ErrNotInitialized {
log.Error("Logging DM history to SQLite: %s", err)
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
return
}
// Are we logging this public channel?
if IsLoggingChannel(msg.Channel) {
LogChannel(s, msg.Channel, sub.Username, msg)
}
// Broadcast a chat message to the room.
s.Broadcast(message)
}
// OnTakeback handles takebacks (delete your message for everybody)
func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
// In case we're in a DM thread, remove this message ID from the history table
// if the username matches.
wasRemovedFromHistory, err := (models.DirectMessage{}).TakebackMessage(sub.Username, msg.MessageID, sub.IsAdmin())
if err != nil && err != models.ErrNotInitialized {
log.Error("Error taking back DM history message (%s, %d): %s", sub.Username, msg.MessageID, err)
}
// Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock()
_, ok := sub.messageIDs[msg.MessageID]
sub.midMu.Unlock()
if !ok {
// The messageID is not found in the current chat session, but did we remove
// it from past DM history for the correct current user?
if !wasRemovedFromHistory {
sub.ChatServer("That is not your message to take back.")
return
}
sub.ChatServer("That is not your message to take back.")
return
}
}
@ -304,17 +227,6 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
return
}
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from watching cameras on chat?
if rule.NoImage {
sub.ChatServer(config.Current.Strings.ModRuleErrorNoImage)
return
}
}
// Detect image type and convert it into an <img src="data:"> tag.
var (
filename = msg.Message
@ -377,11 +289,6 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
return
}
// If there is blocking happening, do not send.
if sub.Blocks(rcpt) {
return
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
@ -394,30 +301,8 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
// OnMe handles current user state updates.
func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Reflect a 'me' message back at them? (e.g. if server forces their camera NSFW)
var reflect bool
if msg.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive {
log.Debug("User %s turns on their video feed", sub.Username)
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from sharing their camera on chat?
if rule.NoBroadcast || rule.NoVideo {
sub.SendCut()
sub.ChatServer(config.Current.Strings.ModRuleErrorNoBroadcast)
msg.VideoStatus = 0
}
// Is their camera forced to always be explicit?
if rule.CameraAlwaysNSFW && !(msg.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW) {
msg.VideoStatus |= messages.VideoFlagNSFW
reflect = true // send them a 'me' echo afterward to inform the front-end page properly of this
sub.ChatServer(config.Current.Strings.ModRuleErrorCameraAlwaysNSFW)
}
}
}
// Hidden status: for operators only, + fake a join/exit chat message.
@ -427,14 +312,14 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceExited,
Message: "has exited the room!",
})
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
// Leaving hidden - fake join message
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceJoined,
Message: "has joined the room!",
})
}
} else if msg.ChatStatus == "hidden" {
@ -448,37 +333,14 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
// Sync the WhoList to everybody.
s.SendWhoList()
// Reflect a 'me' message back?
if reflect {
sub.SendMe()
}
}
// OnOpen is a client wanting to start WebRTC with another, e.g. to see their camera.
func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
// Moderation rules?
if rule := sub.GetModerationRule(); rule != nil {
// Are they barred from watching cameras on chat?
if rule.NoVideo {
sub.ChatServer(config.Current.Strings.ModRuleErrorNoVideo)
return
}
}
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
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,
)
log.Error(err.Error())
return
}
@ -510,71 +372,22 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
})
}
// IsVideoNotAllowed verifies whether a viewer can open a broadcaster's camera.
//
// Returns a boolean and an error message to return if false.
func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, string) {
var (
ourVideoActive = (sub.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
theirVideoActive = (other.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
theirMutualRequired = (other.VideoStatus & messages.VideoFlagMutualRequired) == messages.VideoFlagMutualRequired
theirVIPRequired = (other.VideoStatus & messages.VideoFlagOnlyVIP) == messages.VideoFlagOnlyVIP
)
// Conditions in which we can not watch their video.
var conditions = []struct {
If bool
Error string
}{
{
If: !theirVideoActive,
Error: "Their video is not currently enabled.",
},
{
If: theirMutualRequired && !ourVideoActive,
Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username),
},
{
If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
{
If: (other.Mutes(sub.Username) || other.Blocks(sub)) && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
}
for _, c := range conditions {
if c.If {
return false, c.Error
}
}
return true, ""
}
// OnBoot is a user kicking you off their video stream.
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) {
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
sub.muteMu.Lock()
if boot {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
sub.booted[msg.Username] = struct{}{}
// 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() {
other.ChatServer(
"%s has booted you off of their camera!",
sub.Username,
)
}
} else {
log.Info("%s unboots %s from their camera", sub.Username, msg.Username)
delete(sub.booted, msg.Username)
}
sub.booted[msg.Username] = struct{}{}
sub.muteMu.Unlock()
// If the subject of the boot is an admin, inform them they have been booted.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
other.ChatServer(
"%s has booted you off of their camera!",
sub.Username,
)
}
s.SendWhoList()
}
@ -604,34 +417,13 @@ func (s *Server) OnMute(sub *Subscriber, msg messages.Message, mute bool) {
s.SendWhoList()
}
// OnBlock is a user placing a hard block (hide from) another user.
func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
log.Info("%s blocks %s", sub.Username, msg.Username)
// If the subject of the block is an admin, return an error.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
sub.ChatServer(
"You are not allowed to block a chat operator.",
)
return
}
sub.muteMu.Lock()
sub.blocked[msg.Username] = struct{}{}
sub.muteMu.Unlock()
// Send the Who List so the blocker/blockee can disappear from each other's list.
s.SendWhoList()
}
// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website.
func (s *Server) OnBlocklist(sub *Subscriber, msg messages.Message) {
log.Info("[%s] syncs their blocklist: %s", sub.Username, msg.Usernames)
log.Info("%s syncs their blocklist: %s", sub.Username, msg.Usernames)
sub.muteMu.Lock()
for _, username := range msg.Usernames {
sub.muted[username] = struct{}{}
sub.blocked[username] = struct{}{}
}
sub.muteMu.Unlock()
@ -647,14 +439,8 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
return
}
// Attach recent message context to DMs.
if strings.HasPrefix(msg.Channel, "@") {
context := getDirectMessageContext(sub.Username, msg.Username)
msg.Message += "\n\nRecent message context:\n\n" + context
}
// Post to the report webhook.
if _, err := PostWebhook(WebhookReport, WebhookRequest{
if err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
APIKey: config.Current.AdminAPIKey,
Report: WebhookRequestReport{
@ -678,6 +464,7 @@ func (s *Server) OnCandidate(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -693,6 +480,7 @@ func (s *Server) OnSDP(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -708,6 +496,7 @@ func (s *Server) OnWatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -722,6 +511,7 @@ func (s *Server) OnUnwatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}

View File

@ -14,13 +14,11 @@ import (
type Claims struct {
// Custom claims.
IsAdmin bool `json:"op,omitempty"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"img,omitempty"`
ProfileURL string `json:"url,omitempty"`
Nick string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"`
Rules Rules `json:"rules,omitempty"`
// Standard claims. Notes:
// subject = username

View File

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

View File

@ -1,156 +0,0 @@
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
}

View File

@ -1,173 +0,0 @@
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, ":")),
)
}

View File

@ -1,18 +1,15 @@
package messages
import (
"sync"
"time"
)
import "sync"
// Auto incrementing Message ID for anything pushed out by the server.
var (
messageID = time.Now().Unix()
messageID int
mu sync.Mutex
)
// NextMessageID atomically increments and returns a new MessageID.
func NextMessageID() int64 {
func NextMessageID() int {
mu.Lock()
defer mu.Unlock()
messageID++
@ -45,7 +42,7 @@ type Message struct {
DND bool `json:"dnd,omitempty"` // Do Not Disturb, e.g. DMs are closed
// Message ID to support takebacks/local deletions
MessageID int64 `json:"msgID,omitempty"`
MessageID int `json:"msgID,omitempty"`
// Sent on `open` actions along with the (other) Username.
OpenSecret string `json:"openSecret,omitempty"`
@ -69,12 +66,10 @@ type Message struct {
const (
// Actions sent by the client side only
ActionLogin = "login" // post the username to backend
ActionBoot = "boot" // boot a user off your video feed
ActionUnboot = "unboot" // unboot a user
ActionMute = "mute" // mute a user's chat messages
ActionLogin = "login" // post the username to backend
ActionBoot = "boot" // boot a user off your video feed
ActionMute = "mute" // mute a user's chat messages
ActionUnmute = "unmute"
ActionBlock = "block" // hard block another user
ActionBlocklist = "blocklist" // mute in bulk for usernames
ActionReport = "report" // user reports a message
@ -88,13 +83,11 @@ const (
ActionFile = "file" // image sharing in chat
ActionTakeback = "takeback" // user takes back (deletes) their message for everybody
ActionReact = "react" // emoji reaction to a chat message
ActionTyping = "typing" // typing indicator for DM threads
// Actions sent by server only
ActionPing = "ping"
ActionWhoList = "who" // server pushes the Who List
ActionPresence = "presence" // a user joined or left the room
ActionCut = "cut" // tell the client to turn off their webcam
ActionError = "error" // ChatServer errors
ActionKick = "disconnect" // client should disconnect (e.g. have been kicked).
@ -114,7 +107,6 @@ type WhoList struct {
// JWT auth extra settings.
Operator bool `json:"op"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"avatar,omitempty"`
ProfileURL string `json:"profileURL,omitempty"`
Emoji string `json:"emoji,omitempty"`
@ -127,17 +119,7 @@ const (
VideoFlagActive int = 1 << iota // user's camera is enabled/broadcasting
VideoFlagNSFW // viewer's camera is marked as NSFW
VideoFlagMuted // user source microphone is muted
VideoFlagNonExplicit // viewer prefers not to see NSFW cameras (don't auto-open red cams/auto-close blue cams going red)
VideoFlagIsTalking // broadcaster seems to be talking
VideoFlagMutualRequired // video wants viewers to share their camera too
VideoFlagMutualOpen // viewer wants to auto-open viewers' cameras
VideoFlagOnlyVIP // can only shows as active to VIP members
)
// Presence message templates.
const (
PresenceJoined = "has joined the room!"
PresenceExited = "has exited the room!"
PresenceKicked = "has been kicked from the room!"
PresenceBanned = "has been banned!"
PresenceTimedOut = "has timed out!"
)

View File

@ -1,231 +0,0 @@
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)
}
}
}

View File

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

View File

@ -1,235 +0,0 @@
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],
)
}

View File

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

View File

@ -81,7 +81,7 @@ func IndexPage() http.HandlerFunc {
return template.JS(fmt.Sprintf("%v", v))
},
})
tmpl, err := tmpl.ParseFiles("dist/index.html")
tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
if err != nil {
panic(err.Error())
}
@ -109,8 +109,7 @@ func AboutPage() http.HandlerFunc {
"CacheHash": util.RandomString(8),
// The current website settings.
"Config": config.Current,
"Hostname": r.Host,
"Config": config.Current,
}
tmpl.Funcs(template.FuncMap{
@ -126,16 +125,3 @@ func AboutPage() http.HandlerFunc {
tmpl.ExecuteTemplate(w, "index", values)
})
}
// LogoutPage returns the HTML template for the logout page.
func LogoutPage() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Load the template, TODO: once on server startup.
tmpl := template.New("index")
tmpl, err := tmpl.ParseFiles("web/templates/logout.html")
if err != nil {
panic(err.Error())
}
tmpl.ExecuteTemplate(w, "index", nil)
})
}

View File

@ -1,240 +0,0 @@
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(&params); 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())
})
}

View File

@ -1,13 +1,8 @@
package barertc
import (
"io"
"net/http"
"sync"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Server is the primary back-end server struct for BareRTC, see main.go
@ -21,46 +16,28 @@ type Server struct {
// Connected WebSocket subscribers.
subscribersMu sync.RWMutex
subscribers map[*Subscriber]struct{}
// Cached filehandles for channel logging.
logfh map[string]io.WriteCloser
}
// NewServer initializes the Server.
func NewServer() *Server {
return &Server{
subscriberMessageBuffer: 32,
subscriberMessageBuffer: 16,
subscribers: make(map[*Subscriber]struct{}),
}
}
// Setup the server: configure HTTP routes, etc.
func (s *Server) Setup() error {
// Enable the SQLite database for DM history?
if config.Current.DirectMessageHistory.Enabled {
if err := models.Initialize(config.Current.DirectMessageHistory.SQLiteDatabase); err != nil {
log.Error("Error initializing SQLite database: %s", err)
}
}
var mux = http.NewServeMux()
mux.Handle("/", IndexPage())
mux.Handle("/about", AboutPage())
mux.Handle("/logout", LogoutPage())
mux.Handle("/ws", s.WebSocket())
mux.Handle("/poll", s.PollingAPI())
mux.Handle("/api/statistics", s.Statistics())
mux.Handle("/api/blocklist", s.BlockList())
mux.Handle("/api/block/now", s.BlockNow())
mux.Handle("/api/disconnect/now", s.DisconnectNow())
mux.Handle("/api/authenticate", s.Authenticate())
mux.Handle("/api/shutdown", s.ShutdownAPI())
mux.Handle("/api/profile", s.UserProfile())
mux.Handle("/api/message/history", s.MessageHistory())
mux.Handle("/api/message/clear", s.ClearMessages())
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
s.mux = mux
@ -69,7 +46,5 @@ func (s *Server) Setup() error {
// ListenAndServe starts the web server.
func (s *Server) ListenAndServe(address string) error {
// Run the polling user idle kicker.
go s.KickIdlePollUsers()
return http.ListenAndServe(address, s.mux)
}

View File

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

View File

@ -39,20 +39,18 @@ func GetWebhook(name string) (config.WebhookURL, bool) {
}
// PostWebhook submits a JSON body to one of the app's configured webhooks.
//
// Returns the bytes of the response body (hopefully, JSON data) and any errors.
func PostWebhook(name string, payload any) ([]byte, error) {
func PostWebhook(name string, payload any) error {
webhook, ok := GetWebhook(name)
if !ok {
return nil, errors.New("PostWebhook(%s): webhook name %s is not configured")
return errors.New("PostWebhook(%s): webhook name %s is not configured")
} else if !webhook.Enabled {
return nil, errors.New("PostWebhook(%s): webhook is not enabled")
return errors.New("PostWebhook(%s): webhook is not enabled")
}
// JSON request body.
jsonStr, err := json.Marshal(payload)
if err != nil {
return nil, err
return err
}
// Make the API request to BareRTC.
@ -60,7 +58,7 @@ func PostWebhook(name string, payload any) ([]byte, error) {
log.Debug("PostWebhook(%s): to %s we send: %s", name, url, jsonStr)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
if err != nil {
return nil, err
return err
}
req.Header.Set("Content-Type", "application/json")
@ -69,15 +67,15 @@ func PostWebhook(name string, payload any) ([]byte, error) {
}
resp, err := client.Do(req)
if err != nil {
return nil, err
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Error("PostWebhook(%s): unexpected response from webhook URL %s (code %d): %s", name, url, resp.StatusCode, body)
return body, errors.New("unexpected error from webhook URL")
return errors.New("unexpected error from webhook URL")
}
return body, nil
return nil
}

View File

@ -2,17 +2,158 @@ package barertc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/util"
"nhooyr.io/websocket"
)
// Subscriber represents a connected WebSocket session.
type Subscriber struct {
// User properties
ID int // ID assigned by server
Username string
ChatStatus string
VideoStatus int
DND bool // Do Not Disturb status (DMs are closed)
JWTClaims *jwt.Claims
authenticated bool // has passed the login step
loginAt time.Time
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
muted map[string]struct{} // usernames you muted
// Record which message IDs belong to this user.
midMu sync.Mutex
messageIDs map[int]struct{}
}
// ReadLoop spawns a goroutine that reads from the websocket connection.
func (sub *Subscriber) ReadLoop(s *Server) {
go func() {
for {
msgType, data, err := sub.conn.Read(sub.ctx)
if err != nil {
log.Error("ReadLoop error(%d=%s): %+v", sub.ID, sub.Username, err)
s.DeleteSubscriber(sub)
// Notify if this user was auth'd and not hidden
if sub.authenticated && sub.ChatStatus != "hidden" {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
})
s.SendWhoList()
}
return
}
if msgType != websocket.MessageText {
log.Error("Unexpected MessageType")
continue
}
// Read the user's posted message.
var msg messages.Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Error("Read(%d=%s) Message error: %s", sub.ID, sub.Username, err)
continue
}
if msg.Action != messages.ActionFile {
log.Debug("Read(%d=%s): %s", sub.ID, sub.Username, data)
}
// What action are they performing?
switch msg.Action {
case messages.ActionLogin:
s.OnLogin(sub, msg)
case messages.ActionMessage:
s.OnMessage(sub, msg)
case messages.ActionFile:
s.OnFile(sub, msg)
case messages.ActionMe:
s.OnMe(sub, msg)
case messages.ActionOpen:
s.OnOpen(sub, msg)
case messages.ActionBoot:
s.OnBoot(sub, msg)
case messages.ActionMute, messages.ActionUnmute:
s.OnMute(sub, msg, msg.Action == messages.ActionMute)
case messages.ActionBlocklist:
s.OnBlocklist(sub, msg)
case messages.ActionCandidate:
s.OnCandidate(sub, msg)
case messages.ActionSDP:
s.OnSDP(sub, msg)
case messages.ActionWatch:
s.OnWatch(sub, msg)
case messages.ActionUnwatch:
s.OnUnwatch(sub, msg)
case messages.ActionTakeback:
s.OnTakeback(sub, msg)
case messages.ActionReact:
s.OnReact(sub, msg)
case messages.ActionReport:
s.OnReport(sub, msg)
default:
sub.ChatServer("Unsupported message type.")
}
}
}()
}
// IsAdmin safely checks if the subscriber is an admin.
func (sub *Subscriber) IsAdmin() bool {
return sub.JWTClaims != nil && sub.JWTClaims.IsAdmin
}
// SendJSON sends a JSON message to the websocket client.
func (sub *Subscriber) SendJSON(v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
return sub.conn.Write(sub.ctx, websocket.MessageText, data)
}
// SendMe sends the current user state to the client.
func (sub *Subscriber) SendMe() {
sub.SendJSON(messages.Message{
Action: messages.ActionMe,
Username: sub.Username,
VideoStatus: sub.VideoStatus,
})
}
// ChatServer is a convenience function to deliver a ChatServer error to the client.
func (sub *Subscriber) ChatServer(message string, v ...interface{}) {
sub.SendJSON(messages.Message{
Action: messages.ActionError,
Username: "ChatServer",
Message: fmt.Sprintf(message, v...),
})
}
// WebSocket handles the /ws websocket connection endpoint.
func (s *Server) WebSocket() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -37,7 +178,19 @@ func (s *Server) WebSocket() http.HandlerFunc {
// ctx := c.CloseRead(r.Context())
ctx, cancel := context.WithCancel(r.Context())
sub := s.NewWebSocketSubscriber(ctx, c, cancel)
sub := &Subscriber{
conn: c,
ctx: ctx,
cancel: cancel,
messages: make(chan []byte, s.subscriberMessageBuffer),
closeSlow: func() {
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
},
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
messageIDs: make(map[int]struct{}),
ChatStatus: "online",
}
s.AddSubscriber(sub)
defer s.DeleteSubscriber(sub)
@ -47,7 +200,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
for {
select {
case msg := <-sub.messages:
err = writeTimeout(ctx, time.Second*time.Duration(config.Current.WebSocketSendTimeout), c, msg)
err = writeTimeout(ctx, time.Second*5, c, msg)
if err != nil {
return
}
@ -56,7 +209,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
var token string
if sub.JWTClaims != nil {
if jwt, err := sub.JWTClaims.ReSign(); err != nil {
log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err)
log.Error("ReSign JWT token for %s: %s", sub.Username, err)
} else {
token = jwt
}
@ -75,6 +228,217 @@ func (s *Server) WebSocket() http.HandlerFunc {
})
}
// Auto incrementing Subscriber ID, assigned in AddSubscriber.
var SubscriberID int
// AddSubscriber adds a WebSocket subscriber to the server.
func (s *Server) AddSubscriber(sub *Subscriber) {
// Assign a unique ID.
SubscriberID++
sub.ID = SubscriberID
log.Debug("AddSubscriber: ID #%d", sub.ID)
s.subscribersMu.Lock()
s.subscribers[sub] = struct{}{}
s.subscribersMu.Unlock()
}
// GetSubscriber by username.
func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
for _, sub := range s.IterSubscribers() {
if sub.Username == username {
return sub, nil
}
}
return nil, errors.New("not found")
}
// DeleteSubscriber removes a subscriber from the server.
func (s *Server) DeleteSubscriber(sub *Subscriber) {
log.Error("DeleteSubscriber: %s", sub.Username)
// Cancel its context to clean up the for-loop goroutine.
if sub.cancel != nil {
sub.cancel()
}
s.subscribersMu.Lock()
delete(s.subscribers, sub)
s.subscribersMu.Unlock()
}
// IterSubscribers loops over the subscriber list with a read lock.
func (s *Server) IterSubscribers() []*Subscriber {
var result = []*Subscriber{}
// Lock for reads.
s.subscribersMu.RLock()
for sub := range s.subscribers {
result = append(result, sub)
}
s.subscribersMu.RUnlock()
return result
}
// UniqueUsername ensures a username will be unique or renames it. If the name is already unique, the error result is nil.
func (s *Server) UniqueUsername(username string) (string, error) {
var (
subs = s.IterSubscribers()
usernames = map[string]interface{}{}
origUsername = username
counter = 2
)
for _, sub := range subs {
usernames[sub.Username] = nil
}
// Check until unique.
for {
if _, ok := usernames[username]; ok {
username = fmt.Sprintf("%s %d", origUsername, counter)
counter++
} else {
break
}
}
if username != origUsername {
return username, errors.New("username was not unique and a unique name has been returned")
}
return username, nil
}
// Broadcast a message to the chat room.
func (s *Server) Broadcast(msg messages.Message) {
if len(msg.Message) < 1024 {
log.Debug("Broadcast: %+v", msg)
}
// Get the list of users who are online NOW, so we don't hold the mutex lock too long.
// Example: sending a fat GIF to a large audience could hang up the server for a long
// time until every copy of the GIF has been sent.
var subs = s.IterSubscribers()
for _, sub := range subs {
if !sub.authenticated {
continue
}
// Don't deliver it if the receiver has muted us.
if sub.Mutes(msg.Username) {
log.Debug("Do not broadcast message to %s: they have muted or booted %s", sub.Username, msg.Username)
continue
}
sub.SendJSON(msg)
}
}
// SendTo sends a message to a given username.
func (s *Server) SendTo(username string, msg messages.Message) error {
log.Debug("SendTo(%s): %+v", username, msg)
username = strings.TrimPrefix(username, "@")
var found bool
var subs = s.IterSubscribers()
for _, sub := range subs {
if sub.Username == username {
found = true
sub.SendJSON(messages.Message{
Action: msg.Action,
Channel: msg.Channel,
Username: msg.Username,
Message: msg.Message,
MessageID: msg.MessageID,
})
}
}
if !found {
return fmt.Errorf("%s is not online", username)
}
return nil
}
// SendWhoList broadcasts the connected members to everybody in the room.
func (s *Server) SendWhoList() {
var (
subscribers = s.IterSubscribers()
usernames = []string{} // distinct and sorted usernames
userSub = map[string]*Subscriber{}
)
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
usernames = append(usernames, sub.Username)
userSub[sub.Username] = sub
}
sort.Strings(usernames)
// Build the WhoList for each subscriber.
// TODO: it's the only way to fake videoActive for booted user views.
for _, sub := range subscribers {
if !sub.authenticated {
continue
}
var users = []messages.WhoList{}
for _, un := range usernames {
user := userSub[un]
if user.ChatStatus == "hidden" {
continue
}
who := messages.WhoList{
Username: user.Username,
Status: user.ChatStatus,
Video: user.VideoStatus,
DND: user.DND,
LoginAt: user.loginAt.Unix(),
}
// If this person had booted us, force their camera to "off"
if (user.Boots(sub.Username) || user.Mutes(sub.Username)) && !sub.IsAdmin() {
who.Video = 0
}
if user.JWTClaims != nil {
who.Operator = user.JWTClaims.IsAdmin
who.Avatar = user.JWTClaims.Avatar
who.ProfileURL = user.JWTClaims.ProfileURL
who.Nickname = user.JWTClaims.Nick
who.Emoji = user.JWTClaims.Emoji
who.Gender = user.JWTClaims.Gender
}
users = append(users, who)
}
sub.SendJSON(messages.Message{
Action: messages.ActionWhoList,
WhoList: users,
})
}
}
// Boots checks whether the subscriber has blocked username from their camera.
func (s *Subscriber) Boots(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.booted[username]
return ok
}
// Mutes checks whether the subscriber has muted username.
func (s *Subscriber) Mutes(username string) bool {
s.muteMu.RLock()
defer s.muteMu.RUnlock()
_, ok := s.muted[username]
return ok
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 159 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,75 +0,0 @@
/*
/* color palette from <https://github.com/vuejs/theme>
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*/

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@ -1,2 +0,0 @@
@import './base.css';

View File

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

View File

@ -1,71 +0,0 @@
<script>
export default {
props: {
visible: Boolean,
user: Object,
},
data() {
return {
dontShowAgain: false,
};
},
methods: {
accept() {
if (this.dontShowAgain) {
this.$emit('dont-show-again');
}
this.$emit('accept');
},
cancel() {
this.$emit('cancel');
},
}
}
</script>
<template>
<!-- NSFW Modal: before user views a NSFW camera the first time -->
<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">This camera may contain Explicit content</p>
</header>
<div class="card-content">
<p class="block">
This camera has been marked as "Explicit/<abbr title="Not Safe For Work">NSFW</abbr>" and may
contain displays of sexuality. If you do not want to see this, look for cameras with
a <span class="button is-small is-info is-outlined px-1"><i class="fa fa-video"></i></span>
blue icon rather than the <span class="button is-small is-danger is-outlined px-1"><i
class="fa fa-video"></i></span>
red ones.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="dontShowAgain">
Don't show this message again
</label>
</div>
<div class="field">
<div class="control has-text-centered">
<button type="button" class="button is-link mr-4"
@click="accept()">
Open webcam
</button>
<button type="button" class="button"
@click="cancel()">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true
}
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@ -1,65 +0,0 @@
<script>
export default {
props: {
visible: Boolean,
},
data() {
return {
username: '',
};
},
methods: {
signIn() {
this.$emit('signIn', this.username);
}
}
}
</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">Sign In</p>
</header>
<div class="card-content">
<form @submit.prevent="signIn()">
<div v-if="autoLogin" class="content">
<p>
Welcome to <span v-html="config.branding"></span>! Please just click on the "Enter Chat"
button below to log on. Your username has been pre-filled from the website that
sent you here.
</p>
<p>
This dialog box is added as an experiment to see whether it
helps iOS devices (iPads and iPhones) to log in to the chat more reliably, by
having you interact with the page before it connects to the server. Let us
know in chat if your iPhone or iPad is able to log in this way!
</p>
</div>
<div class="field">
<label class="label">Username</label>
<input class="input" v-model="username" placeholder="Username" autocomplete="off" autofocus
:disabled="autoLogin" required>
</div>
<div class="field">
<div class="control">
<button class="button is-link">Enter Chat</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,537 +0,0 @@
<script>
import EmojiPicker from 'vue3-emoji-picker';
import LocalStorage from '../lib/LocalStorage';
import 'vue3-emoji-picker/css';
export default {
props: {
message: Object, // chat Message object
action: String, // presence, notification, or (default) normal chat message
appearance: String, // message style appearance (cards, compact, etc.)
user: Object, // User object of the Message author
isOffline: Boolean, // user is not currently online
username: String, // current username logged in
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
isDnd: Boolean, // user is not accepting DMs
isMuted: Boolean, // user is muted by current user
reactions: Object, // emoji reactions on the message
reportEnabled: Boolean, // Report Message webhook is available
position: Number, // position of the message (0 to n), for the emoji menu to know which side to pop
isDm: Boolean, // is in a DM thread (hide DM buttons)
isOp: Boolean, // current user is Operator (always show takeback button)
noButtons: Boolean, // hide all message buttons (e.g. for Report Modal)
},
components: {
EmojiPicker,
},
data() {
return {
// 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: {
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;
},
hasReactions() {
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
},
// Compactify a message (remove paragraph breaks added by Markdown renderer)
compactMessage() {
return this.message.message.replace(/<\/p>\s*<p>/g, "<br><br>").replace(/<\/?p>/g, "");
},
emojiPickerTheme() {
let theme = LocalStorage.get('theme');
if (theme === 'light' || theme === 'dark') {
return theme;
}
return 'auto';
},
},
methods: {
openProfile() {
this.$emit('open-profile', this.message.username);
},
openDMs() {
this.$emit('send-dm', {
username: this.message.username,
});
},
muteUser() {
this.$emit('mute-user', this.message.username);
},
takeback() {
this.$emit('takeback', this.message);
},
removeMessage() {
this.$emit('remove', this.message);
},
reportMessage() {
this.$emit('report', this.message);
},
sendReact(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) {
// Prepend the base websiteUrl if the given URL is relative.
if (url.match(/^https?:/i)) {
return url;
}
return this.websiteUrl.replace(/\/+$/, "") + url;
},
// Current user has reacted to the message.
iReacted(emoji) {
if (!this.hasReactions) return false;
// test whether the current user has reacted
if (this.reactions[emoji] != undefined) {
for (let reactor of this.reactions[emoji]) {
if (reactor === this.username) {
return true;
}
}
}
return false;
},
// TODO: DRY
prettyDate(date) {
if (date == undefined) return '';
let hours = date.getHours(),
minutes = String(date.getMinutes()).padStart(2, '0'),
ampm = hours >= 12 ? "pm" : "am";
let hour = hours % 12 || 12;
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>
<template>
<!-- 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-left">
<a :href="profileURL" @click.prevent="openProfile()">
<figure class="image is-48x48">
<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="avatarURL" :src="avatarURL">
<img v-else src="/static/img/shy.png">
</figure>
</a>
</div>
<div class="media-content">
<div class="columns is-mobile pb-0">
<div class="column is-narrow pb-0">
<strong :class="{
'has-text-success is-dark': message.isChatServer,
'has-text-warning is-dark': message.isAdmin,
'has-text-danger': message.isChatClient
}">
<!-- User nickname/display name -->
{{ nickname }}
<!-- Offline now? -->
<span v-if="isOffline">(offline)</span>
</strong>
</div>
<div class="column has-text-right pb-0">
<small class="has-text-grey is-size-7" :title="message.at">{{ prettyDate(message.at) }}</small>
</div>
</div>
<!-- User @username below it which may link to a profile URL if JWT -->
<div class="columns is-mobile pt-0" v-if="(message.isChatClient || message.isChatServer)">
<div class="column is-narrow pt-0">
<small v-if="!(message.isChatClient || message.isChatServer)">
<a v-if="profileURL" :href="profileURL" target="_blank" @click.prevent="openProfile()"
class="has-text-grey">
@{{ message.username }}
</a>
<span v-else class="has-text-grey">@{{ message.username }}</span>
</small>
<small v-else class="has-text-grey">internal</small>
</div>
</div>
<div v-else class="columns is-mobile pt-0">
<div class="column is-narrow pt-0">
<small v-if="!(message.isChatClient || message.isChatServer)">
<a :href="profileURL || '#'" target="_blank" @click.prevent="openProfile()"
class="has-text-grey">
@{{ message.username }}
</a>
</small>
<small v-else class="has-text-grey">internal</small>
</div>
<div class="column is-narrow pl-1 pt-0" v-if="!noButtons">
<!-- DMs button -->
<button type="button" v-if="!(message.username === username || isDm)"
class="button is-small px-2" @click="openDMs()"
:title="isDnd ? 'This person is not accepting new DMs' : 'Open a Direct Message (DM) thread'"
:disabled="isDnd">
<i class="fa fa-comment"></i>
</button>
<!-- Mute button -->
<button type="button" v-if="!(message.username === username)"
class="button is-small px-2 ml-1" @click="muteUser()" title="Mute user">
<i class="fa fa-comment-slash" :class="{
'has-text-success': isMuted,
'has-text-danger': !isMuted
}"></i>
</button>
<!-- Owner or admin: take back the message -->
<button type="button" v-if="message.username === username || isOp"
class="button is-small px-2 ml-1"
title="Take back this message (delete it for everybody)" @click="takeback()"
:data-msgid="message.msgID">
<i class="fa fa-rotate-left has-text-danger"></i>
</button>
<!-- Everyone else: can hide it locally -->
<button type="button" v-if="message.username !== username"
class="button is-small px-2 ml-1"
title="Hide this message (delete it only for your view)" @click="removeMessage()">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Report & Emoji buttons -->
<div v-if="message.msgID && !noButtons" class="emoji-button columns is-mobile is-gapless mb-0">
<!-- Report message button -->
<div class="column" v-if="reportEnabled && message.username !== username">
<button class="button is-small is-outlined mr-1 py-2" :class="{
'is-danger': !message.reported,
'has-text-grey': message.reported
}" title="Report this message" @click="reportMessage()">
<i class="fa fa-flag"></i>
<i class="fa fa-check ml-1" v-if="message.reported"></i>
</button>
</div>
<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()">
<span>
<i class="fa fa-heart has-text-grey"></i>
<i class="fa fa-plus has-text-grey pl-1"></i>
</span>
</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="emojiPickerTheme" @select="onSelectEmoji"></EmojiPicker>
</div>
</div>
</div>
</div>
<!-- Message box -->
<div class="content pl-5 pb-3 pt-1 mb-5">
<em v-if="message.action === 'presence'">{{ message.message }}</em>
<div v-else v-html="message.message"></div>
<!-- Reactions so far? -->
<div v-if="hasReactions" class="mt-1">
<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>
</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>
</template>
<style scoped></style>

View File

@ -1,344 +0,0 @@
<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: [],
// 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() {
let hours = window.prompt(
"Ban this user for how many hours? (Default 24)",
"24",
);
if (!/^\d+$/.test(hours)) return;
this.$emit('send-command', `/ban ${this.user.username} ${hours}`);
},
urlFor(url) {
// Prepend the base websiteUrl if the given URL is relative.
if (url.match(/^https?:/i)) {
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>
</template>
<style scoped>
</style>

View File

@ -1,99 +0,0 @@
<script>
import MessageBox from './MessageBox.vue';
export default {
props: {
visible: Boolean,
busy: Boolean,
user: Object,
message: Object,
},
components: {
MessageBox,
},
data() {
return {
// Configuration
reportClassifications: [
"It's spam",
"It's abusive (racist, homophobic, etc.)",
"It's malicious (e.g. link to a malware website, phishing)",
"It's illegal (e.g. controlled substances, violence)",
"It's child abuse (CP, CSAM, pedophilia, etc.)",
"Other (please describe)",
],
// Our settings.
classification: "It's spam",
comment: "",
};
},
methods: {
reset() {
this.classification = this.reportClassifications[0];
this.comment = "";
},
accept() {
this.$emit('accept', {
classification: this.classification,
comment: this.comment,
});
this.reset();
},
cancel() {
this.$emit('cancel');
this.reset();
},
}
}
</script>
<template>
<!-- Report Modal -->
<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-warning">
<p class="card-header-title has-text-dark">Report a message</p>
</header>
<div class="card-content">
<!-- Message preview we are reporting on -->
<MessageBox
:message="message"
:user="user"
:no-buttons="true"
></MessageBox>
<div class="field mb-1">
<label class="label" for="classification">Report classification:</label>
<div class="select is-fullwidth">
<select id="classification" v-model="classification" :disabled="busy">
<option v-for="i in reportClassifications" v-bind:key="i" :value="i">{{ i }}</option>
</select>
</div>
</div>
<div class="field">
<label class="label" for="reportComment">Comment:</label>
<textarea class="textarea" v-model="comment" :disabled="busy" cols="80"
rows="2" placeholder="Optional: describe the issue"></textarea>
</div>
<div class="field">
<div class="control has-text-centered">
<button type="button" class="button is-link mr-4" :disabled="busy"
@click="accept()">Submit report</button>
<button type="button" class="button" @click="cancel()">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,86 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@ -1,250 +0,0 @@
<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>

View File

@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@ -1,237 +0,0 @@
<script>
import VideoFlag from '../lib/VideoFlag';
export default {
props: {
user: Object, // User object of the Message author
username: String, // current username logged in
websiteUrl: String, // Base URL to website (for profile/avatar URLs)
isDnd: Boolean, // user is not accepting DMs
isMuted: Boolean, // user is muted by current user
isBlocked: Boolean, // user is blocked on your main website (can't be unmuted)
isBooted: Boolean, // user is booted by current user
vipConfig: Object, // VIP config settings for BareRTC
isOp: Boolean, // current user is operator (can always DM)
isVideoNotAllowed: Boolean, // whether opening this camera is not allowed
videoIconClass: String, // CSS class for the open video icon
isWatchingTab: Boolean, // is the "Watching" tab (replace video button w/ boot)
statusMessage: Object, // StatusMessage controller
},
data() {
return {
VideoFlag: VideoFlag,
};
},
computed: {
profileURL() {
if (this.user.profileURL) {
return this.urlFor(this.user.profileURL);
}
return null;
},
profileButtonClass() {
let result = "";
// VIP background.
if (this.user.vip) {
result = "has-background-vip ";
}
let gender = (this.user.gender || "").toLowerCase();
if (gender.indexOf("m") === 0) {
return result + "has-text-gender-male";
} else if (gender.indexOf("f") === 0) {
return result + "has-text-gender-female";
} else if (gender.length > 0) {
return result + "has-text-gender-other";
}
return "";
},
videoButtonClass() {
let result = "";
// VIP background if their cam is set to VIPs only
if ((this.user.video & VideoFlag.Active) && (this.user.video & VideoFlag.VipOnly)) {
result = "has-background-vip ";
}
// Colors and/or cursors.
if ((this.user.video & VideoFlag.Active) && (this.user.video & VideoFlag.NSFW)) {
result += "is-danger is-outlined";
} else if ((this.user.video & VideoFlag.Active) && !(this.user.video & VideoFlag.NSFW)) {
result += "is-link is-outlined";
} else if (this.isVideoNotAllowed) {
result += "cursor-notallowed";
}
return result;
},
videoButtonTitle() {
// Mouse-over title text for the video button.
let parts = ["Open video stream"];
if (this.user.video & VideoFlag.MutualRequired) {
parts.push("mutual video sharing required");
}
if (this.user.video & VideoFlag.MutualOpen) {
parts.push("will auto-open your video");
}
if (this.user.video & VideoFlag.VipOnly) {
parts.push(`${this.vipConfig.Name} only`);
}
if (this.user.video & VideoFlag.NonExplicit) {
parts.push("prefers non-explicit video");
}
return parts.join("; ");
},
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;
},
hasReactions() {
return this.reactions != undefined && Object.keys(this.reactions).length > 0;
},
// Status icons
hasStatusIcon() {
return this.user.status !== 'online' && this.statusMessage != undefined;
},
statusIconClass() {
let status = this.statusMessage.getStatus(this.user.status);
return status.icon;
},
statusLabel() {
let status = this.statusMessage.getStatus(this.user.status);
return `${status.emoji} ${status.label}`;
},
},
methods: {
openProfile() {
this.$emit('open-profile', this.user.username);
},
// Directly open the profile page.
openProfilePage() {
if (this.profileURL) {
window.open(this.profileURL);
} else {
this.openProfile();
}
},
openDMs() {
this.$emit('send-dm', {
username: this.user.username,
});
},
openVideo() {
this.$emit('open-video', this.user);
},
muteUser() {
this.$emit('mute-user', this.user.username);
},
// Boot user off your cam (for isWatchingTab)
bootUser() {
this.$emit('boot-user', this.user.username);
},
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>
<div class="columns is-mobile">
<!-- Avatar URL if available -->
<div class="column is-narrow pr-0" style="position: relative">
<a :href="profileURL"
@click.prevent="openProfile()"
class="p-0">
<img v-if="avatarURL" :src="avatarURL" width="24" height="24" alt="">
<img v-else src="/static/img/shy.png" width="24" height="24">
<!-- Away symbol -->
<div v-if="hasStatusIcon" class="status-away-icon">
<i :class="statusIconClass" class="has-text-light"
:title="'Status: ' + statusLabel"></i>
</div>
</a>
</div>
<div class="column pr-0 is-clipped" :class="{ 'pl-1': avatarURL }">
<strong class="truncate-text-line is-size-7 cursor-pointer"
@click="openProfile()">
{{ user.username }}
</strong>
<sup class="fa fa-peace has-text-warning is-size-7 ml-1" v-if="user.op"
title="Operator"></sup>
<sup class="is-size-7 ml-1" :class="vipConfig.Icon" v-else-if="user.vip"
:title="vipConfig.Name"></sup>
</div>
<div class="column is-narrow pl-0">
<!-- Emoji icon (Who's Online tab only) -->
<span v-if="user.emoji && !isWatchingTab" class="pr-1 cursor-default" :title="user.emoji">
{{ user.emoji.split(" ")[0] }}
</span>
<!-- Profile button -->
<button type="button" class="button is-small px-2 py-1"
:class="profileButtonClass" @click="openProfilePage()"
:title="'Open profile page' + (user.gender ? ` (gender: ${user.gender})` : '') + (user.vip ? ` (${vipConfig.Name})` : '')">
<i class="fa fa-user"></i>
</button>
<!-- Unmute User button (if muted) -->
<button type="button" v-if="isMuted && !isBlocked" class="button is-small px-2 py-1"
@click="muteUser()" title="This user is muted. Click to unmute them.">
<i class="fa fa-comment-slash has-text-danger"></i>
</button>
<!-- DM button (if not muted) -->
<button type="button" v-else class="button is-small px-2 py-1" @click="openDMs(u)"
:disabled="user.username === username || (user.dnd && !isOp) || (isBlocked && !isOp)"
:title="(user.dnd || isBlocked) ? 'This person is not accepting new DMs' : 'Send a Direct Message'">
<i class="fa" :class="{ 'fa-comment': !(user.dnd || isBlocked), 'fa-comment-slash': user.dnd || isBlocked }"></i>
</button>
<!-- Video button -->
<button type="button" class="button is-small px-2 py-1"
:disabled="!(user.video & VideoFlag.Active)"
:class="videoButtonClass"
:title="videoButtonTitle"
@click="openVideo()">
<i class="fa" :class="videoIconClass"></i>
</button>
<!-- Boot from Video button (Watching tab only) -->
<button v-if="isWatchingTab" type="button" class="button is-small px-2 py-1"
@click="bootUser()"
title="Kick this person off your cam">
<i class="fa fa-user-xmark has-text-danger"></i>
</button>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@ -1,356 +0,0 @@
// 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;

View File

@ -1,85 +0,0 @@
// All the distinct localStorage keys used.
const keys = {
'fontSizeClass': String, // Text magnification
'videoScale': String, // Video magnification (CSS classnames)
'messageStyle': String, // Message display style (cards, compact, etc.)
'imageDisplaySetting': String, // Show/hide/expand image preference
'scrollback': Number, // Scrollback buffer (int)
'preferredDeviceNames': Object, // Webcam/mic device names (object, keys video,audio)
'whoSort': String, // user's preferred sort order for the Who List
'theme': String, // light, dark, or auto theme
// Webcam settings (booleans)
'videoMutual': Boolean,
'videoMutualOpen': Boolean,
'videoAutoMute': Boolean,
'videoVipOnly': Boolean,
'videoExplicit': Boolean, // whether the user turns explicit on by default
'videoNonExplicit': Boolean, // user prefers not to see explicit
'rememberExpresslyClosed': Boolean,
'autoMuteWebcams': Boolean, // automatically mute other peoples' webcam audio feeds
'videoAutoShare': Boolean, // automatically share your webcam on page load
// Booleans
'usePolling': Boolean, // use the polling API instead of WebSocket
'joinMessages': Boolean,
'exitMessages': Boolean,
'watchNotif': Boolean,
'muteSounds': Boolean,
'closeDMs': Boolean, // close unsolicited DMs
'debug': Boolean, // Debug views enabled (admin only)
// Don't Show Again on NSFW modals.
'skip-nsfw-modal': Boolean,
}
// UserSettings centralizes browser settings for the chat room.
class UserSettings {
constructor() {
// Recall stored settings. Only set the keys that were
// found in localStorage on page load.
for (let key of Object.keys(keys)) {
if (localStorage[key] != undefined) {
try {
this[key] = JSON.parse(localStorage[key]);
} catch(e) {
console.error(`LocalStorage: parsing key ${key}: ${e}`);
delete(this[key]);
}
}
}
console.log("LocalStorage: Loaded settings", this);
}
// Return all of the current settings where the user had actually
// left a preference on them (was in localStorage).
getSettings() {
let result = {};
for (let key of Object.keys(keys)) {
if (this[key] != undefined) {
result[key] = this[key];
}
}
return result;
}
// Get a value from localStorage, if set.
get(key) {
return this[key];
}
// Generic setter.
set(key, value) {
if (keys[key] == undefined) {
throw `${key}: not a supported localStorage setting`;
}
localStorage[key] = JSON.stringify(value);
this[key] = value;
}
}
// LocalStorage is a global singleton to access and update user settings.
const LocalStorage = new UserSettings();
export default LocalStorage;

View File

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

View File

@ -1,12 +0,0 @@
// Video flag constants (sync with values in messages.go)
const VideoFlag = {
Active: 1 << 0,
NSFW: 1 << 1,
Muted: 1 << 2,
NonExplicit: 1 << 3,
MutualRequired: 1 << 4,
MutualOpen: 1 << 5,
VipOnly: 1 << 6,
};
export default VideoFlag;

View File

@ -1,26 +0,0 @@
// Try and detect whether the user is on an Apple Safari browser, which has
// special nuances in their WebRTC video sharing support. This is intended to
// detect: iPads, iPhones, and Safari on macOS.
function isAppleWebkit() {
const ua = navigator.userAgent;
// By User-Agent: Apple mobiles.
if (/iPad|iPhone|iPod/.test(ua)) {
return true;
}
// Safari browser: claims to be Safari but not Chrome
// (Google Chrome claims to be both)
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
return true;
}
// By (deprecated) navigator.platform.
if (navigator.platform === 'iPad' || navigator.platform === 'iPhone' || navigator.platform === 'iPod') {
return true;
}
return false;
}
export { isAppleWebkit };

View File

@ -1,48 +0,0 @@
// Available sound effects.
const SoundEffects = [
{
name: "Quiet",
filename: null
},
{
name: "Trill",
filename: "beep-6-96243.mp3"
},
{
name: "Beep",
filename: "beep-sound-8333.mp3"
},
{
name: "Bird",
filename: "bird-3-f-89236.mp3"
},
{
name: "Ping",
filename: "ping-82822.mp3"
},
{
name: "Sonar",
filename: "sonar-ping-95840.mp3"
},
{
name: "Up Chime",
filename: "notification-6175-up.mp3"
},
{
name: "Down Chime",
filename: "notification-6175-down.mp3"
},
];
// Defaults
var DefaultSounds = {
Chat: "Quiet",
DM: "Trill",
Enter: "Quiet",
Leave: "Quiet",
Watch: "Quiet",
Unwatch: "Quiet",
Mentioned: "Ping",
};
export { SoundEffects, DefaultSounds };

View File

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

View File

@ -1,8 +0,0 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -1,16 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

File diff suppressed because it is too large Load Diff

11851
web/static/css/bulma.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

1
web/static/css/bulma.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -6,18 +6,6 @@ body {
min-height: 100vh;
}
/* No dragging images - it plays wrongly with the drag/drop image sharing feature
and users can accidentally share in chat by dragging an on-page image */
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
user-select: none;
-moz-user-select: none;
-webkit-user-drag: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.float-right {
float: right;
}
@ -29,17 +17,10 @@ img {
/* DM title and bg color */
.has-background-private {
background-color: #b748c7 !important;
background-color: #b748c7;
}
.has-background-dm {
background-color: #fff9ff !important;
}
.has-background-at-mention {
background-color: rgb(250, 250, 192);
}
.has-text-private {
color: #CC00CC !important;
background-color: #ffefff;
}
/* Truncate long text, e.g. usernames in the who list */
@ -91,6 +72,13 @@ img {
grid-template-rows: auto 1fr auto;
}
@media (prefers-color-scheme: dark) {
.chat-container {
background: rgb(39, 39, 39);
background: linear-gradient(0deg, rgb(39, 39, 39) 0%, rgb(66, 66, 66) 100%);
}
}
/* Header row */
.chat-container > .chat-header {
grid-column: 1 / 4;
@ -119,14 +107,6 @@ img {
bottom: 4px;
}
/* User status indicator in the lower left corner of DMs */
.user-status-dm-field {
position: absolute;
z-index: 38; /* below auto-scroll checkbox */
left: 12px;
bottom: 4px;
}
/* Footer row: message entry box */
.chat-container > .chat-footer {
grid-column: 1 / 4;
@ -142,7 +122,7 @@ img {
/* Responsive CSS styles */
@media screen and (min-width: 1024px) {
.mobile-only {
display: none !important;
display: none;
}
}
@media screen and (max-width: 1024px) {
@ -241,25 +221,20 @@ img {
width: 168px;
height: 112px;
background-color: black;
border: 1px solid black;
margin: 3px;
overflow: hidden;
resize: both;
}
/* A speaking webcam */
.feed.is-speaking {
border: 1px solid #09F !important;
}
/* A popped-out video feed window */
div.feed.popped-out {
position: absolute;
border: 1px solid #FFF;
cursor: move;
top: 0;
left: 0;
z-index: 1000;
resize: none;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
.video-feeds.x1 > .feed {
@ -295,14 +270,12 @@ div.feed.popped-out {
position: absolute;
left: 4px;
bottom: 4px;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
.feed > .close {
position: absolute;
right: 4px;
top: 0;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
.feed > .caption {
@ -313,7 +286,6 @@ div.feed.popped-out {
left: 4px;
font-size: small;
padding: 2px 4px;
z-index: 1; /* work around Safari video being on top when return from fullscreen */
}
/* YouTube embeds */
@ -364,8 +336,3 @@ div.feed.popped-out {
.has-text-gender-other {
color: #cc00cc !important;
}
/* VIP colors for profile icon */
.has-background-vip {
background-image: linear-gradient(141deg, #d1e1ff 0, #ffddff 100%)
}

Some files were not shown because too many files have changed in this diff Show More