Compare commits

...

109 Commits

Author SHA1 Message Date
Noah b011e36ddf Adjust dark webcam threshold 2024-05-27 20:08:32 +00:00
Noah a536862a91 Dark video detection
* Add local detection for users who are broadcasting dark (e.g. mostly
  or completely black) video feeds from their local device.
* Every 5 seconds while the webcam is active, the average RGB color is
  sampled. If the average color value remains below 60 (out of 255) for
  two consecutive samples, the camera is stopped automatically.
2024-05-18 19:09:11 -07:00
Noah fd36d09727 Update README for moderator commands 2024-05-17 20:47:19 -07:00
Noah 9c77bdb62e New Op commands and fixes with blocking admin users
Add moderation rules:

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

Chat UI improvements around users blocking admin accounts:

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

New operator commands:

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

Miscellaneous fixes:

* The admin commands now tolerate an @ prefix in front of usernames.
* The /nsfw command won't fire unless the user's camera is actually active and
  not marked as explicit.
2024-05-17 17:15:48 -07:00
Noah b74edd1512 A background graphic for videos to detect broken connections 2024-05-15 19:42:51 -07:00
Noah b82e8f651b Don't count unread messages for DebugChannel 2024-05-13 22:28:38 -07:00
Noah 747f4fd5d4 Let channels configure whether to permit photos 2024-05-13 18:51:54 -07:00
Noah 745c282650 Bugfix in isOp function 2024-05-10 22:24:07 -07:00
Noah e70b439cdd Admin command buttons in the profile modal 2024-05-10 21:32:32 -07:00
Noah b5bbbde784 Update the About page to remove Safari/iPad notes 2024-05-09 21:24:03 -07:00
Noah f36c83dbcc Add receive-only transceivers to remove Apple compat mode
* WebRTC functionality is now 100% working as intended for Safari and
  iPad browsers!
* The legacy WebRTC API had properties like offerToReceiveVideo
  available on createOffer(), to set up a receive-only channel, but the
  modern WebRTC API had removed these and Safari only supports the
  modern API.
* The modern solution for the same feature is to add a recvonly
  transceiver to the connection in place of offering a local video/audio
  stream to share.
2024-05-08 12:44:15 -07:00
Noah f094213a34 Improve WebRTC connection for Safari browsers 2024-05-07 20:54:13 -07:00
Noah b8b53c65f3 Color improvement in dark theme 2024-04-13 14:55:03 -07:00
Noah 3424be2f4d Clear DMs history button 2024-04-11 23:28:35 -07:00
Noah d510ac791f Click names in video feeds to open their profile card 2024-04-09 17:57:37 -07:00
Noah 9932cb5a2c Fix DM buttons on light theme 2024-04-07 11:56:29 -07:00
Noah 93c4e12680 Theme fix for emoji picker popup 2024-04-06 16:59:23 -07:00
Noah a0786b2fa9 At-mention background on dark theme 2024-04-06 16:03:30 -07:00
Noah bef135fbd6 Background color fix on DMs 2024-04-06 16:01:32 -07:00
Noah ed82920de9 Dark mode color tweaks 2024-04-06 15:54:37 -07:00
Noah 92a376786d Update to Bulma CSS 1.0
* Update the CSS and add a theme selector to the Chat Settings to force a
  light or dark mode theme (default is automatic).
2024-04-06 14:35:52 -07:00
Noah f0dd1d952c Direct Message History
* Add support for storing DM history between users in a SQLite3 database.
* Opt-in by editing your settings.toml to set DirectMessageHistory/Enabled=true
* Retention days (default 90) will flush old DMs on app startup.
* On the front-end, DM history is checked when a DM thread is opened.
2024-03-29 17:48:01 -07:00
Noah 96d61614f4 Add DisconnectNow API endpoint 2024-03-15 15:59:42 -07:00
Noah c7ef254361 Disable right-click in image modal 2024-02-29 19:30:51 -08:00
Noah 2a0f8b0cdf Disable right-click on user shared images 2024-02-29 09:29:06 -08:00
Noah 5e68c99514 Update page title with unread DM count 2024-02-10 16:35:32 -08:00
Noah 206784e0b9 Make the X button on cameras more touchable 2024-01-27 13:15:58 -08:00
Noah e74f7297e6 Include DM context in reported messages 2024-01-20 15:17:02 -08:00
Noah 8e87c377e8 Apple compatibility mode for WebRTC
* Try a new strategy to get Apple (iPad/iPhone) webcams to connect.
* "Apple compatibility mode" setting: on by default if iPad/iPhone is
  detected or can be opted into in the chat settings Misc tab.
* In Apple compat mode: when you open someone else's webcam, you always
  attach your local video on the WebRTC offer. This would normally make
  your video auto-open on the remote side, but the previous commit
  updates the chat page to ignore offered video if you did not opt-in to
  auto-open your viewer's camera.
* This should satisfy the two-way video call limitation in Safari: the
  iPad always shares its video and gets video from the person they are
  watching.
* If the person they are watching did not auto-open your video: they
  ignore the attached video on your offer and don't display it.
2024-01-11 20:33:57 -08:00
Noah bf59a7b6c9 Auto-mute other users' webcam sound channels 2024-01-11 19:45:32 -08:00
Noah 27380ec558 Status Message overhaul 2023-12-30 14:50:52 -08:00
Noah ebf5b3f47e Fix image click handler and emoji popup 2023-12-22 21:59:23 -08:00
Noah 21797788a2 Disable cursor events on images (interferes with drag/drop) 2023-12-21 20:23:57 -08:00
Noah dffd432221 Share images by drag/drop onto page 2023-12-21 17:47:17 -08:00
Noah 449929b8d1 Fix mouse cursor over VIP webcam checkbox on broadcast modal 2023-12-21 14:13:52 -08:00
Noah aa162a5b7a Resync Mutes and Boots on reconnect to server 2023-12-21 14:11:37 -08:00
Noah 139f9ece70 Bugfix in WebRTC video stream handler 2023-12-18 17:59:35 -08:00
Noah f75ad32728 Ban command update, join/leave messages
* The /ban command doesn't require the target user to be online at the
  time of the ban.
* Update the presence messages so they will generally only go to the
  primary (first) public channel, and also to another public channel if
  the user is currently looking at one of the others.
2023-12-16 15:10:48 -08:00
Noah 264b8f2a46 Cleanup debug log 2023-12-10 18:44:18 -08:00
Noah 0e0aac991d Polling API for the chat room 2023-12-10 18:43:18 -08:00
Noah d57d41ea3a Abstract WebSocket client into library 2023-12-10 16:09:00 -08:00
Noah 00c6015148 Server-side IsVideoNotAllowed validation checks 2023-12-10 15:31:47 -08:00
Noah c3808bbe89 Fix a null pointer exception if a DM partner goes offline 2023-12-03 21:46:14 -08:00
Noah 538347ebc7 Update DM disclaimer at top of page 2023-11-26 20:49:54 -08:00
Noah 2cf4e5cc27 Logging bugfix 2023-11-25 18:36:38 -08:00
Noah 30fbba2f55 Bugfix on mutualOpen localStorage setting 2023-11-18 15:44:13 -08:00
Noah deb3bb616b Fix webcam freezing issues with mutualOpen video connections 2023-11-18 15:38:02 -08:00
Noah 356d2ddfa8 Null pointer exception fixes 2023-11-11 15:16:17 -08:00
Noah f0a6585af1 Bump config version 2023-11-11 15:06:17 -08:00
Noah 1e702b0e1e Add channel logging feature 2023-11-11 14:59:49 -08:00
Noah 8004edb7b8 CSS tweak for video zoom buttons 2023-10-29 14:27:09 -07:00
Noah db819af8af Easy video zoom in/out buttons 2023-10-29 14:15:15 -07:00
Noah 2ac3e8e128 Fix graceful disconnect commands from the server 2023-10-23 19:05:02 -07:00
Noah 95c6c7859f Fix timestamp display 2023-10-22 16:07:58 -07:00
Noah fea1d1c7b9 Prefers non-explicit and option to expressly remember closed videos 2023-10-14 12:24:30 -07:00
Noah bdb5e6359b Various fixes and improvements
* Re-set user's status if they disconnect and reconnect
* Remove "(offline)" text next to ChatServer/ChatClient messages
* Make names and pictures in presence messages clickable to open profile
  cards
