From 8f60bdba0ef26e1a1f90f5fe007488bc3aa65283 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 5 Feb 2023 00:53:50 -0800 Subject: [PATCH] Spit and polish * Add configuration system and default public channels support * Add support for multiple channels and DM threads with users, with unread badge indicators. DMs rearrange themselves by most recently updated on top. * Responsive CSS to work well on mobile devices. --- README.md | 60 ++++++-- cmd/BareRTC/main.go | 4 + go.mod | 1 + go.sum | 2 + pkg/config/config.go | 84 +++++++++++ pkg/handlers.go | 23 ++- pkg/messages.go | 3 +- pkg/pages.go | 33 +++-- pkg/util/strings.go | 13 ++ pkg/websocket.go | 20 +++ web/static/css/chat.css | 25 ++++ web/static/js/BareRTC.js | 300 ++++++++++++++++++++++++++++++++++++--- web/templates/chat.html | 166 ++++++++++++---------- 13 files changed, 615 insertions(+), 119 deletions(-) create mode 100644 pkg/config/config.go create mode 100644 pkg/util/strings.go diff --git a/README.md b/README.md index c16b9b6..6fb0861 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,45 @@ # BareRTC -BareRTC is a simple WebRTC-based chat room application. It is especially -designed to be plugged into any existing website, with or without a pre-existing -base of users. +BareRTC is a simple WebRTC-based chat room application. It is especially designed to be plugged into any existing website, with or without a pre-existing base of users. + +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. + +This is still a **work in progress** and see the features it still needs, below. # Features -Planned features: +* Specify multiple Public Channels that all users have access to. +* Users can open direct message (one-on-one) conversations with each other. +* No long-term server side state: messages are pushed out as they come in. +* Users may broadcast their webcam which shows a camera icon by their name in the Who List. Users may click on those icons to open multiple camera feeds of other users they are interested in. +* Mobile friendly: works best on iPads and above but adapts to smaller screens well. +* WebRTC means peer-to-peer video streaming so cheap on hosting costs! +* Simple integration with your existing userbase via signed JWT tokens. -* One common group chat area where all participants can broadcast text messages. -* Direct (one-on-one) text conversations between any two users. -* Simple integration with your pre-existing userbase via signed JWT tokens. +Some important features it still needs: + +* JWT authentication, and admin user permissions (kick/ban/etc.) + * Support for profile URLs, custom avatar image URLs, custom profile fields to show in-app +* Lots of UI cleanup. # Configuration -TBD +Work in progress. On first run it will create the settings.toml file for you: + +```toml +[JWT] + Enabled = false + SecretKey = "" + +[[PublicChannels]] + ID = "lobby" + Name = "Lobby" + Icon = "fa fa-gavel" + +[[PublicChannels]] + ID = "offtopic" + Name = "Off Topic" +``` # Authentication @@ -31,6 +56,25 @@ claims: } ``` +This feature is not hooked up yet. JWT authenticated users sent by your app is the primary supported userbase and will bring many features such as: + +* Admin user permissions: you tell us who the admin is and they can moderate the chat room. +* User profile URLs that can be opened from the Who List. +* Custom avatar image URLs for your users. +* Extra profile fields/icons that you can customize the display with. + +## Running Without Authentication + +The default app doesn't need any authentication at all: users are asked to pick their own username when joining the chat. The server may re-assign them a new name if they enter one that's already taken. + +It is not recommended to run in this mode as admin controls to moderate the server are disabled. + +### Known Bugs Running Without Authentication + +This app is not designed to run without JWT authentication for users enabled. In the app's default state, users can pick their own username when they connect and the server will adjust their name to resolve duplicates. Direct message threads are based on the username so if a user logs off, somebody else could log in with the same username and "resume" direct message threads that others were involved in. + +Note that they would not get past history of those DMs as this server only pushes _new_ messages to users after they connect. + # License GPLv3. diff --git a/cmd/BareRTC/main.go b/cmd/BareRTC/main.go index 2e77177..ceab369 100644 --- a/cmd/BareRTC/main.go +++ b/cmd/BareRTC/main.go @@ -6,6 +6,7 @@ import ( "time" barertc "git.kirsle.net/apps/barertc/pkg" + "git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/log" ) @@ -27,6 +28,9 @@ func main() { log.SetDebug(true) } + // Load configuration. + config.LoadSettings() + app := barertc.NewServer() app.Setup() panic(app.ListenAndServe(address)) diff --git a/go.mod b/go.mod index e39ebd4..44baf68 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.19 require git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b require ( + github.com/BurntSushi/toml v1.2.1 // indirect github.com/klauspost/compress v1.10.3 // indirect github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect golang.org/x/crypto v0.5.0 // indirect diff --git a/go.sum b/go.sum index afb755c..cebc06e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o= git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..82c9759 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,84 @@ +package config + +import ( + "bytes" + "encoding/json" + "html/template" + "os" + + "git.kirsle.net/apps/barertc/pkg/log" + "github.com/BurntSushi/toml" +) + +// Config for your BareRTC app. +type Config struct { + JWT struct { + Enabled bool + SecretKey string + } + + PublicChannels []Channel +} + +// 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) +} + +// 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) +} + +// Current loaded configuration. +var Current = DefaultConfig() + +// DefaultConfig returns sensible defaults and will write the initial +// settings.toml file to disk. +func DefaultConfig() Config { + var c = Config{ + PublicChannels: []Channel{ + { + ID: "lobby", + Name: "Lobby", + Icon: "fa fa-gavel", + }, + { + ID: "offtopic", + Name: "Off Topic", + }, + }, + } + return c +} + +// LoadSettings reads a settings.toml from disk if available. +func LoadSettings() error { + data, err := os.ReadFile("./settings.toml") + if err != nil { + // Settings file didn't exist, create the default one. + if os.IsNotExist(err) { + WriteSettings() + return nil + } + + return err + } + + _, err = toml.Decode(string(data), &Current) + return err +} + +// WriteSettings will commit the settings.toml to disk. +func WriteSettings() error { + log.Error("Note: initial settings.toml was written to disk.") + var buf = new(bytes.Buffer) + err := toml.NewEncoder(buf).Encode(Current) + if err != nil { + return err + } + return os.WriteFile("./settings.toml", buf.Bytes(), 0644) +} diff --git a/pkg/handlers.go b/pkg/handlers.go index 66b86be..41b7b39 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -2,9 +2,11 @@ package barertc import ( "fmt" + "strings" "time" "git.kirsle.net/apps/barertc/pkg/log" + "git.kirsle.net/apps/barertc/pkg/util" ) // OnLogin handles "login" actions from the client. @@ -52,9 +54,28 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) { return } + // Message to be echoed to the channel. + var message = Message{ + Action: ActionMessage, + Channel: msg.Channel, + Username: sub.Username, + Message: msg.Message, + } + + // Is this a DM? + if strings.HasPrefix(msg.Channel, "@") { + // Echo the message only to both parties. + // message.Channel = "@" + sub.Username + s.SendTo(sub.Username, message) + message.Channel = "@" + sub.Username + s.SendTo(msg.Channel, message) + return + } + // Broadcast a chat message to the room. s.Broadcast(Message{ Action: ActionMessage, + Channel: msg.Channel, Username: sub.Username, Message: msg.Message, }) @@ -82,7 +103,7 @@ func (s *Server) OnOpen(sub *Subscriber, msg Message) { } // Make up a WebRTC shared secret and send it to both of them. - secret := RandomString(16) + secret := util.RandomString(16) log.Info("WebRTC: %s opens %s with secret %s", sub.Username, other.Username, secret) // Ring the target of this request and give them the secret. diff --git a/pkg/messages.go b/pkg/messages.go index 387e4f1..d6819c2 100644 --- a/pkg/messages.go +++ b/pkg/messages.go @@ -2,8 +2,9 @@ package barertc type Message struct { Action string `json:"action,omitempty"` + Channel string `json:"channel,omitempty"` Username string `json:"username,omitempty"` - Message string `json:"message",omitempty` + Message string `json:"message,omitempty"` // WhoList for `who` actions WhoList []WhoList `json:"whoList,omitempty"` diff --git a/pkg/pages.go b/pkg/pages.go index bf7f240..66fddd6 100644 --- a/pkg/pages.go +++ b/pkg/pages.go @@ -2,10 +2,11 @@ package barertc import ( "html/template" - "math/rand" "net/http" + "git.kirsle.net/apps/barertc/pkg/config" "git.kirsle.net/apps/barertc/pkg/log" + "git.kirsle.net/apps/barertc/pkg/util" ) // IndexPage returns the HTML template for the chat room. @@ -13,11 +14,23 @@ func IndexPage() http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Load the template, TODO: once on server startup. tmpl := template.New("index") + + // Variables to give to the front-end page. + var values = map[string]interface{}{ + // A cache-busting hash for JS and CSS includes. + "CacheHash": util.RandomString(8), + + // The current website settings. + "Config": config.Current, + } + tmpl.Funcs(template.FuncMap{ // Cache busting random string for JS and CSS dependency. - "CacheHash": func() string { - return RandomString(8) - }, + // "CacheHash": func() string { + // return util.RandomString(8) + // }, + + // }) tmpl, err := tmpl.ParseFiles("web/templates/chat.html") if err != nil { @@ -26,16 +39,6 @@ func IndexPage() http.HandlerFunc { // END load the template log.Info("Index route hit") - tmpl.ExecuteTemplate(w, "index", nil) + tmpl.ExecuteTemplate(w, "index", values) }) } - -// RandomString returns a random string of any length. -func RandomString(n int) string { - const charset = "abcdefghijklmnopqrstuvwxyz" - var result = make([]byte, n) - for i := 0; i < n; i++ { - result[i] = charset[rand.Intn(len(charset))] - } - return string(result) -} diff --git a/pkg/util/strings.go b/pkg/util/strings.go new file mode 100644 index 0000000..0a0850d --- /dev/null +++ b/pkg/util/strings.go @@ -0,0 +1,13 @@ +package util + +import "math/rand" + +// RandomString returns a random string of any length. +func RandomString(n int) string { + const charset = "abcdefghijklmnopqrstuvwxyz" + var result = make([]byte, n) + for i := 0; i < n; i++ { + result[i] = charset[rand.Intn(len(charset))] + } + return string(result) +} diff --git a/pkg/websocket.go b/pkg/websocket.go index 7b5fdd8..1e70246 100644 --- a/pkg/websocket.go +++ b/pkg/websocket.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "git.kirsle.net/apps/barertc/pkg/log" @@ -229,12 +230,31 @@ func (s *Server) Broadcast(msg Message) { for _, sub := range s.IterSubscribers(true) { sub.SendJSON(Message{ Action: msg.Action, + Channel: msg.Channel, Username: msg.Username, Message: msg.Message, }) } } +// SendTo sends a message to a given username. +func (s *Server) SendTo(username string, msg Message) { + log.Debug("SendTo(%s): %+v", username, msg) + username = strings.TrimPrefix(username, "@") + s.subscribersMu.RLock() + defer s.subscribersMu.RUnlock() + for _, sub := range s.IterSubscribers(true) { + if sub.Username == username { + sub.SendJSON(Message{ + Action: msg.Action, + Channel: msg.Channel, + Username: msg.Username, + Message: msg.Message, + }) + } + } +} + // SendWhoList broadcasts the connected members to everybody in the room. func (s *Server) SendWhoList() { var ( diff --git a/web/static/css/chat.css b/web/static/css/chat.css index ea424ff..e14dced 100644 --- a/web/static/css/chat.css +++ b/web/static/css/chat.css @@ -5,6 +5,10 @@ body { min-height: 100vh; } +.float-right { + float: right; +} + /************************ * Main CSS Grid Layout * ************************/ @@ -51,6 +55,27 @@ body { overflow: hidden; } +/* Responsive CSS styles */ +@media screen and (min-width: 1024px) { + .mobile-only { + display: none; + } +} +@media screen and (max-width: 1024px) { + .chat-container { + grid-template-columns: 0px 1fr 0px; + column-gap: 0; + } + + .left-column { + display: none; + } + + .right-column { + display: none; + } +} + /*********************************************** * Reusable CSS Grid-based Bulma Card layouts * * with a fixed header, full size scrollable * diff --git a/web/static/js/BareRTC.js b/web/static/js/BareRTC.js index 1cb9825..6c86c1c 100644 --- a/web/static/js/BareRTC.js +++ b/web/static/js/BareRTC.js @@ -14,6 +14,11 @@ const app = Vue.createApp({ return { busy: false, + // Website configuration provided by chat.html template. + config: { + channels: PublicChannels, + }, + channel: "lobby", username: "", //"test", message: "", @@ -46,9 +51,34 @@ const app = Vue.createApp({ // Chat history. history: [], + channels: { + // There will be values here like: + // "lobby": { + // "history": [], + // "updated": timestamp, + // "unread": 4, + // }, + // "@username": { + // "history": [], + // ... + // } + }, historyScrollbox: null, DMs: {}, + // Responsive CSS for mobile. + responsive: { + leftDrawerOpen: false, + rightDrawerOpen: false, + nodes: { + // DOM nodes for the CSS grid cells. + $container: null, + $left: null, + $center: null, + $right: null, + } + }, + loginModal: { visible: false, }, @@ -58,6 +88,22 @@ const app = Vue.createApp({ this.webcam.elem = document.querySelector("#localVideo"); this.historyScrollbox = document.querySelector("#chatHistory"); + this.responsive.nodes = { + $container: document.querySelector(".chat-container"), + $left: document.querySelector(".left-column"), + $center: document.querySelector(".chat-column"), + $right: document.querySelector(".right-column"), + }; + + window.addEventListener("resize", () => { + // Reset CSS overrides for responsive display on any window size change. + this.resetResponsiveCSS(); + }); + + for (let channel of this.config.channels) { + this.initHistory(channel.ID); + } + this.ChatServer("Welcome to BareRTC!") if (!this.username) { @@ -66,6 +112,46 @@ const app = Vue.createApp({ this.signIn(); } }, + computed: { + chatHistory() { + if (this.channels[this.channel] == undefined) { + return []; + } + + let history = this.channels[this.channel].history; + + // How channels work: + // - Everything going to a public channel like "lobby" goes + // into the "lobby" channel in the front-end + // - Direct messages are different: they are all addressed + // "to" the channel of the current @user, but they are + // divided into DM threads based on the username. + if (this.channel.indexOf("@") === 0) { + // DM thread, divide them by sender. + // let username = this.channel.substring(1); + // return history.filter(v => { + // return v.username === username; + // }); + } + return history; + }, + channelName() { + // Return a suitable channel title. + if (this.channel.indexOf("@") === 0) { + // A DM, return it directly as is. + return this.channel; + } + + // Find the friendly name from our config. + for (let channel of this.config.channels) { + if (channel.ID === this.channel) { + return channel.Name; + } + } + + return this.channel; + }, + }, methods: { signIn() { this.loginModal.visible = false; @@ -89,6 +175,7 @@ const app = Vue.createApp({ console.debug("Send message: %s", this.message); this.ws.conn.send(JSON.stringify({ action: "message", + channel: this.channel, message: this.message, })); @@ -111,7 +198,21 @@ const app = Vue.createApp({ this.username = msg.username; } - this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`); + // this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`); + }, + + // WhoList updates. + onWho(msg) { + this.whoList = msg.whoList; + + // If we had a camera open with any of these and they have gone + // off camera, close our side of the connection. + for (let row of this.whoList) { + if (this.WebRTC.streams[row.username] != undefined && + row.videoActive !== true) { + this.closeVideo(row.username); + } + } }, // Send a video request to access a user's camera. @@ -125,7 +226,7 @@ const app = Vue.createApp({ // Response for the opener to begin WebRTC connection. const secret = msg.openSecret; console.log("OPEN: connect to %s with secret %s", msg.username, secret); - this.ChatClient(`onOpen called for ${msg.username}.`); + // this.ChatClient(`onOpen called for ${msg.username}.`); this.startWebRTC(msg.username, true); }, @@ -139,13 +240,13 @@ const app = Vue.createApp({ }, onUserExited(msg) { // A user has logged off the server. Clean up any WebRTC connections. - delete(this.WebRTC.streams[msg.username]); - delete(this.WebRTC.pc[msg.username]); + this.closeVideo(msg.username); }, // Handle messages sent in chat. onMessage(msg) { this.pushHistory({ + channel: msg.channel, username: msg.username, message: msg.message, }); @@ -189,7 +290,7 @@ const app = Vue.createApp({ switch (msg.action) { case "who": console.log("Got the Who List: %s", msg); - this.whoList = msg.whoList; + this.onWho(msg); break; case "me": console.log("Got a self-update: %s", msg); @@ -226,7 +327,7 @@ const app = Vue.createApp({ this.pushHistory({ username: msg.username || 'Internal Server Error', message: msg.message, - isChatServer: true, + isChatClient: true, }); default: console.error("Unexpected action: %s", JSON.stringify(msg)); @@ -242,7 +343,7 @@ const app = Vue.createApp({ // Start WebRTC with the other username. startWebRTC(username, isOfferer) { - this.ChatClient(`Begin WebRTC with ${username}.`); + // this.ChatClient(`Begin WebRTC with ${username}.`); let pc = new RTCPeerConnection(configuration); this.WebRTC.pc[username] = pc; @@ -271,7 +372,7 @@ const app = Vue.createApp({ // If the user is offerer let the 'negotiationneeded' event create the offer. if (isOfferer) { - this.ChatClient("We are the offerer - set up onNegotiationNeeded"); + // this.ChatClient("We are the offerer - set up onNegotiationNeeded"); pc.onnegotiationneeded = () => { console.error("WebRTC OnNegotiationNeeded called!"); this.ChatClient("Negotiation Needed, creating WebRTC offer."); @@ -281,19 +382,19 @@ const app = Vue.createApp({ // When a remote stream arrives. pc.ontrack = event => { - this.ChatServer("ON TRACK CALLED!!!"); + // this.ChatServer("ON TRACK CALLED!!!"); console.error("WebRTC OnTrack called!", event); const stream = event.streams[0]; // Do we already have it? - this.ChatClient(`Received a video stream from ${username}.`); + // this.ChatClient(`Received a video stream from ${username}.`); if (this.WebRTC.streams[username] == undefined || this.WebRTC.streams[username].id !== stream.id) { this.WebRTC.streams[username] = stream; } window.requestAnimationFrame(() => { - this.ChatServer("Setting