JWT Token-based Authentication
* Add support for JWT tokens to authenticate users from your external app. * JWT backed users can have profile pictures, profile URLs, and operator status (admin). Note that no operator features exist yet. * Add WelcomeMessages to settings.toml for default ChatServer messages to write to each public channel directed at a new user logging in. * Markdown support for chat messages!
This commit is contained in:
parent
8f60bdba0e
commit
1ecff195ac
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
settings.toml
|
118
README.md
118
README.md
|
@ -20,6 +20,7 @@ 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
|
||||
* See who all is looking at your camera right now, and kick them off.
|
||||
* Lots of UI cleanup.
|
||||
|
||||
# Configuration
|
||||
|
@ -27,41 +28,128 @@ Some important features it still needs:
|
|||
Work in progress. On first run it will create the settings.toml file for you:
|
||||
|
||||
```toml
|
||||
WebsiteURL = "http://localhost:8080"
|
||||
|
||||
[JWT]
|
||||
Enabled = false
|
||||
SecretKey = ""
|
||||
Enabled = true
|
||||
Strict = true
|
||||
SecretKey = "change me"
|
||||
|
||||
[[PublicChannels]]
|
||||
ID = "lobby"
|
||||
Name = "Lobby"
|
||||
Icon = "fa fa-gavel"
|
||||
WelcomeMessages = ["Welcome to the chat server!", "Please follow the basic rules:\n\n1. Have fun\n2. Be kind"]
|
||||
|
||||
[[PublicChannels]]
|
||||
ID = "offtopic"
|
||||
Name = "Off Topic"
|
||||
WelcomeMessages = ["Welcome to the Off Topic channel!"]
|
||||
```
|
||||
|
||||
A description of the config directives includes:
|
||||
|
||||
* **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.
|
||||
|
||||
# Authentication
|
||||
|
||||
BareRTC supports custom (user-defined) authentication with your app in the form
|
||||
of JSON Web Tokens (JWTs). Configure a shared Secret Key in the ChatRTC settings
|
||||
and have your app create a signed JWT with the same key and the following custom
|
||||
claims:
|
||||
BareRTC supports custom (user-defined) authentication with your app in the form of JSON Web Tokens (JWTs). JWTs will allow your existing app to handle authentication for users by signing a token that vouches for them, and the BareRTC app will trust your signed token.
|
||||
|
||||
```json
|
||||
The workflow is as follows:
|
||||
|
||||
1. Your existing app already has the user logged-in and you trust who they are. To get them into the chat room, your server signs a JWT token using a secret key that both it and BareRTC knows.
|
||||
2. Your server redirects the user to your BareRTC website sending the JWT token as a `jwt` parameter, either in the query string (GET) or POST request.
|
||||
* e.g. you send them to `https://chat.example.com/?jwt=TOKEN`
|
||||
* If the JWT token is too long to fit in a query string, you may create a `<form>` with `method="POST"` that posts the `jwt` as a form field.
|
||||
3. The BareRTC server will parse and validate the token using the shared Secret Key that only it and your back-end website knows.
|
||||
|
||||
There are JWT libraries available for most programming languages.
|
||||
|
||||
Configure a shared secret key (random text string) in both the BareRTC settings and in your app, and your app will sign a JWT including claims that look like the following (using signing method HS264):
|
||||
|
||||
```javascript
|
||||
// JSON Web Token "claims" expected by BareRTC
|
||||
{
|
||||
"username": "Soandso",
|
||||
"icon": "https://path/to/square/icon.png",
|
||||
"admin": false,
|
||||
// Custom claims
|
||||
"sub": "username", // Username for chat (standard JWT claim)
|
||||
"op": true, // User will have admin/operator permissions.
|
||||
"img": "/static/photos/username.jpg", // user picture URL
|
||||
"url": "/u/username", // user profile URL
|
||||
|
||||
// Standard JWT claims that we support:
|
||||
"iss": "my own app", // Issuer name
|
||||
"exp": 1675645084, // Expires at (time): 5 minutes out is plenty!
|
||||
"nbf": 1675644784, // Not Before (time)
|
||||
"iat": 1675644784, // Issued At (time)
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
An example how to sign your JWT tokens in Go (using [golang-jwt](https://github.com/golang-jwt/jwt)):
|
||||
|
||||
* 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.
|
||||
```golang
|
||||
import "github.com/golang-jwt/jwt/v4"
|
||||
|
||||
// JWT signing key - keep it a secret on your back-end shared between
|
||||
// your app and BareRTC, do not use it in front-end javascript code or
|
||||
// where a user can find it.
|
||||
const SECRET = "change me"
|
||||
|
||||
// Your custom JWT claims.
|
||||
type CustomClaims struct {
|
||||
// Custom claims used by BareRTC.
|
||||
Avatar string `json:"img"` // URI to user profile picture
|
||||
ProfileURL string `json:"url"` // URI to user's profile page
|
||||
IsAdmin bool `json:"op"` // give operator permission
|
||||
|
||||
// Standard JWT claims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Assuming your internal User struct looks anything at all like:
|
||||
type User struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
ProfilePicture string // like "/static/photos/username.jpg"
|
||||
}
|
||||
|
||||
// Create a JWT token for this user.
|
||||
func SignForUser(user User) string {
|
||||
claims := CustomClaims{
|
||||
// Custom claims
|
||||
ProfileURL: "/users/" + user.Username,
|
||||
Avatar: user.ProfilePicture,
|
||||
IsAdmin: user.IsAdmin,
|
||||
|
||||
// Standard claims
|
||||
Subject: user.Username, // their chat username!
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
IssuedAt: time.Now(),
|
||||
NotBefore: time.Now(),
|
||||
Issuer: "my own app",
|
||||
ID: user.ID,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString(SECRET)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return tokenstr
|
||||
}
|
||||
```
|
||||
|
||||
## JWT Strict Mode
|
||||
|
||||
You can enable JWT authentication in a mixed mode: users presenting a valid token will get a profile picture and operator status (if applicable) and users who don't have a JWT token are asked to pick their own username and don't get any special flair.
|
||||
|
||||
In strict mode (default/recommended), only a valid JWT token can sign a user into the chat room. Set `[JWT]/Strict=false` in your settings.toml to disable strict JWT verification and allow "guest users" to log in. Note that this can have the same caveats as [running without authentication](#running-without-authentication) and is not a recommended use case.
|
||||
|
||||
## Running Without Authentication
|
||||
|
||||
|
|
22
go.mod
22
go.mod
|
@ -2,14 +2,30 @@ module git.kirsle.net/apps/barertc
|
|||
|
||||
go 1.19
|
||||
|
||||
require git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
|
||||
require (
|
||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
|
||||
github.com/BurntSushi/toml v1.2.1
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.22
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
|
||||
nhooyr.io/websocket v1.8.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.10.3 // indirect
|
||||
github.com/russross/blackfriday v1.5.2 // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b // indirect
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||
golang.org/x/crypto v0.5.0 // indirect
|
||||
golang.org/x/net v0.5.0 // indirect
|
||||
golang.org/x/sys v0.4.0 // indirect
|
||||
golang.org/x/term v0.4.0 // indirect
|
||||
nhooyr.io/websocket v1.8.7 // indirect
|
||||
)
|
||||
|
|
33
go.sum
33
go.sum
|
@ -2,6 +2,8 @@ git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/Qg
|
|||
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
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=
|
||||
|
@ -13,19 +15,46 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
|
|||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
|
||||
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/microcosm-cc/bluemonday v1.0.22 h1:p2tT7RNzRdCi0qmwxG+HbqD6ILkmwter1ZwVZn1oTxA=
|
||||
github.com/microcosm-cc/bluemonday v1.0.22/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs=
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b h1:rBIwpb5ggtqf0uZZY5BPs1sL7njUMM7I8qD2jiou70E=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c h1:p3w+lTqXulfa3aDeycxmcLJDNxyUB89gf2/XqqK3eO0=
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
|
@ -35,6 +64,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
|
|||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
@ -45,7 +76,9 @@ golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxb
|
|||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
|
||||
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
|
||||
|
|
|
@ -14,9 +14,12 @@ import (
|
|||
type Config struct {
|
||||
JWT struct {
|
||||
Enabled bool
|
||||
Strict bool
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
WebsiteURL string
|
||||
|
||||
PublicChannels []Channel
|
||||
}
|
||||
|
||||
|
@ -31,6 +34,9 @@ type Channel struct {
|
|||
ID string // Like "lobby"
|
||||
Name string // Like "Main Chat Room"
|
||||
Icon string `toml:",omitempty"` // CSS class names for room icon (optional)
|
||||
|
||||
// ChatServer messages to send to the user immediately upon connecting.
|
||||
WelcomeMessages []string
|
||||
}
|
||||
|
||||
// Current loaded configuration.
|
||||
|
@ -40,18 +46,27 @@ var Current = DefaultConfig()
|
|||
// settings.toml file to disk.
|
||||
func DefaultConfig() Config {
|
||||
var c = Config{
|
||||
WebsiteURL: "https://www.example.com",
|
||||
PublicChannels: []Channel{
|
||||
{
|
||||
ID: "lobby",
|
||||
Name: "Lobby",
|
||||
Icon: "fa fa-gavel",
|
||||
WelcomeMessages: []string{
|
||||
"Welcome to the chat server!",
|
||||
"Please follow the basic rules:\n\n1. Have fun\n2. Be kind",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "offtopic",
|
||||
Name: "Off Topic",
|
||||
WelcomeMessages: []string{
|
||||
"Welcome to the Off Topic channel!",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
c.JWT.Strict = true
|
||||
return c
|
||||
}
|
||||
|
||||
|
|
|
@ -5,12 +5,45 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/config"
|
||||
"git.kirsle.net/apps/barertc/pkg/jwt"
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"git.kirsle.net/apps/barertc/pkg/util"
|
||||
)
|
||||
|
||||
// OnLogin handles "login" actions from the client.
|
||||
func (s *Server) OnLogin(sub *Subscriber, msg Message) {
|
||||
// Using a JWT token for authentication?
|
||||
var claims = &jwt.Claims{}
|
||||
if msg.JWTToken != "" || (config.Current.JWT.Enabled && config.Current.JWT.Strict) {
|
||||
parsed, ok, err := jwt.ParseAndValidate(msg.JWTToken)
|
||||
if err != nil {
|
||||
log.Error("Error parsing JWT token in WebSocket login: %s", err)
|
||||
sub.ChatServer("Your authentication has expired. Please go back and launch the chat room again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Sanity check the username.
|
||||
if msg.Username != parsed.Subject {
|
||||
log.Error("JWT login had a different username: %s vs %s", parsed.Subject, msg.Username)
|
||||
sub.ChatServer("Your authentication username did not match the expected username. Please go back and launch the chat room again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Strict enforcement?
|
||||
if config.Current.JWT.Strict && !ok {
|
||||
log.Error("JWT enforcement is strict and user did not pass JWT checks")
|
||||
sub.ChatServer("Server side authentication is required. Please go back and launch the chat room from your logged-in account.")
|
||||
return
|
||||
}
|
||||
|
||||
claims = parsed
|
||||
msg.Username = claims.Subject
|
||||
sub.JWTClaims = claims
|
||||
}
|
||||
|
||||
log.Info("JWT claims: %+v", claims)
|
||||
|
||||
// Ensure the username is unique, or rename it.
|
||||
var duplicate bool
|
||||
for _, other := range s.IterSubscribers() {
|
||||
|
@ -30,6 +63,7 @@ func (s *Server) OnLogin(sub *Subscriber, msg Message) {
|
|||
|
||||
// Use their username.
|
||||
sub.Username = msg.Username
|
||||
sub.authenticated = true
|
||||
log.Debug("OnLogin: %s joins the room", sub.Username)
|
||||
|
||||
// Tell everyone they joined.
|
||||
|
@ -44,6 +78,18 @@ func (s *Server) OnLogin(sub *Subscriber, msg Message) {
|
|||
|
||||
// Send the WhoList to everybody.
|
||||
s.SendWhoList()
|
||||
|
||||
// Send the initial ChatServer messages to the public channels.
|
||||
for _, channel := range config.Current.PublicChannels {
|
||||
for _, msg := range channel.WelcomeMessages {
|
||||
sub.SendJSON(Message{
|
||||
Channel: channel.ID,
|
||||
Action: ActionError,
|
||||
Username: "ChatServer",
|
||||
Message: RenderMarkdown(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnMessage handles a chat message posted by the user.
|
||||
|
@ -54,18 +100,23 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
|
|||
return
|
||||
}
|
||||
|
||||
// Translate their message as Markdown syntax.
|
||||
markdown := RenderMarkdown(msg.Message)
|
||||
if markdown == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Message to be echoed to the channel.
|
||||
var message = Message{
|
||||
Action: ActionMessage,
|
||||
Channel: msg.Channel,
|
||||
Username: sub.Username,
|
||||
Message: msg.Message,
|
||||
Message: markdown,
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
@ -73,12 +124,7 @@ func (s *Server) OnMessage(sub *Subscriber, msg Message) {
|
|||
}
|
||||
|
||||
// Broadcast a chat message to the room.
|
||||
s.Broadcast(Message{
|
||||
Action: ActionMessage,
|
||||
Channel: msg.Channel,
|
||||
Username: sub.Username,
|
||||
Message: msg.Message,
|
||||
})
|
||||
s.Broadcast(message)
|
||||
}
|
||||
|
||||
// OnMe handles current user state updates.
|
||||
|
|
54
pkg/jwt/jwt.go
Normal file
54
pkg/jwt/jwt.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package jwt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/config"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// Custom JWT Claims.
|
||||
type Claims struct {
|
||||
// Custom claims.
|
||||
IsAdmin bool `json:"op"`
|
||||
Avatar string `json:"img"`
|
||||
ProfileURL string `json:"url"`
|
||||
|
||||
// Standard claims. Notes:
|
||||
// subject = username
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ToJSON serializes the claims to JavaScript.
|
||||
func (c Claims) ToJSON() template.JS {
|
||||
data, _ := json.Marshal(c)
|
||||
return template.JS(data)
|
||||
}
|
||||
|
||||
// ParseAndValidate returns the Claims, a boolean authOK, and any errors.
|
||||
func ParseAndValidate(tokenStr string) (*Claims, bool, error) {
|
||||
// Handle a JWT authentication token.
|
||||
var (
|
||||
claims = &Claims{}
|
||||
authOK bool
|
||||
)
|
||||
if tokenStr != "" {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Current.JWT.SecretKey), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if parsed, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
claims = parsed
|
||||
authOK = true
|
||||
} else {
|
||||
return nil, false, errors.New("claims did not parse OK")
|
||||
}
|
||||
}
|
||||
|
||||
return claims, authOK, nil
|
||||
}
|
19
pkg/markdown.go
Normal file
19
pkg/markdown.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package barertc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/shurcooL/github_flavored_markdown"
|
||||
)
|
||||
|
||||
// Rendermarkdown from untrusted sources.
|
||||
func RenderMarkdown(input string) string {
|
||||
// Render Markdown to HTML.
|
||||
html := github_flavored_markdown.Markdown([]byte(input))
|
||||
|
||||
// Sanitize the HTML from any nasties.
|
||||
p := bluemonday.UGCPolicy()
|
||||
safened := p.SanitizeBytes(html)
|
||||
return strings.TrimSpace(string(safened))
|
||||
}
|
|
@ -6,6 +6,9 @@ type Message struct {
|
|||
Username string `json:"username,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// JWT token for `login` actions.
|
||||
JWTToken string `json:"jwt,omitempty"`
|
||||
|
||||
// WhoList for `who` actions
|
||||
WhoList []WhoList `json:"whoList,omitempty"`
|
||||
|
||||
|
@ -45,4 +48,9 @@ const (
|
|||
type WhoList struct {
|
||||
Username string `json:"username"`
|
||||
VideoActive bool `json:"videoActive"`
|
||||
|
||||
// JWT auth extra settings.
|
||||
Operator bool `json:"op"`
|
||||
Avatar string `json:"avatar"`
|
||||
ProfileURL string `json:"profileURL"`
|
||||
}
|
||||
|
|
36
pkg/pages.go
36
pkg/pages.go
|
@ -1,10 +1,12 @@
|
|||
package barertc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/config"
|
||||
"git.kirsle.net/apps/barertc/pkg/jwt"
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"git.kirsle.net/apps/barertc/pkg/util"
|
||||
)
|
||||
|
@ -15,6 +17,35 @@ func IndexPage() http.HandlerFunc {
|
|||
// Load the template, TODO: once on server startup.
|
||||
tmpl := template.New("index")
|
||||
|
||||
// Handle a JWT authentication token.
|
||||
var (
|
||||
tokenStr = r.FormValue("jwt")
|
||||
claims = &jwt.Claims{}
|
||||
authOK bool
|
||||
)
|
||||
if tokenStr != "" {
|
||||
parsed, ok, err := jwt.ParseAndValidate(tokenStr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf("Error parsing your JWT token: %s", err),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
authOK = ok
|
||||
claims = parsed
|
||||
}
|
||||
|
||||
// Are we enforcing strict JWT authentication?
|
||||
if config.Current.JWT.Enabled && config.Current.JWT.Strict && !authOK {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf("Authentication denied. Please go back and try again."),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Variables to give to the front-end page.
|
||||
var values = map[string]interface{}{
|
||||
// A cache-busting hash for JS and CSS includes.
|
||||
|
@ -22,6 +53,11 @@ func IndexPage() http.HandlerFunc {
|
|||
|
||||
// The current website settings.
|
||||
"Config": config.Current,
|
||||
|
||||
// Authentication settings.
|
||||
"JWTTokenString": tokenStr,
|
||||
"JWTAuthOK": authOK,
|
||||
"JWTClaims": claims,
|
||||
}
|
||||
|
||||
tmpl.Funcs(template.FuncMap{
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kirsle.net/apps/barertc/pkg/jwt"
|
||||
"git.kirsle.net/apps/barertc/pkg/log"
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
@ -16,14 +17,16 @@ import (
|
|||
// Subscriber represents a connected WebSocket session.
|
||||
type Subscriber struct {
|
||||
// User properties
|
||||
ID int // ID assigned by server
|
||||
Username string
|
||||
VideoActive bool
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
messages chan []byte
|
||||
closeSlow func()
|
||||
ID int // ID assigned by server
|
||||
Username string
|
||||
VideoActive bool
|
||||
JWTClaims *jwt.Claims
|
||||
authenticated bool // has passed the login step
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
messages chan []byte
|
||||
closeSlow func()
|
||||
}
|
||||
|
||||
// ReadLoop spawns a goroutine that reads from the websocket connection.
|
||||
|
@ -228,6 +231,10 @@ func (s *Server) Broadcast(msg Message) {
|
|||
s.subscribersMu.RLock()
|
||||
defer s.subscribersMu.RUnlock()
|
||||
for _, sub := range s.IterSubscribers(true) {
|
||||
if !sub.authenticated {
|
||||
continue
|
||||
}
|
||||
|
||||
sub.SendJSON(Message{
|
||||
Action: msg.Action,
|
||||
Channel: msg.Channel,
|
||||
|
@ -263,10 +270,20 @@ func (s *Server) SendWhoList() {
|
|||
)
|
||||
|
||||
for _, sub := range subscribers {
|
||||
users = append(users, WhoList{
|
||||
if !sub.authenticated {
|
||||
continue
|
||||
}
|
||||
|
||||
who := WhoList{
|
||||
Username: sub.Username,
|
||||
VideoActive: sub.VideoActive,
|
||||
})
|
||||
}
|
||||
if sub.JWTClaims != nil {
|
||||
who.Operator = sub.JWTClaims.IsAdmin
|
||||
who.Avatar = sub.JWTClaims.Avatar
|
||||
who.ProfileURL = sub.JWTClaims.ProfileURL
|
||||
}
|
||||
users = append(users, who)
|
||||
}
|
||||
|
||||
for _, sub := range subscribers {
|
||||
|
|
|
@ -9,6 +9,15 @@ body {
|
|||
float: right;
|
||||
}
|
||||
|
||||
.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Bulma override */
|
||||
.media-content {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/************************
|
||||
* Main CSS Grid Layout *
|
||||
************************/
|
||||
|
@ -25,7 +34,7 @@ body {
|
|||
display: grid;
|
||||
column-gap: 10px;
|
||||
row-gap: 10px;
|
||||
grid-template-columns: 260px 1fr 260px;
|
||||
grid-template-columns: 260px 1fr 280px;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
|
|
BIN
web/static/img/client.png
Normal file
BIN
web/static/img/client.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.5 KiB |
BIN
web/static/img/server.png
Normal file
BIN
web/static/img/server.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.9 KiB |
BIN
web/static/img/shy.png
Normal file
BIN
web/static/img/shy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
|
@ -17,6 +17,14 @@ const app = Vue.createApp({
|
|||
// Website configuration provided by chat.html template.
|
||||
config: {
|
||||
channels: PublicChannels,
|
||||
website: WebsiteURL,
|
||||
},
|
||||
|
||||
// User JWT settings if available.
|
||||
jwt: {
|
||||
token: UserJWTToken,
|
||||
valid: UserJWTValid,
|
||||
claims: UserJWTClaims
|
||||
},
|
||||
|
||||
channel: "lobby",
|
||||
|
@ -31,6 +39,7 @@ const app = Vue.createApp({
|
|||
|
||||
// Who List for the room.
|
||||
whoList: [],
|
||||
whoMap: {}, // map username to wholist entry
|
||||
|
||||
// My video feed.
|
||||
webcam: {
|
||||
|
@ -106,6 +115,15 @@ const app = Vue.createApp({
|
|||
|
||||
this.ChatServer("Welcome to BareRTC!")
|
||||
|
||||
// Auto login with JWT token?
|
||||
// TODO: JWT validation on the WebSocket as well.
|
||||
if (this.jwt.valid && this.jwt.claims.sub) {
|
||||
this.username = this.jwt.claims.sub;
|
||||
}
|
||||
|
||||
// Scrub JWT token from query string parameters.
|
||||
history.pushState(null, "", location.href.split("?")[0]);
|
||||
|
||||
if (!this.username) {
|
||||
this.loginModal.visible = true;
|
||||
} else {
|
||||
|
@ -151,6 +169,10 @@ const app = Vue.createApp({
|
|||
|
||||
return this.channel;
|
||||
},
|
||||
isDM() {
|
||||
// Is the current channel a DM?
|
||||
return this.channel.indexOf("@") === 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
signIn() {
|
||||
|
@ -204,10 +226,12 @@ const app = Vue.createApp({
|
|||
// WhoList updates.
|
||||
onWho(msg) {
|
||||
this.whoList = msg.whoList;
|
||||
this.whoMap = {};
|
||||
|
||||
// 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) {
|
||||
this.whoMap[row.username] = row;
|
||||
if (this.WebRTC.streams[row.username] != undefined &&
|
||||
row.videoActive !== true) {
|
||||
this.closeVideo(row.username);
|
||||
|
@ -252,6 +276,25 @@ const app = Vue.createApp({
|
|||
});
|
||||
},
|
||||
|
||||
// User logged in or out.
|
||||
onPresence(msg) {
|
||||
// TODO: make a dedicated leave event
|
||||
if (msg.message.indexOf("has exited the room!") > -1) {
|
||||
// Clean up data about this user.
|
||||
this.onUserExited(msg);
|
||||
}
|
||||
|
||||
// Push it to the history of all public channels.
|
||||
for (let channel of this.config.channels) {
|
||||
this.pushHistory({
|
||||
channel: channel.ID,
|
||||
action: msg.action,
|
||||
username: msg.username,
|
||||
message: msg.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Dial the WebSocket connection.
|
||||
dial() {
|
||||
console.log("Dialing WebSocket...");
|
||||
|
@ -259,6 +302,9 @@ const app = Vue.createApp({
|
|||
const conn = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
|
||||
conn.addEventListener("close", ev => {
|
||||
// Lost connection to server - scrub who list.
|
||||
this.onWho({whoList: []});
|
||||
|
||||
this.ws.connected = false;
|
||||
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
|
||||
|
||||
|
@ -276,6 +322,7 @@ const app = Vue.createApp({
|
|||
this.ws.conn.send(JSON.stringify({
|
||||
action: "login",
|
||||
username: this.username,
|
||||
jwt: this.jwt.token,
|
||||
}));
|
||||
});
|
||||
|
||||
|
@ -300,16 +347,7 @@ const app = Vue.createApp({
|
|||
this.onMessage(msg);
|
||||
break;
|
||||
case "presence":
|
||||
// TODO: make a dedicated leave event
|
||||
if (msg.message.indexOf("has exited the room!") > -1) {
|
||||
// Clean up data about this user.
|
||||
this.onUserExited(msg);
|
||||
}
|
||||
this.pushHistory({
|
||||
action: msg.action,
|
||||
username: msg.username,
|
||||
message: msg.message,
|
||||
});
|
||||
this.onPresence(msg);
|
||||
break;
|
||||
case "ring":
|
||||
this.onRing(msg);
|
||||
|
@ -325,9 +363,10 @@ const app = Vue.createApp({
|
|||
break;
|
||||
case "error":
|
||||
this.pushHistory({
|
||||
channel: msg.channel,
|
||||
username: msg.username || 'Internal Server Error',
|
||||
message: msg.message,
|
||||
isChatClient: true,
|
||||
isChatServer: true,
|
||||
});
|
||||
default:
|
||||
console.error("Unexpected action: %s", JSON.stringify(msg));
|
||||
|
@ -506,6 +545,9 @@ const app = Vue.createApp({
|
|||
this.channel = typeof(channel) === "string" ? channel : channel.ID;
|
||||
this.scrollHistory();
|
||||
this.channels[this.channel].unread = 0;
|
||||
|
||||
// Responsive CSS: switch back to chat panel upon selecting a channel.
|
||||
this.openChatPanel();
|
||||
},
|
||||
hasUnread(channel) {
|
||||
if (this.channels[channel] == undefined) {
|
||||
|
@ -517,6 +559,47 @@ const app = Vue.createApp({
|
|||
let channel = "@" + user.username;
|
||||
this.initHistory(channel);
|
||||
this.setChannel(channel);
|
||||
|
||||
// Responsive CSS: switch back to chat panel upon opening a DM.
|
||||
this.openChatPanel();
|
||||
},
|
||||
openProfile(user) {
|
||||
let url = this.profileURLForUsername(user.username);
|
||||
if (url) {
|
||||
window.open(url);
|
||||
}
|
||||
},
|
||||
avatarURL(user) {
|
||||
// Resolve the avatar URL of this user.
|
||||
if (user.avatar.match(/^https?:/i)) {
|
||||
return user.avatar;
|
||||
} else if (user.avatar.indexOf("/") === 0) {
|
||||
return this.config.website.replace(/\/+$/, "") + user.avatar;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
avatarForUsername(username) {
|
||||
if (this.whoMap[username] != undefined && this.whoMap[username].avatar) {
|
||||
return this.avatarURL(this.whoMap[username]);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
profileURLForUsername(username) {
|
||||
if (!username) return;
|
||||
username = username.replace(/^@/, "");
|
||||
if (this.whoMap[username] != undefined && this.whoMap[username].profileURL) {
|
||||
let url = this.whoMap[username].profileURL;
|
||||
if (url.match(/^https?:/i)) {
|
||||
return url;
|
||||
} else if (url.indexOf("/") === 0) {
|
||||
// Subdirectory relative to our WebsiteURL
|
||||
return this.config.website.replace(/\/+$/, "") + url;
|
||||
} else {
|
||||
this.ChatClient("Didn't know how to open profile URL: " + url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
leaveDM() {
|
||||
// Validate we're in a DM currently.
|
||||
|
@ -675,7 +758,10 @@ const app = Vue.createApp({
|
|||
|
||||
// Mark unread notifiers if this is not our channel.
|
||||
if (this.channel !== channel) {
|
||||
this.channels[channel].unread++;
|
||||
// Don't notify about presence broadcasts.
|
||||
if (action !== "presence") {
|
||||
this.channels[channel].unread++;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -111,25 +111,39 @@
|
|||
<div class="card grid-card">
|
||||
<header class="card-header has-background-link">
|
||||
<div class="columns is-mobile card-header-title has-text-light">
|
||||
<div class="column is-narrow mobile-only">
|
||||
<div class="column is-narrow mobile-only pr-0">
|
||||
<!-- Responsive mobile button to pan to Left Column -->
|
||||
<button type="button"
|
||||
class="button is-success"
|
||||
:class="{'is-small': isDM}"
|
||||
@click="openChannelsPanel">
|
||||
<i class="fa fa-message"></i>
|
||||
<i v-if="isDM" class="fa fa-arrow-left"></i>
|
||||
<i v-else class="fa fa-message"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column">[[channelName]]</div>
|
||||
<div class="column">
|
||||
[[channelName]]
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<!-- If a DM thread and the user has a profile URL -->
|
||||
<button type="button"
|
||||
v-if="this.channel.indexOf('@') === 0 && profileURLForUsername(this.channel)"
|
||||
class="button is-small is-outlined is-light mr-1"
|
||||
@click="openProfile({username: this.channel})">
|
||||
<i class="fa fa-user"></i>
|
||||
</button>
|
||||
|
||||
<!-- DMs: Leave convo button -->
|
||||
<button type="button"
|
||||
v-if="channel.indexOf('@') === 0"
|
||||
class="float-right button is-small is-warning is-outline"
|
||||
class="float-right button is-small is-warning is-outlined"
|
||||
@click="leaveDM()">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-narrow mobile-only">
|
||||
|
||||
<!-- Who List button, only shown on public channel view -->
|
||||
<div v-if="!isDM" class="column is-narrow mobile-only">
|
||||
<!-- Responsive mobile button to pan to Right Column -->
|
||||
<button type="button"
|
||||
class="button is-success"
|
||||
|
@ -155,21 +169,59 @@
|
|||
</div>
|
||||
<div class="card-content" id="chatHistory">
|
||||
|
||||
<div v-for="(msg, i) in chatHistory" v-bind:key="i">
|
||||
<div>
|
||||
<label class="label"
|
||||
:class="{'has-text-success is-dark': msg.isChatServer,
|
||||
'has-text-warning is-dark': msg.isAdmin,
|
||||
'has-text-danger': msg.isChatClient}">
|
||||
[[msg.username]]
|
||||
</label>
|
||||
<!-- No history? -->
|
||||
<div v-if="chatHistory.length === 0">
|
||||
<em v-if="isDM">
|
||||
Starting a direct message chat with [[channel]]. Type a message and say hello!
|
||||
</em>
|
||||
<em v-else>
|
||||
There are no messages in this channel yet.
|
||||
</em>
|
||||
</div>
|
||||
|
||||
<div v-for="(msg, i) in chatHistory" v-bind:key="i" class="mb-2">
|
||||
<div class="media mb-0">
|
||||
<div class="media-left">
|
||||
<a :href="profileURLForUsername(msg.username)" @click.prevent="openProfile({username: msg.username})"
|
||||
:class="{'cursor-default': !profileURLForUsername(msg.username)}">
|
||||
<figure class="image is-24x24">
|
||||
<img v-if="msg.isChatServer"
|
||||
src="/static/img/server.png">
|
||||
<img v-else-if="msg.isChatClient"
|
||||
src="/static/img/client.png">
|
||||
<img v-else-if="avatarForUsername(msg.username)"
|
||||
:src="avatarForUsername(msg.username)">
|
||||
<img v-else src="/static/img/shy.png">
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<label class="label"
|
||||
:class="{'has-text-success is-dark': msg.isChatServer,
|
||||
'has-text-warning is-dark': msg.isAdmin,
|
||||
'has-text-danger': msg.isChatClient}">
|
||||
[[msg.username]]
|
||||
</label>
|
||||
</div>
|
||||
<div class="column is-narrow"
|
||||
v-if="!(msg.isChatServer || msg.isChatClient || msg.username === username)">
|
||||
<button type="button"
|
||||
class="button is-dark is-outlined is-small"
|
||||
@click="openDMs({username: msg.username})">
|
||||
<i class="fa fa-message"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="msg.action === 'presence'">
|
||||
<em>[[msg.message]]</em>
|
||||
</div>
|
||||
<div v-else>
|
||||
[[msg.message]]
|
||||
<div v-else class="content">
|
||||
<div v-html="msg.message"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -234,17 +286,43 @@
|
|||
<ul class="menu-list">
|
||||
<li v-for="(u, i) in whoList" v-bind:key="i">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">[[ u.username ]]</div>
|
||||
<div class="column is-narrow">
|
||||
<!-- Avatar URL if available -->
|
||||
<div class="column is-narrow pr-0"
|
||||
v-if="u.avatar">
|
||||
<img :src="avatarURL(u)"
|
||||
width="24" height="24"
|
||||
:alt="'Avatar image for ' + u.username">
|
||||
</div>
|
||||
<div class="column pr-0"
|
||||
:class="{'pl-1': u.avatar}">
|
||||
<i class="fa fa-gavel has-text-warning-dark"
|
||||
v-if="u.op"
|
||||
title="Operator"></i>
|
||||
[[ u.username ]]
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<!-- Profile button -->
|
||||
<button type="button"
|
||||
v-if="u.profileURL"
|
||||
class="button is-small px-2 py-1"
|
||||
@click="openProfile(u)"
|
||||
title="Open profile page">
|
||||
<i class="fa fa-user"></i>
|
||||
</button>
|
||||
|
||||
<!-- DM button -->
|
||||
<button type="button"
|
||||
class="button is-small px-2 py-1"
|
||||
@click="openDMs(u)"
|
||||
title="Start direct message thread"
|
||||
:disabled="u.username === username">
|
||||
<i class="fa fa-message"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="button is-small"
|
||||
<!-- Video button -->
|
||||
<button type="button" class="button is-small px-2 py-1"
|
||||
:disabled="!u.videoActive"
|
||||
title="Open video stream"
|
||||
@click="openVideo(u)">
|
||||
<i class="fa fa-video"></i>
|
||||
</button>
|
||||
|
@ -262,6 +340,10 @@
|
|||
|
||||
<script type="text/javascript">
|
||||
const PublicChannels = {{.Config.GetChannels}};
|
||||
const WebsiteURL = "{{.Config.WebsiteURL}}";
|
||||
const UserJWTToken = {{.JWTTokenString}};
|
||||
const UserJWTValid = {{if .JWTAuthOK}}true{{else}}false{{end}};
|
||||
const UserJWTClaims = {{.JWTClaims.ToJSON}};
|
||||
</script>
|
||||
|
||||
<script src="/static/js/vue-3.2.45.js"></script>
|
||||
|
|
Loading…
Reference in New Issue
Block a user