2023-10-14 11:01:58 -07:00
Noah 30c5538ce6 Don't auto-open expressly closed videos + other fixes
* If a user expressly closes a webcam (by clicking the 'X' button),
  record this intent so that the webcam will not auto-open in case the
  "auto-open my viewer's camera" happens again. Only clear the expressly
  close intent when the user expressly clicks the video button on the
  Who List to open someone's camera back up.
* Fix some bugs around booting and muting from cameras:
  * If you boot someone off your camera, you can not open THEIR camera
    anymore (similar to muting them)
  * When opening a user who auto-opens your camera back: do not attach
    your local video if you are an Admin and you have previously
    muted/booted that user from your camera.
  * Draw the slash mark over videos that you can not re-open because you
    had booted that user off your camera.
2023-10-10 18:45:00 -07:00
Noah 802fab3862 Test fix for popped-out videos appearing over modals 2023-10-08 12:37:35 -07:00
Noah 7ecea89e03 Bugfix 2023-10-08 12:24:19 -07:00
Noah cb2975edca Emoji icon in profile cards 2023-10-08 12:23:11 -07:00
Noah ef79b2aa9b Bugfix with file sending 2023-10-08 11:55:56 -07:00
Noah f18fce63ce Tweaks to profile cards 2023-10-08 11:24:44 -07:00
Noah 7373882abf Profile Modals + Misc Features
* Add profile modal popups and Webhook support to get more detailed user
  info from your website.
* Add "unboot" command, available in the profile modal.
2023-10-07 13:22:41 -07:00
Noah 2810169ce9 Volume sliders, fullscreen video, misc tweaks 2023-10-05 18:59:49 -07:00
Noah 489f5b6aad Tweak padding and spacing 2023-09-30 15:59:11 -07:00
Noah b363bd3cab Fix content display 2023-09-30 15:52:26 -07:00
Noah dec0f63eca Fix Markdown display in compact mode 2023-09-30 15:47:19 -07:00
Noah 85a431c6b5 Lighten DM background color a tad 2023-09-30 15:44:17 -07:00
Noah 1d29c6da18 Tweak compact display option 2023-09-30 15:18:55 -07:00
Noah b5d0885c23 Compact-style message display options 2023-09-30 14:53:43 -07:00
Noah 15b291826e Make WebSocketSendTimeout configurable 2023-09-30 12:46:45 -07:00
Noah a1b0d2e965 Blocklist improvements + WebSocket timeout tweak 2023-09-30 12:32:09 -07:00
Noah 4b971fcf41 Server side filtering 2023-09-29 19:10:34 -07:00
Noah 6fda8dca63 Fix go.mod replacement 2023-09-27 01:22:09 +00:00
Noah 4b8ae56abd Add JavaScript macro support to the chatbot 2023-09-26 18:20:40 -07:00
Noah 810115d20c Update documentation 2023-09-25 17:29:44 -07:00
Noah 267cda7989 Tweak chatbot logging for deadlock detection 2023-09-19 17:47:57 -07:00
Noah e600250908 Chatbot Object Macros: NSFW and Send Message 2023-09-16 16:03:54 -07:00
Noah d651f96678 Bugfix with freeze video interval 2023-09-13 22:31:24 -07:00
Noah b7dc4c8df6 Properly cancel frozen video intervals 2023-09-13 21:51:15 -07:00
Noah d01bae9966 Bugfix on booted cams 2023-09-12 20:03:10 -07:00
Noah 1acc626819 Update chatbot deadlock watcher 2023-09-10 12:02:34 -07:00
Noah 239e80a7cc Fix z-index on message entry emoji menu 2023-09-09 12:12:29 -07:00
Noah 56ae9dbe9c Custom emoji group for reaction picker 2023-09-09 11:55:43 -07:00
Noah ff6e36a142 Emoji picker for text entry box too 2023-09-09 11:38:36 -07:00
Noah 7999ffc6d9 Lazy load emoji picker component to save on memory 2023-09-09 04:47:49 +00:00
Noah 676c183528 Fix find/replace on at mentions 2023-09-08 20:53:32 -07:00
Noah 25bbe84a61 Bugfixes on at-mentions and use images on emoji keyboard 2023-09-08 20:43:17 -07:00
Noah f091747380 At-mention popups for chat 2023-09-08 20:27:00 -07:00
Noah 3b06676343 Better emoji keyboard 2023-09-08 19:37:39 -07:00
Noah cbfbcd768f Chat Setting Menu + Various Tweaks
* In place of the Help and Settings buttons, add a hamburger menu
  dropdown and place the links under there.
* Also in the dropdown is Close All Cameras and Mute All Cameras (if you
  have any cams open; the links are hidden if not)
* Also in the dropdown add a Logout button that just links to a new
  /logout route in order to unload the page and align with some users'
  expectations (not knowing closing out of the chat page was enough to
  log out of the room before)
* Bring back "(offline)" indicators when a user is no longer in the
  room.
2023-09-08 18:46:36 -07:00
Noah 52dd53240e Another minor undeclared variable fix 2023-09-07 21:20:26 -07:00
Noah 5d0515cba6 Minor undeclared variable fix 2023-09-07 21:17:33 -07:00
Noah dbfd45794a More safely parse JSON from localStorage 2023-09-07 21:03:15 -07:00
Noah a2cb32cce2 Fix emoji upvotes and add interactjs 2023-09-07 20:36:47 -07:00
Noah d7226e7f1d Small tweaks 2023-09-07 20:26:06 -07:00
Noah 8853f9882b Store sort order and explicit setting to localStorage 2023-09-07 20:05:52 -07:00
Noah d8c92800f3 Fixes for admins, VIP and blocking + Frontend tweaks
Changes to the chat server:
* Blocking will not apply to admin user accounts (operators)
* Users who block an admin will instead mute them, but the admin can
  still DM them if required
* Messages to VIP channels are broadcast to admins even if they are not
  VIPs, e.g. so moderator chatbots can see
* On the Who List: VIP-only cameras to highlight with the VIP background
  color on those buttons
2023-09-07 19:43:03 -07:00
Noah d8cb1c7c11 Refactor more Vue components
Spin out components for:
* MessageBox: draw a chat message in the chat history panel as well as reused
  in the Report Modal.
* WhoListRow: provides a consistent UX for the Who List and Watching tab. On
  the Watching tab, the video button is replaced with the boot from video.

Other changes:
* Move VideoFlag into its own separate ES module.
* Emoji available reactions are moved into MessageBox.
* On WhoListRow: usernames are clickable to also open their profile page.
* On WhoListRow: the Watching tab is now sortable and follows the user's
  sort selection like the Online tab does.
2023-09-07 19:24:26 -07:00
Noah 8906e89a51 Refactor some modals and features into components
Move some chat modals into external components:
* LoginModal
* ExplicitOpenModal
* ReportModal
* The Photo Modal was hoisted into the main index.html page, because it is not
  a Vue component and relied on global onclick handlers and the DOM.

Spin off some external JS modules:
* isAppleWebkit moved to lib/browsers.js
* Local Storage management centralized and moved to lib/LocalStorage.js
2023-09-06 23:03:12 -07:00
Noah e728644a77 Port front-end over to Vue CLI (create-vue)
This commit makes an initial port of the front-end over to a proper Vue
CLI application. It seems to work from surface level testing.

Changes made:

* Rename web/static to public/static to place it into the Vue build path
  * Notes: web/static/js/BareRTC.js and web/templates/chat.html are now
    deprecated
* Rename web/static/js/sounds.js into src/lib/sounds.js making it a
  proper JavaScript module with exports.
* Fill out initial src/App.vue by copying and updating
  web/templates/chat.html and web/static/js/BareRTC.js into this module.
2023-09-06 17:15:02 -07:00
Noah a7342988ba Null exception fix 2023-09-05 14:09:07 -07:00
Noah 7ffa6b4dbd More thorough blocking behavior 2023-09-05 13:57:11 -07:00
Noah 940f14e2d6 VIP-only chat channels 2023-09-03 12:48:21 -07:00
Noah 0174bf7bd8 Some bugfixes with mutual require video 2023-09-03 12:36:15 -07:00
Noah 6e2aa517f5 Support for VIP users via JWT Auth 2023-09-03 12:08:23 -07:00
Noah f65f653430 Quick mute all sounds checkbox 2023-09-01 17:11:17 -07:00
Noah 3404373a4b Disable autocomplete on the message box 2023-08-31 17:34:40 -07:00
Noah 0607fac724 Merge pull request 'WebRTC iPad Testing' (#36) from ipad-testing into master
Reviewed-on: #36
2023-09-01 00:24:07 +00:00
2208 changed files with 35795 additions and 21051 deletions

11
.eslintrc.cjs Normal file
View File

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

28
.gitignore vendored
View File

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

7
.vscode/extensions.json vendored Normal file
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,21 +3,28 @@
On first run it will create the default settings.toml file for you which you may then customize to your liking:
```toml
Version = 2
Version = 7
Title = "BareRTC"
Branding = "BareRTC"
WebsiteURL = "https://www.example.com"
CORSHosts = ["https://www.example.com"]
WebsiteURL = "http://www.example.com"
CORSHosts = ["http://www.example.com"]
AdminAPIKey = "e635e463-7987-4788-94f3-671a5c2a589f"
PermitNSFW = true
UseXForwardedFor = true
WebSocketReadLimit = 41943040
UseXForwardedFor = false
WebSocketReadLimit = 40971520
MaxImageWidth = 1280
PreviewImageWidth = 360
[JWT]
Enabled = false
Enabled = true
Strict = true
SecretKey = ""
SecretKey = "05c45344-1c52-430b-beb9-c3f64ff7ed12"
LandingPageURL = "https://www.example.com/enter-chat"
[TURN]
URLs = ["stun:stun.l.google.com:19302"]
Username = ""
Credential = ""
[[PublicChannels]]
ID = "lobby"
@ -29,28 +36,145 @@ 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
DisableCamera = false
[DirectMessageHistory]
Enabled = true
SQLiteDatabase = "database.sqlite"
RetentionDays = 90
DisclaimerMessage = "Reminder: please conduct yourself honorable in DMs."
[Logging]
Enabled = true
Directory = "./logs"
Channels = ["lobby"]
Usernames = []
```
A description of the config directives includes:
* Website settings:
* **Title** goes in the title bar of the chat page.
* **Branding** is the title shown in the corner of the page. HTML is permitted here! You may write an `<img>` tag to embed an image or use custom markup to color and prettify your logo.
* **WebsiteURL** is the base URL of your actual website which is used in a couple of places:
* 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.
## 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.
* **DisableCamera** (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.
## 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.

View File

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

27
go.mod
View File

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

66
go.sum
View File

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

72
index.html Normal file
View File

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

2087
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"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",
"interactjs": "^1.10.18",
"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,6 +2,7 @@ package barertc
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
@ -12,6 +13,7 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Statistics (/api/statistics) returns info about the users currently logged onto the chat,
@ -354,6 +356,653 @@ func (s *Server) BlockList() http.HandlerFunc {
})
}
// BlockNow (/api/block/now) allows your website to add to a current online chatter's
// blocked list immediately.
//
// For example: the BlockList endpoint does a bulk sync of the blocklist at the time
// a user joins the chat room, but if users are already on chat when the blocking begins,
// it doesn't take effect until one or the other re-joins the room. This API endpoint
// can apply the blocking immediately to the currently online users.
//
// It is a POST request with a json body containing the following schema:
//
// {
// "APIKey": "from settings.toml",
// "Usernames": [ "source", "target" ]
// }
//
// The pair of usernames will be the two users who block one another (in any order).
// If any of the users are currently connected to the chat, they will all mutually
// block one another immediately.
func (s *Server) BlockNow() http.HandlerFunc {
type request struct {
APIKey string
Usernames []string
}
type result struct {
OK bool
Error string `json:",omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only POST methods allowed",
})
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(result{
Error: "Only application/json content-types allowed",
})
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params request
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&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,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
@ -47,20 +48,34 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg messages.Message) bool {
case "/nsfw":
s.NSFWCommand(words, sub)
return true
case "/cut":
s.CutCommand(words, sub)
return true
case "/unmute-all":
s.UnmuteAllCommand(words, sub)
return true
case "/help":
sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" +
sub.ChatServer(RenderMarkdown("The most common moderator commands on chat are:\n\n" +
"* `/kick <username>` to kick from chat\n" +
"* `/ban <username> <duration>` to ban from chat (default duration is 24 (hours))\n" +
"* `/unban <username>` to list the ban on a user\n" +
"* `/bans` to list current banned users and their expiration date\n" +
"* `/nsfw <username>` to mark their camera NSFW\n" +
"* `/cut <username>` to make them turn off their camera\n" +
"* `/unmute-all` to lift all mutes on your side\n" +
"* `/help` to show this message\n" +
"* `/help-advanced` to show advanced admin commands\n\n" +
"Note: shell-style quoting is supported, if a username has a space in it, quote the whole username, e.g.: `/kick \"username 2\"`",
))
return true
case "/help-advanced":
sub.ChatServer(RenderMarkdown("The following are **dangerous** commands that you should not use unless you know what you're doing:\n\n" +
"* `/op <username>` to grant operator rights to a user\n" +
"* `/deop <username>` to remove operator rights from a user\n" +
"* `/shutdown` to gracefully shut down (reboot) the chat server\n" +
"* `/kickall` to kick EVERYBODY off and force them to log back in\n" +
"* `/reconfigure` to dynamically reload the chat server settings file\n" +
"* `/help` to show this message\n\n" +
"Note: shell-style quoting is supported, if a username has a space in it, quote the whole username, e.g.: `/kick \"username 2\"`",
"* `/help-advanced` to show this message",
))
return true
case "/shutdown":
@ -109,14 +124,23 @@ func (s *Server) NSFWCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.")
}
username := words[1]
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/nsfw: username not found: %s", username)
} else {
// Sanity check that the target user is presently on a blue camera.
if !(other.VideoStatus&messages.VideoFlagActive == messages.VideoFlagActive) {
sub.ChatServer("/nsfw: %s's camera was not currently enabled.", username)
return
} else if other.VideoStatus&messages.VideoFlagNSFW == messages.VideoFlagNSFW {
sub.ChatServer("/nsfw: %s's camera was already marked as explicit.", username)
return
}
// The message to deliver to the target.
var message = "Just a friendly reminder to mark your camera as 'Explicit' by using the button at the top " +
"of the page if you are going to be sexual on webcam. "
"of the page if you are going to be sexual on webcam.<br><br>"
// If the admin who marked it was previously booted
if other.Boots(sub.Username) {
@ -133,6 +157,41 @@ 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.ChatServer("Your mute on %d users has been lifted.", count)
}
// KickCommand handles the `/kick` operator command.
func (s *Server) KickCommand(words []string, sub *Subscriber) {
if len(words) == 1 {
@ -141,7 +200,7 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
))
return
}
username := words[1]
username := strings.TrimPrefix(words[1], "@")
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/kick: username not found: %s", username)
@ -152,14 +211,15 @@ func (s *Server) KickCommand(words []string, sub *Subscriber) {
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
s.DeleteSubscriber(other)
other.authenticated = false
other.Username = ""
sub.ChatServer("%s has been kicked from the room", username)
// Broadcast it to everyone.
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: "has been kicked from the room!",
Message: messages.PresenceKicked,
})
}
}
@ -200,7 +260,8 @@ func (s *Server) KickAllCommand() {
continue
}
s.DeleteSubscriber(sub)
sub.authenticated = false
sub.Username = ""
}
}
@ -216,7 +277,7 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
// Parse the command.
var (
username = words[1]
username = strings.TrimPrefix(words[1], "@")
duration = 24 * time.Hour
)
if len(words) >= 3 {
@ -227,27 +288,26 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) {
log.Info("Operator %s bans %s for %d hours", sub.Username, username, duration/time.Hour)
other, err := s.GetSubscriber(username)
if err != nil {
sub.ChatServer("/ban: username not found: %s", username)
} else {
// Ban them.
BanUser(username, duration)
// Add them to the ban list.
BanUser(username, duration)
// Broadcast it to everyone.
// If the target user is currently online, disconnect them and broadcast the ban to everybody.
if other, err := s.GetSubscriber(username); err == nil {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: username,
Message: "has been banned!",
Message: messages.PresenceBanned,
})
other.ChatServer("You have been banned from the chat room by %s. You may come back after %d hours.", sub.Username, duration/time.Hour)
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
s.DeleteSubscriber(other)
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
other.authenticated = false
other.Username = ""
}
sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour)
}
// UnbanCommand handles the `/unban` operator command.
@ -260,7 +320,7 @@ func (s *Server) UnbanCommand(words []string, sub *Subscriber) {
}
// Parse the command.
var username = words[1]
var username = strings.TrimPrefix(words[1], "@")
if UnbanUser(username) {
sub.ChatServer("The ban on %s has been lifted.", username)
@ -298,7 +358,7 @@ func (s *Server) OpCommand(words []string, sub *Subscriber) {
}
// Parse the command.
var username = words[1]
var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/op: user %s was not found.", username)
} else {
@ -328,7 +388,7 @@ func (s *Server) DeopCommand(words []string, sub *Subscriber) {
}
// Parse the command.
var username = words[1]
var username = strings.TrimPrefix(words[1], "@")
if other, err := s.GetSubscriber(username); err != nil {
sub.ChatServer("/deop: user %s was not found.", username)
} else {

View File

@ -13,7 +13,7 @@ import (
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 6
var currentVersion = 13
// Config for your BareRTC app.
type Config struct {
@ -36,15 +36,25 @@ type Config struct {
UseXForwardedFor bool
WebSocketReadLimit int64
MaxImageWidth int
PreviewImageWidth int
WebSocketReadLimit int64
WebSocketSendTimeout int
MaxImageWidth int
PreviewImageWidth int
TURN TurnConfig
PublicChannels []Channel
WebhookURLs []WebhookURL
VIP VIP
MessageFilters []*MessageFilter
ModerationRule []*ModerationRule
DirectMessageHistory DirectMessageHistory
Logging Logging
}
type TurnConfig struct {
@ -53,17 +63,43 @@ type TurnConfig struct {
Credential string
}
type VIP struct {
Name string
Branding string
Icon string
MutuallySecret bool
}
type DirectMessageHistory struct {
Enabled bool
SQLiteDatabase string
RetentionDays int
DisclaimerMessage string
}
// GetChannels returns a JavaScript safe array of the default PublicChannels.
func (c Config) GetChannels() template.JS {
data, _ := json.Marshal(c.PublicChannels)
return template.JS(data)
}
// GetChannel looks up and returns a channel by ID.
func (c Config) GetChannel(id string) (Channel, bool) {
for _, ch := range c.PublicChannels {
if ch.ID == id {
return ch, true
}
}
return Channel{}, false
}
// Channel config for a default public room.
type Channel struct {
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
ID string // Like "lobby"
Name string // Like "Main Chat Room"
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
VIP bool // For VIP users only
PermitPhotos bool // photos are allowed to be shared
// ChatServer messages to send to the user immediately upon connecting.
WelcomeMessages []string
@ -76,6 +112,21 @@ type WebhookURL struct {
URL 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
DisableCamera bool
}
// Current loaded configuration.
var Current = DefaultConfig()
@ -90,9 +141,10 @@ func DefaultConfig() Config {
CORSHosts: []string{
"https://www.example.com",
},
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
MaxImageWidth: 1280,
PreviewImageWidth: 360,
WebSocketReadLimit: 1024 * 1024 * 40, // 40 MB.
WebSocketSendTimeout: 10, // seconds
MaxImageWidth: 1280,
PreviewImageWidth: 360,
PublicChannels: []Channel{
{
ID: "lobby",
@ -108,6 +160,16 @@ func DefaultConfig() Config {
WelcomeMessages: []string{
"Welcome to the Off Topic channel!",
},
PermitPhotos: true,
},
{
ID: "vip",
Name: "VIPs Only",
VIP: true,
PermitPhotos: true,
WelcomeMessages: []string{
"This channel is only for operators and VIPs.",
},
},
},
TURN: TurnConfig{
@ -120,6 +182,44 @@ 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",
},
},
DirectMessageHistory: DirectMessageHistory{
Enabled: false,
SQLiteDatabase: "database.sqlite",
RetentionDays: 90,
DisclaimerMessage: `<i class="fa fa-info-circle mr-1"></i> <strong>Reminder:</strong> please conduct yourself honorably in Direct Messages.`,
},
Logging: Logging{
Directory: "./logs",
Channels: []string{"lobby", "offtopic"},
Usernames: []string{},
},
}
c.JWT.Strict = true
@ -166,3 +266,13 @@ func WriteSettings() error {
}
return os.WriteFile("./settings.toml", buf.Bytes(), 0644)
}
// GetModerationRule returns a matching ModerationRule for the given user, or nil if no rule is found.
func (c Config) GetModerationRule(username string) *ModerationRule {
for _, rule := range c.ModerationRule {
if rule.Username == username {
return rule
}
}
return nil
}

View File

@ -0,0 +1,47 @@
package config
import (
"regexp"
"sync"
"git.kirsle.net/apps/barertc/pkg/log"
)
// MessageFilter configures censored or auto-flagged messages in chat.
type MessageFilter struct {
Enabled bool
PublicChannels bool
PrivateChannels bool
KeywordPhrases []string
CensorMessage bool
ForwardMessage bool
ReportMessage bool
ChatServerResponse string
// Private use variables.
isRegexpCompiled bool
regexps []*regexp.Regexp
regexpMu sync.Mutex
}
// IterPhrases returns the keyword phrases as regular expressions.
func (mf *MessageFilter) IterPhrases() []*regexp.Regexp {
if mf.isRegexpCompiled {
return mf.regexps
}
// Compile and return the regexps.
mf.regexpMu.Lock()
defer mf.regexpMu.Unlock()
mf.regexps = []*regexp.Regexp{}
for _, phrase := range mf.KeywordPhrases {
re, err := regexp.Compile(phrase)
if err != nil {
log.Error("MessageFilter: phrase '%s' did not compile as a regexp: %s", phrase, err)
continue
}
mf.regexps = append(mf.regexps, re)
}
return mf.regexps
}

View File

@ -11,6 +11,7 @@ import (
"git.kirsle.net/apps/barertc/pkg/jwt"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/models"
"git.kirsle.net/apps/barertc/pkg/util"
)
@ -62,7 +63,8 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
other.SendJSON(messages.Message{
Action: messages.ActionKick,
})
s.DeleteSubscriber(other)
other.authenticated = false
other.Username = ""
}
// They will take over their original username.
@ -82,7 +84,6 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
sub.SendJSON(messages.Message{
Action: messages.ActionKick,
})
s.DeleteSubscriber(sub)
return
}
@ -97,7 +98,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg messages.Message) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: msg.Username,
Message: "has joined the room!",
Message: messages.PresenceJoined,
})
// Send the user back their settings.
@ -159,6 +160,41 @@ 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.")
return
}
// Send the report to the main website.
if err := s.reportFilteredMessage(sub, msg); err != nil {
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.
@ -171,34 +207,78 @@ func (s *Server) OnMessage(sub *Subscriber, msg messages.Message) {
if err == nil && rcpt.Mutes(sub.Username) && !sub.IsAdmin() {
log.Debug("Do not send message to %s: they have muted or booted %s", rcpt.Username, sub.Username)
return
} else if err != nil {
// 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
}
// If the sender already mutes the recipient, reply back with the error.
if err == nil && sub.Mutes(rcpt.Username) {
if err == nil && sub.Mutes(rcpt.Username) && !sub.IsAdmin() {
sub.ChatServer("You have muted %s and so your message has not been sent.", rcpt.Username)
return
}
// If there is blocking happening, do not send.
if sub.Blocks(rcpt) {
return
}
// Log this conversation?
if IsLoggingUsername(sub) && IsLoggingUsername(rcpt) {
// Both sides are logged, copy it to both logs.
LogMessage(sub, rcpt.Username, sub.Username, msg)
LogMessage(rcpt, sub.Username, sub.Username, msg)
} else if IsLoggingUsername(sub) {
// The sender of this message is being logged.
LogMessage(sub, rcpt.Username, sub.Username, msg)
} else if IsLoggingUsername(rcpt) {
// The recipient of this message is being logged.
LogMessage(rcpt, sub.Username, sub.Username, msg)
}
// Add it to the DM history SQLite database.
if err := (models.DirectMessage{}).LogMessage(sub.Username, rcpt.Username, message); err != nil && err != models.ErrNotInitialized {
log.Error("Logging DM history to SQLite: %s", err)
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
return
}
// Are we logging this public channel?
if IsLoggingChannel(msg.Channel) {
LogChannel(s, msg.Channel, sub.Username, msg)
}
// Broadcast a chat message to the room.
s.Broadcast(message)
}
// OnTakeback handles takebacks (delete your message for everybody)
func (s *Server) OnTakeback(sub *Subscriber, msg messages.Message) {
// In case we're in a DM thread, remove this message ID from the history table
// if the username matches.
wasRemovedFromHistory, err := (models.DirectMessage{}).TakebackMessage(sub.Username, msg.MessageID, sub.IsAdmin())
if err != nil && err != models.ErrNotInitialized {
log.Error("Error taking back DM history message (%s, %d): %s", sub.Username, msg.MessageID, err)
}
// Permission check.
if sub.JWTClaims == nil || !sub.JWTClaims.IsAdmin {
sub.midMu.Lock()
_, ok := sub.messageIDs[msg.MessageID]
sub.midMu.Unlock()
if !ok {
sub.ChatServer("That is not your message to take back.")
return
// The messageID is not found in the current chat session, but did we remove
// it from past DM history for the correct current user?
if !wasRemovedFromHistory {
sub.ChatServer("That is not your message to take back.")
return
}
}
}
@ -289,6 +369,11 @@ func (s *Server) OnFile(sub *Subscriber, msg messages.Message) {
return
}
// If there is blocking happening, do not send.
if sub.Blocks(rcpt) {
return
}
if err := s.SendTo(msg.Channel, message); err != nil {
sub.ChatServer("Your message could not be delivered: %s", err)
}
@ -301,8 +386,36 @@ 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 := config.Current.GetModerationRule(sub.Username); rule != nil {
// Are they barred from sharing their camera on chat?
if rule.DisableCamera {
sub.SendCut()
sub.ChatServer(
"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.",
)
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(
"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.",
)
}
}
}
// Hidden status: for operators only, + fake a join/exit chat message.
@ -312,14 +425,14 @@ func (s *Server) OnMe(sub *Subscriber, msg messages.Message) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
Message: messages.PresenceExited,
})
} else if sub.ChatStatus == "hidden" && msg.ChatStatus != "hidden" {
// Leaving hidden - fake join message
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has joined the room!",
Message: messages.PresenceJoined,
})
}
} else if msg.ChatStatus == "hidden" {
@ -333,6 +446,11 @@ 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.
@ -340,7 +458,14 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
// Enforce whether the viewer has permission to see this camera.
if ok, reason := s.IsVideoNotAllowed(sub, other); !ok {
sub.ChatServer(
"Could not open that video: %s", reason,
)
return
}
@ -372,22 +497,71 @@ func (s *Server) OnOpen(sub *Subscriber, msg messages.Message) {
})
}
// OnBoot is a user kicking you off their video stream.
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message) {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
// IsVideoNotAllowed verifies whether a viewer can open a broadcaster's camera.
//
// Returns a boolean and an error message to return if false.
func (s *Server) IsVideoNotAllowed(sub *Subscriber, other *Subscriber) (bool, string) {
var (
ourVideoActive = (sub.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
theirVideoActive = (other.VideoStatus & messages.VideoFlagActive) == messages.VideoFlagActive
theirMutualRequired = (other.VideoStatus & messages.VideoFlagMutualRequired) == messages.VideoFlagMutualRequired
theirVIPRequired = (other.VideoStatus & messages.VideoFlagOnlyVIP) == messages.VideoFlagOnlyVIP
)
sub.muteMu.Lock()
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,
)
// Conditions in which we can not watch their video.
var conditions = []struct {
If bool
Error string
}{
{
If: !theirVideoActive,
Error: "Their video is not currently enabled.",
},
{
If: theirMutualRequired && !ourVideoActive,
Error: fmt.Sprintf("%s has requested that you should share your own camera too before opening theirs.", other.Username),
},
{
If: theirVIPRequired && !sub.IsVIP() && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
{
If: (other.Mutes(sub.Username) || other.Blocks(sub)) && !sub.IsAdmin(),
Error: "You do not have permission to view that camera.",
},
}
for _, c := range conditions {
if c.If {
return false, c.Error
}
}
return true, ""
}
// OnBoot is a user kicking you off their video stream.
func (s *Server) OnBoot(sub *Subscriber, msg messages.Message, boot bool) {
sub.muteMu.Lock()
if boot {
log.Info("%s boots %s off their camera", sub.Username, msg.Username)
sub.booted[msg.Username] = struct{}{}
// 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.muteMu.Unlock()
s.SendWhoList()
}
@ -417,13 +591,34 @@ func (s *Server) OnMute(sub *Subscriber, msg messages.Message, mute bool) {
s.SendWhoList()
}
// OnBlock is a user placing a hard block (hide from) another user.
func (s *Server) OnBlock(sub *Subscriber, msg messages.Message) {
log.Info("%s blocks %s: %v", sub.Username, msg.Username)
// If the subject of the block is an admin, return an error.
if other, err := s.GetSubscriber(msg.Username); err == nil && other.IsAdmin() {
sub.ChatServer(
"You are not allowed to block a chat operator.",
)
return
}
sub.muteMu.Lock()
sub.blocked[msg.Username] = struct{}{}
sub.muteMu.Unlock()
// Send the Who List so the blocker/blockee can disappear from each other's list.
s.SendWhoList()
}
// OnBlocklist is a bulk user mute from the CachedBlocklist sent by the website.
func (s *Server) OnBlocklist(sub *Subscriber, msg messages.Message) {
log.Info("%s syncs their blocklist: %s", sub.Username, msg.Usernames)
log.Info("[%s] syncs their blocklist: %s", sub.Username, msg.Usernames)
sub.muteMu.Lock()
for _, username := range msg.Usernames {
sub.muted[username] = struct{}{}
sub.blocked[username] = struct{}{}
}
sub.muteMu.Unlock()
@ -439,8 +634,14 @@ func (s *Server) OnReport(sub *Subscriber, msg messages.Message) {
return
}
// Attach recent message context to DMs.
if strings.HasPrefix(msg.Channel, "@") {
context := getDirectMessageContext(sub.Username, msg.Username)
msg.Message += "\n\nRecent message context:\n\n" + context
}
// Post to the report webhook.
if err := PostWebhook(WebhookReport, WebhookRequest{
if _, err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
APIKey: config.Current.AdminAPIKey,
Report: WebhookRequestReport{
@ -464,7 +665,6 @@ func (s *Server) OnCandidate(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -480,7 +680,6 @@ func (s *Server) OnSDP(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -496,7 +695,6 @@ func (s *Server) OnWatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}
@ -511,7 +709,6 @@ func (s *Server) OnUnwatch(sub *Subscriber, msg messages.Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
log.Error(err.Error())
return
}

View File

@ -14,6 +14,7 @@ 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"`

156
pkg/logging.go Normal file
View File

@ -0,0 +1,156 @@
package barertc
import (
"fmt"
"io"
"os"
"strings"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
)
// IsLoggingUsername checks whether the app is currently configured to log a user's DMs.
func IsLoggingUsername(sub *Subscriber) bool {
if !config.Current.Logging.Enabled || sub == nil {
return false
}
// Has a cached setting and writer.
if sub.log {
return true
}
// Check the server config.
for _, username := range config.Current.Logging.Usernames {
if username == sub.Username {
sub.log = true
}
}
return sub.log
}
// IsLoggingChannel checks whether the app is currently logging a public channel.
func IsLoggingChannel(channel string) bool {
if !config.Current.Logging.Enabled {
return false
}
for _, value := range config.Current.Logging.Channels {
if value == channel {
return true
}
}
return false
}
// LogMessage appends to a user's conversation log.
func LogMessage(sub *Subscriber, otherUsername, senderUsername string, msg messages.Message) {
if sub == nil || !sub.log {
return
}
// Create or get the filehandle.
fh, err := initLogFile(sub, "@"+sub.Username, otherUsername)
if err != nil {
log.Error("LogMessage(%s): %s", sub.Username, err)
return
}
fh.Write(
[]byte(fmt.Sprintf(
"%s [%s] %s\n",
time.Now().Format(time.RFC3339),
senderUsername,
msg.Message,
)),
)
}
// LogChannel appends to a channel's conversation log.
func LogChannel(s *Server, channel string, username string, msg messages.Message) {
fh, err := initLogFile(s, channel)
if err != nil {
log.Error("LogChannel(%s): %s", channel, err)
}
fh.Write(
[]byte(fmt.Sprintf(
"%s [%s] %s\n",
time.Now().Format(time.RFC3339),
username,
msg.Message,
)),
)
}
// Tear down log files for subscribers.
func (s *Subscriber) teardownLogs() {
if s.logfh == nil {
return
}
for username, fh := range s.logfh {
log.Error("TeardownLogs(%s/%s)", s.Username, username)
fh.Close()
}
}
// Initialize a logging directory.
func initLogFile(sub LogCacheable, components ...string) (io.WriteCloser, error) {
// Initialize the logfh cache?
var logfh = sub.GetLogFilehandleCache()
var (
suffix = components[len(components)-1]
middle = components[:len(components)-1]
paths = append([]string{
config.Current.Logging.Directory,
}, middle...,
)
filename = strings.Join(
append(paths, suffix+".txt"),
"/",
)
)
// Already have this conversation log open?
if fh, ok := logfh[suffix]; ok {
return fh, nil
}
log.Warn("Initialize log directory: path=%+v suffix=%s", paths, suffix)
if err := os.MkdirAll(strings.Join(paths, "/"), 0755); err != nil {
return nil, err
}
fh, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
logfh[suffix] = fh
return logfh[suffix], nil
}
// Interface for objects that hold log filehandle caches.
type LogCacheable interface {
GetLogFilehandleCache() map[string]io.WriteCloser
}
// Implementations of LogCacheable.
func (sub *Subscriber) GetLogFilehandleCache() map[string]io.WriteCloser {
if sub.logfh == nil {
sub.logfh = map[string]io.WriteCloser{}
}
return sub.logfh
}
func (s *Server) GetLogFilehandleCache() map[string]io.WriteCloser {
if s.logfh == nil {
s.logfh = map[string]io.WriteCloser{}
}
return s.logfh
}

173
pkg/message_filters.go Normal file
View File

@ -0,0 +1,173 @@
package barertc
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/messages"
)
// Functionality for handling server-side message filtering and reporting.
// filterMessage will check an incoming user message against the configured
// server-side filters and react accordingly. This function also is
// responsible for collecting the recent contexts (10 messages per channel).
//
// Parameters: the rawMsg is their (pre-Markdown-formatted) original message
// (for the message context); the msg pointer is their post-formatted one, which
// may be modified to censor their word before returning.
//
// Returns the matching message filter (or nil) and a boolean (matched).
func (s *Server) filterMessage(sub *Subscriber, rawMsg messages.Message, msg *messages.Message) (*config.MessageFilter, bool) {
// Collect the recent channel context first.
if strings.HasPrefix(msg.Channel, "@") {
// DM
pushDirectMessageContext(sub, sub.Username, msg.Channel[1:], rawMsg)
// If either party is an admin user, waive filtering this DM chat.
if sub.IsAdmin() {
return nil, false
} else if other, err := s.GetSubscriber(msg.Channel[1:]); err == nil && other.IsAdmin() {
return nil, false
}
} else {
// Public channel
pushMessageContext(sub, msg.Channel, rawMsg)
}
// Check it against the configured filters.
var matched bool
for _, filter := range config.Current.MessageFilters {
if !filter.Enabled {
continue
}
for _, phrase := range filter.IterPhrases() {
m := phrase.FindAllStringSubmatch(msg.Message, -1)
for _, match := range m {
// Found a match!
matched = true
// Censor it?
if filter.CensorMessage {
msg.Message = strings.ReplaceAll(msg.Message, match[0], strings.Repeat("*", len(match[0])))
}
}
}
if matched {
return filter, true
}
}
return nil, false
}
// Report the filtered message along with recent context.
func (s *Server) reportFilteredMessage(sub *Subscriber, msg messages.Message) error {
if !WebhookEnabled(WebhookReport) {
return errors.New("report webhook is not enabled on this server")
}
// Prepare the report.
var context string
if strings.HasPrefix(msg.Channel, "@") {
context = getDirectMessageContext(sub.Username, msg.Channel[1:])
} else {
context = getMessageContext(msg.Channel)
}
if _, err := PostWebhook(WebhookReport, WebhookRequest{
Action: WebhookReport,
APIKey: config.Current.AdminAPIKey,
Report: WebhookRequestReport{
FromUsername: sub.Username,
AboutUsername: sub.Username,
Channel: msg.Channel,
Timestamp: time.Now().Format(time.RFC1123),
Reason: "Server Side Message Filter",
Message: msg.Message,
Comment: fmt.Sprintf(
"This is an automated report via server side chat filters.\n\n"+
"The recent context in this channel included the following conversation:\n\n"+
"%s",
context,
),
},
}); err != nil {
return err
}
return nil
}
// Message Context Caching
//
// Hold the recent (10) messages for each channel so in case of automated
// reporting, the context can be delivered in the report.
var (
messageContexts = map[string][]string{}
messageContextMu sync.RWMutex
messageContextSize = 30
)
// Push a message onto the recent messages context.
func pushMessageContext(sub *Subscriber, channel string, msg messages.Message) {
messageContextMu.Lock()
defer messageContextMu.Unlock()
// Initialize the context for new channel the first time.
if _, ok := messageContexts[channel]; !ok {
messageContexts[channel] = []string{}
}
// Append this message to it.
messageContexts[channel] = append(messageContexts[channel], fmt.Sprintf(
"%s [%s] %s",
time.Now().Format("2006-01-02 15:04:05"),
sub.Username,
strings.TrimSpace(msg.Message),
))
// Trim the context to recent messages only.
if len(messageContexts[channel]) > messageContextSize {
messageContexts[channel] = messageContexts[channel][len(messageContexts[channel])-messageContextSize:]
}
}
// Push a message context for DMs. A channel name will be derived consistently
// based on the sorted pair of usernames.
func pushDirectMessageContext(sub *Subscriber, username1, username2 string, msg messages.Message) {
var names = []string{username1, username2}
sort.Strings(names)
pushMessageContext(
sub,
fmt.Sprintf("@%s", strings.Join(names, ":")),
msg,
)
}
// Get the recent message context, pretty printed.
func getMessageContext(channel string) string {
messageContextMu.RLock()
defer messageContextMu.RUnlock()
if _, ok := messageContexts[channel]; !ok {
return "(No recent message history in this channel)"
}
return strings.Join(messageContexts[channel], "\n\n")
}
func getDirectMessageContext(username1, username2 string) string {
var names = []string{username1, username2}
sort.Strings(names)
return getMessageContext(
fmt.Sprintf("@%s", strings.Join(names, ":")),
)
}

View File

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

View File

@ -0,0 +1,238 @@
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.VideoFlagIsTalking == messages.VideoFlagIsTalking && !f.IsTalking {
return errors.New("IsTalking expected to be set")
} else if video&messages.VideoFlagIsTalking != messages.VideoFlagIsTalking && f.IsTalking {
return errors.New("IsTalking expected NOT to be set")
}
if video&messages.VideoFlagMutualRequired == messages.VideoFlagMutualRequired && !f.MutualRequired {
return errors.New("MutualRequired expected to be set")
} else if video&messages.VideoFlagMutualRequired != messages.VideoFlagMutualRequired && f.MutualRequired {
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 | messages.VideoFlagIsTalking
},
Expect: flags{
Active: true,
Muted: true,
OnlyVIP: true,
NSFW: true,
IsTalking: true,
},
},
}
for i, tc := range tests {
flag = tc.Mutate(flag)
if err := tc.Expect.Check(flag); err != nil {
t.Errorf("Test #%d: video flag %d failed check: %s", i, flag, err)
}
}
}

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

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

View File

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

View File

@ -81,7 +81,7 @@ func IndexPage() http.HandlerFunc {
return template.JS(fmt.Sprintf("%v", v))
},
})
tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
tmpl, err := tmpl.ParseFiles("dist/index.html")
if err != nil {
panic(err.Error())
}
@ -125,3 +125,16 @@ func AboutPage() http.HandlerFunc {
tmpl.ExecuteTemplate(w, "index", values)
})
}
// LogoutPage returns the HTML template for the logout page.
func LogoutPage() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Load the template, TODO: once on server startup.
tmpl := template.New("index")
tmpl, err := tmpl.ParseFiles("web/templates/logout.html")
if err != nil {
panic(err.Error())
}
tmpl.ExecuteTemplate(w, "index", nil)
})
}

240
pkg/polling_api.go Normal file
View File

@ -0,0 +1,240 @@
package barertc
import (
"context"
"encoding/json"
"net/http"
"time"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/messages"
"git.kirsle.net/apps/barertc/pkg/util"
"github.com/google/uuid"
)
// Polling user timeout before disconnecting them.
const PollingUserTimeout = time.Minute
// JSON payload structure for polling API.
type PollMessage struct {
// Send the username after authenticated.
Username string `json:"username,omitempty"`
// SessionID for authentication.
SessionID string `json:"session_id,omitempty"`
// BareRTC protocol message.
Message messages.Message `json:"msg"`
}
type PollResponse struct {
// Session ID.
Username string `json:"username,omitempty"`
SessionID string `json:"session_id,omitempty"`
// Pending messages.
Messages []messages.Message `json:"messages"`
}
// Helper method to send an error as a PollResponse.
func PollResponseError(message string) PollResponse {
return PollResponse{
Messages: []messages.Message{
{
Action: messages.ActionError,
Username: "ChatServer",
Message: message,
},
},
}
}
// KickIdlePollUsers is a goroutine that will disconnect polling API users
// who haven't been seen in a while.
func (s *Server) KickIdlePollUsers() {
log.Debug("KickIdlePollUsers goroutine engaged")
for {
time.Sleep(10 * time.Second)
for _, sub := range s.IterSubscribers() {
if sub.usePolling && time.Since(sub.lastPollAt) > PollingUserTimeout {
// Send an exit message.
if sub.authenticated && sub.ChatStatus != "hidden" {
log.Error("KickIdlePollUsers: %s last seen %s ago", sub.Username, sub.lastPollAt)
sub.authenticated = false
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: messages.PresenceTimedOut,
})
s.SendWhoList()
}
s.DeleteSubscriber(sub)
}
}
}
}
// FlushPollResponse returns a response for the polling API that will flush
// all pending messages sent to the client.
func (sub *Subscriber) FlushPollResponse() PollResponse {
var msgs = []messages.Message{}
// Drain the messages from the outbox channel.
for len(sub.messages) > 0 {
message := <-sub.messages
var msg messages.Message
json.Unmarshal(message, &msg)
msgs = append(msgs, msg)
}
return PollResponse{
Username: sub.Username,
SessionID: sub.sessionID,
Messages: msgs,
}
}
// Functions for the Polling API as an alternative to WebSockets.
func (s *Server) PollingAPI() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := util.IPAddress(r)
// JSON writer for the response.
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
// Parse the request.
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(PollResponseError("Only POST methods allowed"))
return
} else if r.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusBadRequest)
enc.Encode(PollResponseError("Only application/json content-types allowed"))
return
}
defer r.Body.Close()
// Parse the request payload.
var (
params PollMessage
dec = json.NewDecoder(r.Body)
)
if err := dec.Decode(&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,8 +1,13 @@
package barertc
import (
"io"
"net/http"
"sync"
"git.kirsle.net/apps/barertc/pkg/config"
"git.kirsle.net/apps/barertc/pkg/log"
"git.kirsle.net/apps/barertc/pkg/models"
)
// Server is the primary back-end server struct for BareRTC, see main.go
@ -16,28 +21,46 @@ type Server struct {
// Connected WebSocket subscribers.
subscribersMu sync.RWMutex
subscribers map[*Subscriber]struct{}
// Cached filehandles for channel logging.
logfh map[string]io.WriteCloser
}
// NewServer initializes the Server.
func NewServer() *Server {
return &Server{
subscriberMessageBuffer: 16,
subscriberMessageBuffer: 32,
subscribers: make(map[*Subscriber]struct{}),
}
}
// Setup the server: configure HTTP routes, etc.
func (s *Server) Setup() error {
// Enable the SQLite database for DM history?
if config.Current.DirectMessageHistory.Enabled {
if err := models.Initialize(config.Current.DirectMessageHistory.SQLiteDatabase); err != nil {
log.Error("Error initializing SQLite database: %s", err)
}
}
var mux = http.NewServeMux()
mux.Handle("/", IndexPage())
mux.Handle("/about", AboutPage())
mux.Handle("/logout", LogoutPage())
mux.Handle("/ws", s.WebSocket())
mux.Handle("/poll", s.PollingAPI())
mux.Handle("/api/statistics", s.Statistics())
mux.Handle("/api/blocklist", s.BlockList())
mux.Handle("/api/block/now", s.BlockNow())
mux.Handle("/api/disconnect/now", s.DisconnectNow())
mux.Handle("/api/authenticate", s.Authenticate())
mux.Handle("/api/shutdown", s.ShutdownAPI())
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
mux.Handle("/api/profile", s.UserProfile())
mux.Handle("/api/message/history", s.MessageHistory())
mux.Handle("/api/message/clear", s.ClearMessages())
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("dist/assets"))))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("dist/static"))))
s.mux = mux
@ -46,5 +69,7 @@ func (s *Server) Setup() error {
// ListenAndServe starts the web server.
func (s *Server) ListenAndServe(address string) error {
// Run the polling user idle kicker.
go s.KickIdlePollUsers()
return http.ListenAndServe(address, s.mux)
}

View File

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

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
@ -30,19 +31,126 @@ type Subscriber struct {
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
// Connection details (WebSocket).
conn *websocket.Conn // WebSocket user
ctx context.Context
cancel context.CancelFunc
messages chan []byte
closeSlow func()
// Polling API users.
usePolling bool
sessionID string
lastPollAt time.Time
lastPollJWT time.Time // give a new JWT once in a while
muteMu sync.RWMutex
booted map[string]struct{} // usernames booted off your camera
blocked map[string]struct{} // usernames you have blocked
muted map[string]struct{} // usernames you muted
// Record which message IDs belong to this user.
midMu sync.Mutex
messageIDs map[int]struct{}
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.
@ -59,7 +167,7 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.Broadcast(messages.Message{
Action: messages.ActionPresence,
Username: sub.Username,
Message: "has exited the room!",
Message: messages.PresenceExited,
})
s.SendWhoList()
}
@ -82,41 +190,8 @@ func (sub *Subscriber) ReadLoop(s *Server) {
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.")
}
// Handle their message.
s.OnClientMessage(sub, msg)
}
}()
}
@ -126,6 +201,11 @@ 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)
@ -133,7 +213,16 @@ func (sub *Subscriber) SendJSON(v interface{}) error {
return err
}
log.Debug("SendJSON(%d=%s): %s", sub.ID, sub.Username, data)
return sub.conn.Write(sub.ctx, websocket.MessageText, 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.
@ -145,6 +234,13 @@ func (sub *Subscriber) SendMe() {
})
}
// 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{
@ -178,19 +274,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
// ctx := c.CloseRead(r.Context())
ctx, cancel := context.WithCancel(r.Context())
sub := &Subscriber{
conn: c,
ctx: ctx,
cancel: cancel,
messages: make(chan []byte, s.subscriberMessageBuffer),
closeSlow: func() {
c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages")
},
booted: make(map[string]struct{}),
muted: make(map[string]struct{}),
messageIDs: make(map[int]struct{}),
ChatStatus: "online",
}
sub := s.NewWebSocketSubscriber(ctx, c, cancel)
s.AddSubscriber(sub)
defer s.DeleteSubscriber(sub)
@ -200,7 +284,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
for {
select {
case msg := <-sub.messages:
err = writeTimeout(ctx, time.Second*5, c, msg)
err = writeTimeout(ctx, time.Second*time.Duration(config.Current.WebSocketSendTimeout), c, msg)
if err != nil {
return
}
@ -209,7 +293,7 @@ func (s *Server) WebSocket() http.HandlerFunc {
var token string
if sub.JWTClaims != nil {
if jwt, err := sub.JWTClaims.ReSign(); err != nil {
log.Error("ReSign JWT token for %s: %s", sub.Username, err)
log.Error("ReSign JWT token for %s#%d: %s", sub.Username, sub.ID, err)
} else {
token = jwt
}
@ -255,13 +339,21 @@ func (s *Server) GetSubscriber(username string) (*Subscriber, error) {
// 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()
@ -316,6 +408,13 @@ func (s *Server) Broadcast(msg messages.Message) {
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.
@ -331,6 +430,18 @@ func (s *Server) Broadcast(msg messages.Message) {
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)
}
}
@ -392,6 +503,12 @@ func (s *Server) SendWhoList() {
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,
@ -400,9 +517,27 @@ func (s *Server) SendWhoList() {
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
// Hide video flags of other users (never for the current user).
if user.Username != sub.Username {
// If this person had booted us, force their camera to "off"
if user.Boots(sub.Username) || user.Mutes(sub.Username) {
if sub.IsAdmin() {
// They kicked the admin off, but admin can reopen the cam if they want.
// But, unset the user's "auto-open your camera" flag, so if the admin
// reopens it, the admin's cam won't open on the recipient's screen.
who.Video ^= messages.VideoFlagMutualOpen
} else {
// Force their video to "off"
who.Video = 0
}
}
// If this person's VideoFlag is set to VIP Only, force their camera to "off"
// except when the person looking has the VIP status.
if (user.VideoStatus&messages.VideoFlagOnlyVIP == messages.VideoFlagOnlyVIP) && (!sub.IsVIP() && !sub.IsAdmin()) {
who.Video = 0
}
}
if user.JWTClaims != nil {
@ -412,6 +547,16 @@ func (s *Server) SendWhoList() {
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)
}
@ -439,6 +584,32 @@ func (s *Subscriber) Mutes(username string) bool {
return ok
}
// Blocks checks whether the subscriber blocks the username, or vice versa (blocking goes both directions).
func (s *Subscriber) Blocks(other *Subscriber) bool {
if s == nil || other == nil {
return false
}
// If either side is an admin, blocking is not allowed.
if s.IsAdmin() || other.IsAdmin() {
return false
}
s.muteMu.RLock()
defer s.muteMu.RUnlock()
// Forward block?
if _, ok := s.blocked[other.Username]; ok {
return true
}
// Reverse block?
other.muteMu.RLock()
defer other.muteMu.RUnlock()
_, ok := other.blocked[s.Username]
return ok
}
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

22437
public/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

3
public/static/css/bulma.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -6,6 +6,18 @@ body {
min-height: 100vh;
}
/* No dragging images - it plays wrongly with the drag/drop image sharing feature
and users can accidentally share in chat by dragging an on-page image */
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
user-select: none;
-moz-user-select: none;
-webkit-user-drag: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.float-right {
float: right;
}
@ -17,10 +29,13 @@ body {
/* DM title and bg color */
.has-background-private {
background-color: #b748c7;
background-color: #b748c7 !important;
}
.has-background-dm {
background-color: #ffefff;
background-color: #fff9ff !important;
}
.has-background-at-mention {
background-color: rgb(250, 250, 192);
}
/* Truncate long text, e.g. usernames in the who list */
@ -72,13 +87,6 @@ body {
grid-template-rows: auto 1fr auto;
}
@media (prefers-color-scheme: dark) {
.chat-container {
background: rgb(39, 39, 39);
background: linear-gradient(0deg, rgb(39, 39, 39) 0%, rgb(66, 66, 66) 100%);
}
}
/* Header row */
.chat-container > .chat-header {
grid-column: 1 / 4;
@ -107,6 +115,14 @@ body {
bottom: 4px;
}
/* User status indicator in the lower left corner of DMs */
.user-status-dm-field {
position: absolute;
z-index: 38; /* below auto-scroll checkbox */
left: 12px;
bottom: 4px;
}
/* Footer row: message entry box */
.chat-container > .chat-footer {
grid-column: 1 / 4;
@ -122,7 +138,7 @@ body {
/* Responsive CSS styles */
@media screen and (min-width: 1024px) {
.mobile-only {
display: none;
display: none !important;
}
}
@media screen and (max-width: 1024px) {
@ -233,7 +249,6 @@ div.feed.popped-out {
cursor: move;
top: 0;
left: 0;
z-index: 1000;
resize: none;
}
@ -336,3 +351,8 @@ div.feed.popped-out {
.has-text-gender-other {
color: #cc00cc !important;
}
/* VIP colors for profile icon */
.has-background-vip {
background-image: linear-gradient(141deg, #d1e1ff 0, #ffddff 100%)
}

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