Chat refactor + persistent backing chat users (#1163)

* First pass at chat user registration and validation

* Disable chat if the user is disabled/blocked or the server hits max connections

* Handle dropping sockets if chat is disabled

* Fix origin in automated chat test

* Work for updated chat moderation

* Chat message markdown rendering and fix tests

* Put /api/chat behind a chat user access token. Closes #1085

* Reject blocked username changes

* More WIP moderation

* Defer configuring chat until we know if it is enabled. Closes #1135

* chat user blocking. Closes #1096

* Add tests around user access for #1096

* Add external integration chat message API + update integration auth middleware to pass along integration name. Closes #1092

* Delete old chat messages from db as to not hold on to excessive data. Closes #1152

* Add schema migration for messages. Closes #1155

* Commit updated API documentation

* Add chat load test

* Shared db mutex and db optimizations

* Simplify past display name handling

* Use a new test db for each test run

* Wire up the external messages actions + add tests for them

* Move access tokens to be actual users

* Run message pruning at launch + fix comparison

* Do not return API users in disabled users response

* Fix incorrect highlighting. Closes #1160

* Consolidate user table statements

* Set the max process connection limit to 70% of maximum

* Fix wrong old display name being returned in name change event

* Delete the old chat server files

* Wire back up the webhooks

* Remove unused

* Invalidate user cache on changes

* Do not send rendered body as RawBody

* Some cleanup

* Standardize names for external API users to ExternalAPIUser

* Do not log token

* Checkout branch when building admin for testing

* Bundle in dev admin for testing

* Some cleanup

* Cleanup js logs

* Cleanup and standardize event names

* Clean up some logging

* Update API spec. Closes #1133

* Commit updated API documentation

* Change paths to be better named

* Commit updated API documentation

* Update admin bundle

* Fix duplicate event name

* Rename scope var

* Update admin bundle

* Move connected clients controller into admin package

* Fix collecting usernames for autocomplete purposes

* No longer generate username when it is empty

* Sort clients and users by timestamp

* Move file to admin controller package

* Swap, so the comments stay correct

Co-authored-by: Jannik <jannik@outlook.com>

* Use explicit type alias

Co-authored-by: Jannik <jannik@outlook.com>

* Remove commented code.

Co-authored-by: Jannik <jannik@outlook.com>

* Cleanup test

* Remove some extra logging

* Add some clarity

* Update dev instance of admin for testing

* Consolidate lines

Co-authored-by: Jannik <jannik@outlook.com>

* Remove commented unused vars

Co-authored-by: Jannik <jannik@outlook.com>

* Until needed do not return IP address with client list

* Fix typo of wrong var

* Typo led to a bad test. Fix typo and fix test.

* Guard against the socket reconnecting on error if previously set to shutdown

* Do not log access tokens

* Return success message on enable/disable user

* Clean up some inactionable error messages. Sent ban message. Sort banned users.

* fix styling for when chat is completely disabled

* Unused

* guard against nil clients

* Update dev admin bundle

* Do not unhide messages when unblocking user just to be safe. Send removal action from the controller

* Add convinience function for getting active connections for a single user

* Lock db on these mutations

* Cleanup force disconnect using GetClientsForUser and capture client reference explicitly

* No longer re-showing banned user messages for safety. Removing this test.

* Remove no longer needed comment

* Tweaks to forbidden username handling.

- Standardize naming to not use "block" but "forbidden" instead.
- Pass array over the wire instead of string.
- Add API test
- Fix default list incorrectly being appended to custom list.

* Logging cleanup

* Update dev admin bundle

* Add an artificial delay in order to visually see message being hidden when testing

* Remove the user cache as it is a premature optimization

* When connected to chat let the user know their current user details to sync the username in the UI

* On connected send current display name back to client.
- Move name change out of chat component.
- Add additional event type constants.

* Fix broken workflow due to typo

* Troubleshoot workflow

* Bump htm from 3.0.4 to 3.1.0 in /build/javascript (#1181)

* Bump htm from 3.0.4 to 3.1.0 in /build/javascript

Bumps [htm](https://github.com/developit/htm) from 3.0.4 to 3.1.0.
- [Release notes](https://github.com/developit/htm/releases)
- [Commits](https://github.com/developit/htm/compare/3.0.4...3.1.0)

---
updated-dependencies:
- dependency-name: htm
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Run npm run build and update libraries

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>

* Commit updated Javascript packages

* Re-send current user info when a rejected name change takes place

* All socket writes should be through the send chan and not directly

* Seed the random generator

* Add keys and indexes to users table

* a util to generate consistent emoji markup

* console clean up

* mod tidy

* Commit updated API documentation

* Handle the max payload size of a socket message.
- Only close socket if x2 greater than the max size.
- Send the user a message if a message is too large.
- Surface the max size in bytes in the config.

* Update admin bundle

* Force all events to be sent in their own socket message and do not concatinate in a single message

* Update chat embed to register for access token

* Use a different access token for embed chat

* Update the chat message bubble background color to be bolder

* add base tag to open links in new window, closes #1220

* Support text input of :emoji: in chat (#1190)

* Initial implementation of emoji injection

* fix bookkeeping with multiple emoji

* make the emoji lookup case-insensitive

* try another solution for Caretposition

* add title to emojis

minor refactoring

* bind moji injection to InputKeyUp

* simplify the code

replace all found emojis

* inject emoji if the modifer is released earlier

* more efficient emoji tag search

* use json emoji.emoji as url

* use createEmojiMarkup()

* move emojify() to chat.js

* emojify on paste

* cleanup emoji titles in paste

* update inputText in InputKeyup

* mark emoji titles with 2*zwnj

this way paste cleanup will not interfere with text which include zwnj

* emoji should not change the inputText

* Do not show join messages when chat is offline. Closes #1224
- Show stream starting/ending messages in chat.
- When stream starts show everyone the welcome message.

* Force scrolling chat to bottom after history is populated regardless of scroll position. Closes https://github.com/owncast/owncast/issues/1222

* use maxSocketPayloadSize to calculate total bytes of message payload (#1221)

* utilize maxSocketPayloadSize from config; update chatInput to calculate based on that value instead of text value; remove usage of inputText for counting

* add a buffer to account for entire websocket payload for message char counting; trim nbsp;'s from ends of messages when calculating count

Co-authored-by: Gabe Kangas <gabek@real-ity.com>

Co-authored-by: Owncast <owncast@owncast.online>
Co-authored-by: Jannik <jannik@outlook.com>
Co-authored-by: Ginger Wong <omqmail@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Meisam <39205857+MFTabriz@users.noreply.github.com>
This commit is contained in:
Gabe Kangas
2021-07-19 19:22:29 -07:00
committed by GitHub
parent e3dc736cf4
commit b6f68628c0
88 changed files with 10691 additions and 2281 deletions

View File

@@ -18,6 +18,7 @@ trap shutdown INT TERM ABRT EXIT
echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..." echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..."
git clone https://github.com/owncast/owncast-admin 2> /dev/null git clone https://github.com/owncast/owncast-admin 2> /dev/null
cd owncast-admin cd owncast-admin
git checkout gek/chat-user-refactor
echo "Installing npm modules for the owncast admin..." echo "Installing npm modules for the owncast admin..."
npm --silent install 2> /dev/null npm --silent install 2> /dev/null

View File

@@ -42,6 +42,13 @@ func GetCommit() string {
return GitCommit return GitCommit
} }
var DefaultForbiddenUsernames = []string{
"owncast", "operator", "admin", "system",
}
// The maximum payload we will allow to to be received via the chat socket.
const MaxSocketPayloadSize = 2048
// GetReleaseString gets the version string. // GetReleaseString gets the version string.
func GetReleaseString() string { func GetReleaseString() string {
var versionNumber = VersionNumber var versionNumber = VersionNumber

View File

@@ -9,16 +9,24 @@ import (
"net/http" "net/http"
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
) )
// ExternalUpdateMessageVisibility updates an array of message IDs to have the same visiblity.
func ExternalUpdateMessageVisibility(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
UpdateMessageVisibility(w, r)
}
// UpdateMessageVisibility updates an array of message IDs to have the same visiblity. // UpdateMessageVisibility updates an array of message IDs to have the same visiblity.
func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) { func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
type messageVisibilityUpdateRequest struct {
IDArray []string `json:"idArray"`
Visible bool `json:"visible"`
}
if r.Method != controllers.POST { if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported") controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
return return
@@ -27,8 +35,7 @@ func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
var request messageVisibilityUpdateRequest var request messageVisibilityUpdateRequest
err := decoder.Decode(&request) if err := decoder.Decode(&request); err != nil {
if err != nil {
log.Errorln(err) log.Errorln(err)
controllers.WriteSimpleResponse(w, false, "") controllers.WriteSimpleResponse(w, false, "")
return return
@@ -42,103 +49,142 @@ func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "changed") controllers.WriteSimpleResponse(w, true, "changed")
} }
type messageVisibilityUpdateRequest struct { func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) {
IDArray []string `json:"idArray"` type blockUserRequest struct {
Visible bool `json:"visible"` UserID string `json:"userId"`
Enabled bool `json:"enabled"`
}
if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
return
}
decoder := json.NewDecoder(r.Body)
var request blockUserRequest
if err := decoder.Decode(&request); err != nil {
log.Errorln(err)
controllers.WriteSimpleResponse(w, false, "")
return
}
// Disable/enable the user
if err := user.SetEnabled(request.UserID, request.Enabled); err != nil {
log.Errorln("error changing user enabled status", err)
}
// Hide/show the user's chat messages if disabling.
// Leave hidden messages hidden to be safe.
if !request.Enabled {
if err := chat.SetMessageVisibilityForUserId(request.UserID, request.Enabled); err != nil {
log.Errorln("error changing user messages visibility", err)
}
}
// Forcefully disconnect the user from the chat
if !request.Enabled {
chat.DisconnectUser(request.UserID)
disconnectedUser := user.GetUserById(request.UserID)
_ = chat.SendSystemAction(fmt.Sprintf("**%s** has been removed from chat.", disconnectedUser.DisplayName), true)
}
controllers.WriteSimpleResponse(w, true, fmt.Sprintf("%s enabled: %t", request.UserID, request.Enabled))
}
func GetDisabledUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
users := user.GetDisabledUsers()
controllers.WriteResponse(w, users)
} }
// GetChatMessages returns all of the chat messages, unfiltered. // GetChatMessages returns all of the chat messages, unfiltered.
func GetChatMessages(w http.ResponseWriter, r *http.Request) { func GetChatMessages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
messages := core.GetModerationChatMessages() messages := chat.GetChatModerationHistory()
controllers.WriteResponse(w, messages)
if err := json.NewEncoder(w).Encode(messages); err != nil {
log.Errorln(err)
}
} }
// SendSystemMessage will send an official "SYSTEM" message to chat on behalf of your server. // SendSystemMessage will send an official "SYSTEM" message to chat on behalf of your server.
func SendSystemMessage(w http.ResponseWriter, r *http.Request) { func SendSystemMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
var message models.ChatEvent var message events.SystemMessageEvent
if err := json.NewDecoder(r.Body).Decode(&message); err != nil { if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
controllers.InternalErrorHandler(w, err) controllers.InternalErrorHandler(w, err)
return return
} }
message.MessageType = models.SystemMessageSent if err := chat.SendSystemMessage(message.Body, false); err != nil {
message.Author = data.GetServerName()
message.ClientID = "owncast-server"
message.ID = shortid.MustGenerate()
message.Visible = true
message.SetDefaults()
message.RenderBody()
if err := core.SendMessageToChat(message); err != nil {
controllers.BadRequestHandler(w, err) controllers.BadRequestHandler(w, err)
return
} }
controllers.WriteSimpleResponse(w, true, "sent") controllers.WriteSimpleResponse(w, true, "sent")
} }
// SendUserMessage will send a message to chat on behalf of a user. // SendUserMessage will send a message to chat on behalf of a user. *Depreciated*.
func SendUserMessage(w http.ResponseWriter, r *http.Request) { func SendUserMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
controllers.BadRequestHandler(w, errors.New("no longer supported. see /api/integrations/chat/send"))
}
func SendIntegrationChatMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
var message models.ChatEvent name := integration.DisplayName
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
if name == "" {
controllers.BadRequestHandler(w, errors.New("unknown integration for provided access token"))
return
}
var event events.UserMessageEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
controllers.InternalErrorHandler(w, err) controllers.InternalErrorHandler(w, err)
return return
} }
event.SetDefaults()
event.RenderBody()
event.Type = "CHAT"
if !message.Valid() { if event.Empty() {
controllers.BadRequestHandler(w, errors.New("invalid chat message; id, author, and body are required")) controllers.BadRequestHandler(w, errors.New("invalid message"))
return return
} }
message.MessageType = models.MessageSent event.User = &user.User{
message.ClientID = "external-request" Id: integration.Id,
message.ID = shortid.MustGenerate() DisplayName: name,
message.Visible = true DisplayColor: integration.DisplayColor,
CreatedAt: integration.CreatedAt,
}
message.SetDefaults() if err := chat.Broadcast(&event); err != nil {
message.RenderAndSanitizeMessageBody()
if err := core.SendMessageToChat(message); err != nil {
controllers.BadRequestHandler(w, err) controllers.BadRequestHandler(w, err)
return return
} }
chat.SaveUserMessage(event)
controllers.WriteSimpleResponse(w, true, "sent") controllers.WriteSimpleResponse(w, true, "sent")
} }
// SendChatAction will send a generic chat action. // SendChatAction will send a generic chat action.
func SendChatAction(w http.ResponseWriter, r *http.Request) { func SendChatAction(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
var message models.ChatEvent var message events.SystemActionEvent
if err := json.NewDecoder(r.Body).Decode(&message); err != nil { if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
controllers.InternalErrorHandler(w, err) controllers.InternalErrorHandler(w, err)
return return
} }
message.MessageType = models.ChatActionSent
message.ClientID = "external-request"
message.ID = shortid.MustGenerate()
message.Visible = true
if message.Author != "" {
message.Body = fmt.Sprintf("%s %s", message.Author, message.Body)
}
message.SetDefaults() message.SetDefaults()
message.RenderAndSanitizeMessageBody() message.RenderBody()
if err := core.SendMessageToChat(message); err != nil { if err := chat.SendSystemAction(message.Body, false); err != nil {
controllers.BadRequestHandler(w, err) controllers.BadRequestHandler(w, err)
return return
} }

View File

@@ -12,8 +12,9 @@ import (
"strings" "strings"
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -71,17 +72,12 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "changed") controllers.WriteSimpleResponse(w, true, "changed")
} }
func ExternalSetStreamTitle(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
SetStreamTitle(w, r)
}
func sendSystemChatAction(messageText string, ephemeral bool) { func sendSystemChatAction(messageText string, ephemeral bool) {
message := models.ChatEvent{} if err := chat.SendSystemAction(messageText, ephemeral); err != nil {
message.Body = messageText
message.MessageType = models.ChatActionSent
message.ClientID = "internal-server"
message.Ephemeral = ephemeral
message.SetDefaults()
message.RenderBody()
if err := core.SendMessageToChat(message); err != nil {
log.Errorln(err) log.Errorln(err)
} }
} }
@@ -576,17 +572,24 @@ func SetCustomStyles(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "custom styles updated") controllers.WriteSimpleResponse(w, true, "custom styles updated")
} }
// SetUsernameBlocklist will set the list of usernames we do not allow to use. // SetForbiddenUsernameList will set the list of usernames we do not allow to use.
func SetUsernameBlocklist(w http.ResponseWriter, r *http.Request) { func SetForbiddenUsernameList(w http.ResponseWriter, r *http.Request) {
usernames, success := getValueFromRequest(w, r) type forbiddenUsernameListRequest struct {
if !success { Value []string `json:"value"`
controllers.WriteSimpleResponse(w, false, "unable to update chat username blocklist") }
decoder := json.NewDecoder(r.Body)
var request forbiddenUsernameListRequest
if err := decoder.Decode(&request); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update forbidden usernames with provided values")
return return
} }
data.SetUsernameBlocklist(usernames.Value.(string)) if err := data.SetForbiddenUsernameList(request.Value); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
}
controllers.WriteSimpleResponse(w, true, "blocklist updated") controllers.WriteSimpleResponse(w, true, "forbidden username list updated")
} }
func requirePOST(w http.ResponseWriter, r *http.Request) bool { func requirePOST(w http.ResponseWriter, r *http.Request) bool {

View File

@@ -0,0 +1,25 @@
package admin
import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/user"
)
// GetConnectedClients returns currently connected clients.
func GetConnectedClients(w http.ResponseWriter, r *http.Request) {
clients := chat.GetClients()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(clients); err != nil {
controllers.InternalErrorHandler(w, err)
}
}
// ExternalGetConnectedClients returns currently connected clients.
func ExternalGetConnectedClients(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
GetConnectedClients(w, r)
}

View File

@@ -7,31 +7,30 @@ import (
"time" "time"
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
) )
type deleteTokenRequest struct { type deleteExternalAPIUserRequest struct {
Token string `json:"token"` Token string `json:"token"`
} }
type createTokenRequest struct { type createExternalAPIUserRequest struct {
Name string `json:"name"` Name string `json:"name"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
} }
// CreateAccessToken will generate a 3rd party access token. // CreateExternalAPIUser will generate a 3rd party access token.
func CreateAccessToken(w http.ResponseWriter, r *http.Request) { func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
var request createTokenRequest var request createExternalAPIUserRequest
if err := decoder.Decode(&request); err != nil { if err := decoder.Decode(&request); err != nil {
controllers.BadRequestHandler(w, err) controllers.BadRequestHandler(w, err)
return return
} }
// Verify all the scopes provided are valid // Verify all the scopes provided are valid
if !models.HasValidScopes(request.Scopes) { if !user.HasValidScopes(request.Scopes) {
controllers.BadRequestHandler(w, errors.New("one or more invalid scopes provided")) controllers.BadRequestHandler(w, errors.New("one or more invalid scopes provided"))
return return
} }
@@ -42,26 +41,29 @@ func CreateAccessToken(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := data.InsertToken(token, request.Name, request.Scopes); err != nil { color := utils.GenerateRandomDisplayColor()
if err := user.InsertExternalAPIUser(token, request.Name, color, request.Scopes); err != nil {
controllers.InternalErrorHandler(w, err) controllers.InternalErrorHandler(w, err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
controllers.WriteResponse(w, models.AccessToken{ controllers.WriteResponse(w, user.ExternalAPIUser{
Token: token, AccessToken: token,
Name: request.Name, DisplayName: request.Name,
Scopes: request.Scopes, DisplayColor: color,
Timestamp: time.Now(), Scopes: request.Scopes,
LastUsed: nil, CreatedAt: time.Now(),
LastUsedAt: nil,
}) })
} }
// GetAccessTokens will return all 3rd party access tokens. // GetExternalAPIUsers will return all 3rd party access tokens.
func GetAccessTokens(w http.ResponseWriter, r *http.Request) { func GetExternalAPIUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
tokens, err := data.GetAccessTokens() tokens, err := user.GetExternalAPIUser()
if err != nil { if err != nil {
controllers.InternalErrorHandler(w, err) controllers.InternalErrorHandler(w, err)
return return
@@ -70,8 +72,8 @@ func GetAccessTokens(w http.ResponseWriter, r *http.Request) {
controllers.WriteResponse(w, tokens) controllers.WriteResponse(w, tokens)
} }
// DeleteAccessToken will return a single 3rd party access token. // DeleteExternalAPIUser will return a single 3rd party access token.
func DeleteAccessToken(w http.ResponseWriter, r *http.Request) { func DeleteExternalAPIUser(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if r.Method != controllers.POST { if r.Method != controllers.POST {
@@ -80,7 +82,7 @@ func DeleteAccessToken(w http.ResponseWriter, r *http.Request) {
} }
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
var request deleteTokenRequest var request deleteExternalAPIUserRequest
if err := decoder.Decode(&request); err != nil { if err := decoder.Decode(&request); err != nil {
controllers.BadRequestHandler(w, err) controllers.BadRequestHandler(w, err)
return return
@@ -91,7 +93,7 @@ func DeleteAccessToken(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := data.DeleteToken(request.Token); err != nil { if err := user.DeleteExternalAPIUser(request.Token); err != nil {
controllers.InternalErrorHandler(w, err) controllers.InternalErrorHandler(w, err)
return return
} }

View File

@@ -15,6 +15,7 @@ import (
// GetServerConfig gets the config details of the server. // GetServerConfig gets the config details of the server.
func GetServerConfig(w http.ResponseWriter, r *http.Request) { func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ffmpeg := utils.ValidatedFfmpegPath(data.GetFfMpegPath()) ffmpeg := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
usernameBlocklist := data.GetForbiddenUsernameList()
var videoQualityVariants = make([]models.StreamOutputVariant, 0) var videoQualityVariants = make([]models.StreamOutputVariant, 0)
for _, variant := range data.GetStreamOutputVariants() { for _, variant := range data.GetStreamOutputVariants() {
@@ -57,11 +58,11 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
Enabled: data.GetDirectoryEnabled(), Enabled: data.GetDirectoryEnabled(),
InstanceURL: data.GetServerURL(), InstanceURL: data.GetServerURL(),
}, },
S3: data.GetS3Config(), S3: data.GetS3Config(),
ExternalActions: data.GetExternalActions(), ExternalActions: data.GetExternalActions(),
SupportedCodecs: transcoder.GetCodecs(ffmpeg), SupportedCodecs: transcoder.GetCodecs(ffmpeg),
VideoCodec: data.GetVideoCodec(), VideoCodec: data.GetVideoCodec(),
UsernameBlocklist: data.GetUsernameBlocklist(), ForbiddenUsernames: usernameBlocklist,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -71,20 +72,20 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
} }
type serverConfigAdminResponse struct { type serverConfigAdminResponse struct {
InstanceDetails webConfigResponse `json:"instanceDetails"` InstanceDetails webConfigResponse `json:"instanceDetails"`
FFmpegPath string `json:"ffmpegPath"` FFmpegPath string `json:"ffmpegPath"`
StreamKey string `json:"streamKey"` StreamKey string `json:"streamKey"`
WebServerPort int `json:"webServerPort"` WebServerPort int `json:"webServerPort"`
WebServerIP string `json:"webServerIP"` WebServerIP string `json:"webServerIP"`
RTMPServerPort int `json:"rtmpServerPort"` RTMPServerPort int `json:"rtmpServerPort"`
S3 models.S3 `json:"s3"` S3 models.S3 `json:"s3"`
VideoSettings videoSettings `json:"videoSettings"` VideoSettings videoSettings `json:"videoSettings"`
YP yp `json:"yp"` YP yp `json:"yp"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"` ExternalActions []models.ExternalAction `json:"externalActions"`
SupportedCodecs []string `json:"supportedCodecs"` SupportedCodecs []string `json:"supportedCodecs"`
VideoCodec string `json:"videoCodec"` VideoCodec string `json:"videoCodec"`
UsernameBlocklist string `json:"usernameBlocklist"` ForbiddenUsernames []string `json:"forbiddenUsernames"`
} }
type videoSettings struct { type videoSettings struct {

View File

@@ -4,11 +4,17 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/owncast/owncast/core" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/router/middleware"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ExternalGetChatMessages gets all of the chat messages.
func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
GetChatEmbed(w, r)
}
// GetChatMessages gets all of the chat messages. // GetChatMessages gets all of the chat messages.
func GetChatMessages(w http.ResponseWriter, r *http.Request) { func GetChatMessages(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(&w) middleware.EnableCors(&w)
@@ -16,7 +22,7 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
messages := core.GetAllChatMessages() messages := chat.GetChatHistory()
if err := json.NewEncoder(w).Encode(messages); err != nil { if err := json.NewEncoder(w).Encode(messages); err != nil {
log.Errorln(err) log.Errorln(err)
@@ -28,3 +34,43 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
// RegisterAnonymousChatUser will register a new user.
func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != POST {
WriteSimpleResponse(w, false, r.Method+" not supported")
return
}
type registerAnonymousUserRequest struct {
DisplayName string `json:"displayName"`
}
type registerAnonymousUserResponse struct {
Id string `json:"id"`
AccessToken string `json:"accessToken"`
DisplayName string `json:"displayName"`
}
decoder := json.NewDecoder(r.Body)
var request registerAnonymousUserRequest
if err := decoder.Decode(&request); err != nil { //nolint
// this is fine. register a new user anyway.
}
newUser, err := user.CreateAnonymousUser(request.DisplayName)
if err != nil {
WriteSimpleResponse(w, false, err.Error())
return
}
response := registerAnonymousUserResponse{
Id: newUser.Id,
AccessToken: newUser.AccessToken,
DisplayName: newUser.DisplayName,
}
WriteResponse(w, response)
}

View File

@@ -12,18 +12,19 @@ import (
) )
type webConfigResponse struct { type webConfigResponse struct {
Name string `json:"name"` Name string `json:"name"`
Summary string `json:"summary"` Summary string `json:"summary"`
Logo string `json:"logo"` Logo string `json:"logo"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Version string `json:"version"` Version string `json:"version"`
NSFW bool `json:"nsfw"` NSFW bool `json:"nsfw"`
ExtraPageContent string `json:"extraPageContent"` ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"` SocialHandles []models.SocialHandle `json:"socialHandles"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"` ExternalActions []models.ExternalAction `json:"externalActions"`
CustomStyles string `json:"customStyles"` CustomStyles string `json:"customStyles"`
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
} }
// GetWebConfig gets the status of the server. // GetWebConfig gets the status of the server.
@@ -45,18 +46,19 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
serverSummary = utils.RenderPageContentMarkdown(serverSummary) serverSummary = utils.RenderPageContentMarkdown(serverSummary)
configuration := webConfigResponse{ configuration := webConfigResponse{
Name: data.GetServerName(), Name: data.GetServerName(),
Summary: serverSummary, Summary: serverSummary,
Logo: "/logo", Logo: "/logo",
Tags: data.GetServerMetadataTags(), Tags: data.GetServerMetadataTags(),
Version: config.GetReleaseString(), Version: config.GetReleaseString(),
NSFW: data.GetNSFW(), NSFW: data.GetNSFW(),
ExtraPageContent: pageContent, ExtraPageContent: pageContent,
StreamTitle: data.GetStreamTitle(), StreamTitle: data.GetStreamTitle(),
SocialHandles: socialHandles, SocialHandles: socialHandles,
ChatDisabled: data.GetChatDisabled(), ChatDisabled: data.GetChatDisabled(),
ExternalActions: data.GetExternalActions(), ExternalActions: data.GetExternalActions(),
CustomStyles: data.GetCustomStyles(), CustomStyles: data.GetCustomStyles(),
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
} }
if err := json.NewEncoder(w).Encode(configuration); err != nil { if err := json.NewEncoder(w).Encode(configuration); err != nil {

View File

@@ -1,18 +0,0 @@
package controllers
import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/core"
)
// GetConnectedClients returns currently connected clients.
func GetConnectedClients(w http.ResponseWriter, r *http.Request) {
clients := core.GetChatClients()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(clients); err != nil {
InternalErrorHandler(w, err)
}
}

View File

@@ -2,84 +2,113 @@ package chat
import ( import (
"errors" "errors"
"time" "net/http"
"sort"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
) )
// Setup sets up the chat server. var getStatus func() models.Status
func Setup(listener models.ChatListener) {
func Start(getStatusFunc func() models.Status) error {
setupPersistence() setupPersistence()
clients := make(map[string]*Client) getStatus = getStatusFunc
addCh := make(chan *Client) _server = NewChat()
delCh := make(chan *Client)
sendAllCh := make(chan models.ChatEvent)
pingCh := make(chan models.PingMessage)
doneCh := make(chan bool)
errCh := make(chan error)
_server = &server{ go _server.Run()
clients,
"/entry", //hardcoded due to the UI requiring this and it is not configurable
listener,
addCh,
delCh,
sendAllCh,
pingCh,
doneCh,
errCh,
}
}
// Start starts the chat server. log.Traceln("Chat server started with max connection count of", _server.maxClientCount)
func Start() error {
if _server == nil {
return errors.New("chat server is nil")
}
ticker := time.NewTicker(30 * time.Second)
go func() {
for range ticker.C {
_server.ping()
}
}()
_server.Listen()
return errors.New("chat server failed to start")
}
// SendMessage sends a message to all.
func SendMessage(message models.ChatEvent) {
if _server == nil {
return
}
_server.SendToAll(message)
}
// GetMessages gets all of the messages.
func GetMessages() []models.ChatEvent {
if _server == nil {
return []models.ChatEvent{}
}
return getChatHistory()
}
func GetModerationChatMessages() []models.ChatEvent {
return getChatModerationHistory()
}
func GetClient(clientID string) *Client {
l.RLock()
defer l.RUnlock()
for _, client := range _server.Clients {
if client.ClientID == clientID {
return client
}
}
return nil return nil
} }
// GetClientsForUser will return chat connections that are owned by a specific user.
func GetClientsForUser(userID string) ([]*ChatClient, error) {
clients := map[string][]*ChatClient{}
for _, client := range _server.clients {
clients[client.User.Id] = append(clients[client.User.Id], client)
}
if _, exists := clients[userID]; !exists {
return nil, errors.New("no connections for user found")
}
return clients[userID], nil
}
func GetClients() []*ChatClient {
clients := []*ChatClient{}
// Convert the keyed map to a slice.
for _, client := range _server.clients {
clients = append(clients, client)
}
sort.Slice(clients, func(i, j int) bool {
return clients[i].ConnectedAt.Before(clients[j].ConnectedAt)
})
return clients
}
func SendSystemMessage(text string, ephemeral bool) error {
message := events.SystemMessageEvent{
MessageEvent: events.MessageEvent{
Body: text,
},
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
}
if !ephemeral {
saveEvent(message.Id, "system", message.Body, message.GetMessageType(), nil, message.Timestamp)
}
return nil
}
func SendSystemAction(text string, ephemeral bool) error {
message := events.ActionEvent{
MessageEvent: events.MessageEvent{
Body: text,
},
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system chat action")
}
if !ephemeral {
saveEvent(message.Id, "action", message.Body, message.GetMessageType(), nil, message.Timestamp)
}
return nil
}
func SendAllWelcomeMessage() {
_server.sendAllWelcomeMessage()
}
func Broadcast(event events.OutboundEvent) error {
return _server.Broadcast(event.GetBroadcastPayload())
}
func HandleClientConnection(w http.ResponseWriter, r *http.Request) {
_server.HandleClientConnection(w, r)
}
// DisconnectUser will forcefully disconnect all clients belonging to a user by ID.
func DisconnectUser(userID string) {
_server.DisconnectUser(userID)
}

190
core/chat/chatclient.go Normal file
View File

@@ -0,0 +1,190 @@
package chat
import (
"bytes"
"encoding/json"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/time/rate"
"github.com/gorilla/websocket"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/geoip"
)
type ChatClient struct {
id uint
accessToken string
conn *websocket.Conn
User *user.User `json:"user"`
server *ChatServer
ipAddress string `json:"-"`
// Buffered channel of outbound messages.
send chan []byte
rateLimiter *rate.Limiter
Geo *geoip.GeoDetails `json:"geo"`
MessageCount int `json:"messageCount"`
UserAgent string `json:"userAgent"`
ConnectedAt time.Time `json:"connectedAt"`
}
type chatClientEvent struct {
data []byte
client *ChatClient
}
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
// Larger messages get thrown away.
// Messages > *2 the socket gets closed.
maxMessageSize = config.MaxSocketPayloadSize
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
var (
newline = []byte{'\n'}
space = []byte{' '}
)
func (c *ChatClient) sendConnectedClientInfo() {
payload := events.EventPayload{
"type": events.ConnectedUserInfo,
"user": c.User,
}
c.sendPayload(payload)
}
func (c *ChatClient) readPump() {
c.rateLimiter = rate.NewLimiter(0.6, 5)
defer func() {
c.close()
}()
// If somebody is sending 2x the max message size they're likely a bad actor
// and should be disconnected. Below we throw away messages > max size.
c.conn.SetReadLimit(maxMessageSize * 2)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error { _ = c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
c.close()
}
break
}
// Throw away messages greater than max message size.
if len(message) > maxMessageSize {
c.sendAction("Sorry, that message exceeded the maximum size and can't be delivered.")
continue
}
// Guard against floods.
if !c.passesRateLimit() {
continue
}
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
c.handleEvent(message)
}
}
func (c *ChatClient) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The server closed the channel.
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
if _, err := w.Write(message); err != nil {
log.Debugln(err)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func (c *ChatClient) handleEvent(data []byte) {
c.server.inbound <- chatClientEvent{data: data, client: c}
}
func (c *ChatClient) close() {
log.Traceln("client closed:", c.User.DisplayName, c.id, c.ipAddress)
c.conn.Close()
c.server.unregister <- c
}
func (c *ChatClient) passesRateLimit() bool {
if !c.rateLimiter.Allow() {
log.Debugln("Client", c.id, c.User.DisplayName, "has exceeded the messaging rate limiting thresholds.")
return false
}
return true
}
func (c *ChatClient) sendPayload(payload events.EventPayload) {
var data []byte
data, err := json.Marshal(payload)
if err != nil {
log.Errorln(err)
return
}
c.send <- data
}
func (c *ChatClient) sendAction(message string) {
clientMessage := events.ActionEvent{
MessageEvent: events.MessageEvent{
Body: message,
},
}
clientMessage.SetDefaults()
clientMessage.RenderBody()
c.sendPayload(clientMessage.GetBroadcastPayload())
}

View File

@@ -1,241 +0,0 @@
package chat
import (
"encoding/json"
"fmt"
"io"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/net/websocket"
"github.com/owncast/owncast/geoip"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/teris-io/shortid"
"golang.org/x/time/rate"
)
const channelBufSize = 100
//Client represents a chat client.
type Client struct {
ConnectedAt time.Time
MessageCount int
UserAgent string
IPAddress string
Username *string
ClientID string // How we identify unique viewers when counting viewer counts.
Geo *geoip.GeoDetails `json:"geo"`
Ignore bool // If set to true this will not be treated as a viewer
socketID string // How we identify a single websocket client.
ws *websocket.Conn
ch chan models.ChatEvent
pingch chan models.PingMessage
usernameChangeChannel chan models.NameChangeEvent
userJoinedChannel chan models.UserJoinedEvent
doneCh chan bool
rateLimiter *rate.Limiter
}
// NewClient creates a new chat client.
func NewClient(ws *websocket.Conn) *Client {
if ws == nil {
log.Panicln("ws cannot be nil")
}
var ignoreClient = false
for _, extraData := range ws.Config().Protocol {
if extraData == "IGNORE_CLIENT" {
ignoreClient = true
}
}
ch := make(chan models.ChatEvent, channelBufSize)
doneCh := make(chan bool)
pingch := make(chan models.PingMessage)
usernameChangeChannel := make(chan models.NameChangeEvent)
userJoinedChannel := make(chan models.UserJoinedEvent)
ipAddress := utils.GetIPAddressFromRequest(ws.Request())
userAgent := ws.Request().UserAgent()
socketID, _ := shortid.Generate()
clientID := socketID
rateLimiter := rate.NewLimiter(0.6, 5)
return &Client{time.Now(), 0, userAgent, ipAddress, nil, clientID, nil, ignoreClient, socketID, ws, ch, pingch, usernameChangeChannel, userJoinedChannel, doneCh, rateLimiter}
}
func (c *Client) write(msg models.ChatEvent) {
select {
case c.ch <- msg:
default:
_server.removeClient(c)
_server.err(fmt.Errorf("client %s is disconnected", c.ClientID))
}
}
// Listen Write and Read request via channel.
func (c *Client) listen() {
go c.listenWrite()
c.listenRead()
}
// Listen write request via channel.
func (c *Client) listenWrite() {
for {
select {
// Send a PING keepalive
case msg := <-c.pingch:
if err := websocket.JSON.Send(c.ws, msg); err != nil {
c.handleClientSocketError(err)
}
// send message to the client
case msg := <-c.ch:
if err := websocket.JSON.Send(c.ws, msg); err != nil {
c.handleClientSocketError(err)
}
case msg := <-c.usernameChangeChannel:
if err := websocket.JSON.Send(c.ws, msg); err != nil {
c.handleClientSocketError(err)
}
case msg := <-c.userJoinedChannel:
if err := websocket.JSON.Send(c.ws, msg); err != nil {
c.handleClientSocketError(err)
}
// receive done request
case <-c.doneCh:
_server.removeClient(c)
c.doneCh <- true // for listenRead method
return
}
}
}
func (c *Client) handleClientSocketError(err error) {
_server.removeClient(c)
}
func (c *Client) passesRateLimit() bool {
if !c.rateLimiter.Allow() {
log.Debugln("Client", c.ClientID, "has exceeded the messaging rate limiting thresholds.")
return false
}
return true
}
// Listen read request via channel.
func (c *Client) listenRead() {
for {
select {
// receive done request
case <-c.doneCh:
_server.remove(c)
c.doneCh <- true // for listenWrite method
return
// read data from websocket connection
default:
var data []byte
if err := websocket.Message.Receive(c.ws, &data); err != nil {
if err == io.EOF {
c.doneCh <- true
return
}
c.handleClientSocketError(err)
}
if !c.passesRateLimit() {
continue
}
var messageTypeCheck map[string]interface{}
// Bad messages should be thrown away
if err := json.Unmarshal(data, &messageTypeCheck); err != nil {
log.Debugln("Badly formatted message received from", c.Username, c.ws.Request().RemoteAddr)
continue
}
// If we can't tell the type of message, also throw it away.
if messageTypeCheck == nil {
log.Debugln("Untyped message received from", c.Username, c.ws.Request().RemoteAddr)
continue
}
messageType := messageTypeCheck["type"].(string)
if messageType == models.MessageSent {
c.chatMessageReceived(data)
} else if messageType == models.UserNameChanged {
c.userChangedName(data)
} else if messageType == models.UserJoined {
c.userJoined(data)
}
}
}
}
func (c *Client) userJoined(data []byte) {
var msg models.UserJoinedEvent
if err := json.Unmarshal(data, &msg); err != nil {
log.Errorln(err)
return
}
msg.ID = shortid.MustGenerate()
msg.Type = models.UserJoined
msg.Timestamp = time.Now()
c.Username = &msg.Username
_server.userJoined(msg)
}
func (c *Client) userChangedName(data []byte) {
var msg models.NameChangeEvent
if err := json.Unmarshal(data, &msg); err != nil {
log.Errorln(err)
}
msg.Type = models.UserNameChanged
msg.ID = shortid.MustGenerate()
_server.usernameChanged(msg)
c.Username = &msg.NewName
}
func (c *Client) chatMessageReceived(data []byte) {
var msg models.ChatEvent
if err := json.Unmarshal(data, &msg); err != nil {
log.Errorln(err)
}
msg.SetDefaults()
c.MessageCount++
c.Username = &msg.Author
msg.ClientID = c.ClientID
msg.RenderAndSanitizeMessageBody()
_server.SendToAll(msg)
}
// GetViewerClientFromChatClient returns a general models.Client from a chat websocket client.
func (c *Client) GetViewerClientFromChatClient() models.Client {
return models.Client{
ConnectedAt: c.ConnectedAt,
MessageCount: c.MessageCount,
UserAgent: c.UserAgent,
IPAddress: c.IPAddress,
Username: c.Username,
ClientID: c.ClientID,
Geo: geoip.GetGeoFromIP(c.IPAddress),
}
}

102
core/chat/events.go Normal file
View File

@@ -0,0 +1,102 @@
package chat
import (
"encoding/json"
"fmt"
"strings"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
log "github.com/sirupsen/logrus"
)
func (s *ChatServer) userNameChanged(eventData chatClientEvent) {
var receivedEvent events.NameChangeEvent
if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil {
log.Errorln("error unmarshalling to NameChangeEvent", err)
return
}
proposedUsername := receivedEvent.NewName
blocklist := data.GetForbiddenUsernameList()
for _, blockedName := range blocklist {
normalizedName := strings.TrimSpace(blockedName)
normalizedName = strings.ToLower(normalizedName)
if strings.Contains(normalizedName, proposedUsername) {
// Denied.
log.Debugln(eventData.client.User.DisplayName, "blocked from changing name to", proposedUsername, "due to blocked name", normalizedName)
message := fmt.Sprintf("You cannot change your name to **%s**.", proposedUsername)
s.sendActionToClient(eventData.client, message)
// Resend the client's user so their username is in sync.
eventData.client.sendConnectedClientInfo()
return
}
}
savedUser := user.GetUserByToken(eventData.client.accessToken)
oldName := savedUser.DisplayName
// Save the new name
user.ChangeUsername(eventData.client.User.Id, receivedEvent.NewName)
// Update the connected clients associated user with the new name
eventData.client.User = savedUser
// Send chat event letting everyone about about the name change
savedUser.DisplayName = receivedEvent.NewName
broadcastEvent := events.NameChangeBroadcast{
Oldname: oldName,
}
broadcastEvent.User = savedUser
broadcastEvent.SetDefaults()
payload := broadcastEvent.GetBroadcastPayload()
if err := s.Broadcast(payload); err != nil {
log.Errorln("error broadcasting NameChangeEvent", err)
return
}
// Send chat user name changed webhook
receivedEvent.User = savedUser
webhooks.SendChatEventUsernameChanged(receivedEvent)
}
func (s *ChatServer) userMessageSent(eventData chatClientEvent) {
var event events.UserMessageEvent
if err := json.Unmarshal(eventData.data, &event); err != nil {
log.Errorln("error unmarshalling to UserMessageEvent", err)
return
}
event.SetDefaults()
// Ignore empty messages
if event.Empty() {
return
}
event.User = user.GetUserByToken(eventData.client.accessToken)
// Guard against nil users
if event.User == nil {
return
}
payload := event.GetBroadcastPayload()
if err := s.Broadcast(payload); err != nil {
log.Errorln("error broadcasting UserMessageEvent payload", err)
return
}
// Send chat message sent webhook
webhooks.SendChatEvent(&event)
SaveUserMessage(event)
eventData.client.MessageCount = eventData.client.MessageCount + 1
}

View File

@@ -0,0 +1,20 @@
package events
type ActionEvent struct {
Event
MessageEvent
}
// ActionEvent will return the object to send to all chat users.
func (e *ActionEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"id": e.Id,
"timestamp": e.Timestamp,
"body": e.Body,
"type": e.GetMessageType(),
}
}
func (e *ActionEvent) GetMessageType() EventType {
return ChatActionSent
}

View File

@@ -1,4 +1,4 @@
package models package events
import ( import (
"bytes" "bytes"
@@ -12,38 +12,59 @@ import (
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/renderer/html"
"mvdan.cc/xurls" "mvdan.cc/xurls"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus"
) )
// ChatEvent represents a single chat message. // EventPayload is a generic key/value map for sending out to chat clients.
type ChatEvent struct { type EventPayload map[string]interface{}
ClientID string `json:"-"`
Author string `json:"author,omitempty"` type OutboundEvent interface {
Body string `json:"body,omitempty"` GetBroadcastPayload() EventPayload
RawBody string `json:"-"` GetMessageType() EventType
ID string `json:"id"`
MessageType EventType `json:"type"`
Visible bool `json:"visible"`
Timestamp time.Time `json:"timestamp,omitempty"`
Ephemeral bool `json:"ephemeral,omitempty"`
} }
// Valid checks to ensure the message is valid. // Event is any kind of event. A type is required to be specified.
func (m ChatEvent) Valid() bool { type Event struct {
return m.Author != "" && m.Body != "" && m.ID != "" Type EventType `json:"type"`
Id string `json:"id"`
Timestamp time.Time `json:"timestamp"`
} }
// SetDefaults will set default values on a chat event object. type UserEvent struct {
func (m *ChatEvent) SetDefaults() { User *user.User `json:"user"`
id, _ := shortid.Generate() HiddenAt *time.Time `json:"hiddenAt,omitempty"`
m.ID = id }
m.Timestamp = time.Now()
m.Visible = true // MessageEvent is an event that has a message body.
type MessageEvent struct {
OutboundEvent `json:"-"`
Body string `json:"body"`
RawBody string `json:"-"`
}
type SystemActionEvent struct {
Event
MessageEvent
}
// SetDefaults will set default properties of all inbound events.
func (e *Event) SetDefaults() {
e.Id = shortid.MustGenerate()
e.Timestamp = time.Now()
}
// SetDefaults will set default properties of all inbound events.
func (e *UserMessageEvent) SetDefaults() {
e.Id = shortid.MustGenerate()
e.Timestamp = time.Now()
e.RenderAndSanitizeMessageBody()
} }
// RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize // RenderAndSanitizeMessageBody will turn markdown into HTML, sanitize raw user-supplied HTML and standardize
// the message into something safe and renderable for clients. // the message into something safe and renderable for clients.
func (m *ChatEvent) RenderAndSanitizeMessageBody() { func (m *MessageEvent) RenderAndSanitizeMessageBody() {
m.RawBody = m.Body m.RawBody = m.Body
// Set the new, sanitized and rendered message body // Set the new, sanitized and rendered message body
@@ -51,12 +72,12 @@ func (m *ChatEvent) RenderAndSanitizeMessageBody() {
} }
// Empty will return if this message's contents is empty. // Empty will return if this message's contents is empty.
func (m *ChatEvent) Empty() bool { func (m *MessageEvent) Empty() bool {
return m.Body == "" return m.Body == ""
} }
// RenderBody will render markdown to html without any sanitization // RenderBody will render markdown to html without any sanitization.
func (m *ChatEvent) RenderBody() { func (m *MessageEvent) RenderBody() {
m.RawBody = m.Body m.RawBody = m.Body
m.Body = RenderMarkdown(m.RawBody) m.Body = RenderMarkdown(m.RawBody)
} }
@@ -92,7 +113,7 @@ func RenderMarkdown(raw string) string {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
var buf bytes.Buffer var buf bytes.Buffer
if err := markdown.Convert([]byte(trimmed), &buf); err != nil { if err := markdown.Convert([]byte(trimmed), &buf); err != nil {
panic(err) log.Debugln(err)
} }
return buf.String() return buf.String()

View File

@@ -0,0 +1,34 @@
package events
// EventType is the type of a websocket event.
type EventType = string
const (
// MessageSent is the event sent when a chat event takes place.
MessageSent EventType = "CHAT"
// UserJoined is the event sent when a chat user join action takes place.
UserJoined EventType = "USER_JOINED"
// UserNameChanged is the event sent when a chat username change takes place.
UserNameChanged EventType = "NAME_CHANGE"
// VisibiltyToggled is the event sent when a chat message's visibility changes.
VisibiltyToggled EventType = "VISIBILITY-UPDATE"
// PING is a ping message.
PING EventType = "PING"
// PONG is a pong message.
PONG EventType = "PONG"
// StreamStarted represents a stream started event.
StreamStarted EventType = "STREAM_STARTED"
// StreamStopped represents a stream stopped event.
StreamStopped EventType = "STREAM_STOPPED"
// SystemMessageSent is the event sent when a system message is sent.
SystemMessageSent EventType = "SYSTEM"
// ChatDisabled is when a user is explicitly disabled and blocked from using chat.
ChatDisabled EventType = "CHAT_DISABLED"
// ConnectedUserInfo is a private event to a user letting them know their user details.
ConnectedUserInfo EventType = "CONNECTED_USER_INFO"
// ChatActionSent is a generic chat action that can be used for anything that doesn't need specific handling or formatting.
ChatActionSent EventType = "CHAT_ACTION"
ErrorNeedsRegistration EventType = "ERROR_NEEDS_REGISTRATION"
ErrorMaxConnectionsExceeded EventType = "ERROR_MAX_CONNECTIONS_EXCEEDED"
ErrorUserDisabled EventType = "ERROR_USER_DISABLED"
)

View File

@@ -0,0 +1,26 @@
package events
// NameChangeEvent is received when a user changes their chat display name.
type NameChangeEvent struct {
Event
UserEvent
NewName string `json:"newName"`
}
// NameChangeEventBroadcast is fired when a user changes their chat display name.
type NameChangeBroadcast struct {
Event
UserEvent
Oldname string `json:"oldName"`
}
// GetBroadcastPayload will return the object to send to all chat users.
func (e *NameChangeBroadcast) GetBroadcastPayload() EventPayload {
return EventPayload{
"id": e.Id,
"timestamp": e.Timestamp,
"user": e.User,
"oldName": e.Oldname,
"type": UserNameChanged,
}
}

View File

@@ -0,0 +1,26 @@
package events
import "github.com/owncast/owncast/core/data"
// SystemMessageEvent is a message displayed in chat on behalf of the server.
type SystemMessageEvent struct {
Event
MessageEvent
}
// SystemMessageEvent will return the object to send to all chat users.
func (e *SystemMessageEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"id": e.Id,
"timestamp": e.Timestamp,
"body": e.Body,
"type": SystemMessageSent,
"user": EventPayload{
"displayName": data.GetServerName(),
},
}
}
func (e *SystemMessageEvent) GetMessageType() EventType {
return SystemMessageSent
}

View File

@@ -0,0 +1,17 @@
package events
// UserDisabledEvent is the event fired when a user is banned/blocked and disconnected from chat.
type UserDisabledEvent struct {
Event
UserEvent
}
// GetBroadcastPayload will return the object to send to all chat users.
func (e *UserDisabledEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"type": ErrorUserDisabled,
"id": e.Id,
"timestamp": e.Timestamp,
"user": e.User,
}
}

View File

@@ -0,0 +1,17 @@
package events
// UserJoinedEvent is the event fired when a user joins chat.
type UserJoinedEvent struct {
Event
UserEvent
}
// GetBroadcastPayload will return the object to send to all chat users.
func (e *UserJoinedEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"type": UserJoined,
"id": e.Id,
"timestamp": e.Timestamp,
"user": e.User,
}
}

View File

@@ -0,0 +1,24 @@
package events
// UserMessageEvent is an inbound message from a user.
type UserMessageEvent struct {
Event
UserEvent
MessageEvent
}
// GetBroadcastPayload will return the object to send to all chat users.
func (e *UserMessageEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"id": e.Id,
"timestamp": e.Timestamp,
"body": e.Body,
"user": e.User,
"type": MessageSent,
"visible": e.HiddenAt == nil,
}
}
func (e *UserMessageEvent) GetMessageType() EventType {
return MessageSent
}

View File

@@ -3,7 +3,7 @@ package chat
import ( import (
"testing" "testing"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/core/chat/events"
) )
// Test a bunch of arbitrary markup and markdown to make sure we get sanitized // Test a bunch of arbitrary markup and markdown to make sure we get sanitized
@@ -25,7 +25,7 @@ blah blah blah
<p><a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a> <p><a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a>
<img class="emoji" src="/img/emoji/bananadance.gif"></p>` <img class="emoji" src="/img/emoji/bananadance.gif"></p>`
result := models.RenderAndSanitize(messageContent) result := events.RenderAndSanitize(messageContent)
if result != expected { if result != expected {
t.Errorf("message rendering/sanitation does not match expected. Got\n%s, \n\n want:\n%s", result, expected) t.Errorf("message rendering/sanitation does not match expected. Got\n%s, \n\n want:\n%s", result, expected)
} }
@@ -35,7 +35,7 @@ blah blah blah
func TestBlockRemoteImages(t *testing.T) { func TestBlockRemoteImages(t *testing.T) {
messageContent := `<img src="https://via.placeholder.com/350x150"> test ![](https://via.placeholder.com/350x150)` messageContent := `<img src="https://via.placeholder.com/350x150"> test ![](https://via.placeholder.com/350x150)`
expected := `<p> test </p>` expected := `<p> test </p>`
result := models.RenderAndSanitize(messageContent) result := events.RenderAndSanitize(messageContent)
if result != expected { if result != expected {
t.Errorf("message rendering/sanitation does not match expected. Got\n%s, \n\n want:\n%s", result, expected) t.Errorf("message rendering/sanitation does not match expected. Got\n%s, \n\n want:\n%s", result, expected)
@@ -46,7 +46,7 @@ func TestBlockRemoteImages(t *testing.T) {
func TestAllowEmojiImages(t *testing.T) { func TestAllowEmojiImages(t *testing.T) {
messageContent := `<img src="/img/emoji/beerparrot.gif"> test ![](/img/emoji/beerparrot.gif)` messageContent := `<img src="/img/emoji/beerparrot.gif"> test ![](/img/emoji/beerparrot.gif)`
expected := `<p><img src="/img/emoji/beerparrot.gif"> test <img src="/img/emoji/beerparrot.gif"></p>` expected := `<p><img src="/img/emoji/beerparrot.gif"> test <img src="/img/emoji/beerparrot.gif"></p>`
result := models.RenderAndSanitize(messageContent) result := events.RenderAndSanitize(messageContent)
if result != expected { if result != expected {
t.Errorf("message rendering/sanitation does not match expected. Got\n%s, \n\n want:\n%s", result, expected) t.Errorf("message rendering/sanitation does not match expected. Got\n%s, \n\n want:\n%s", result, expected)
@@ -57,7 +57,7 @@ func TestAllowEmojiImages(t *testing.T) {
func TestAllowHTML(t *testing.T) { func TestAllowHTML(t *testing.T) {
messageContent := `<img src="/img/emoji/beerparrot.gif"><ul><li>**test thing**</li></ul>` messageContent := `<img src="/img/emoji/beerparrot.gif"><ul><li>**test thing**</li></ul>`
expected := "<p><img src=\"/img/emoji/beerparrot.gif\"><ul><li><strong>test thing</strong></li></ul></p>\n" expected := "<p><img src=\"/img/emoji/beerparrot.gif\"><ul><li><strong>test thing</strong></li></ul></p>\n"
result := models.RenderMarkdown(messageContent) result := events.RenderMarkdown(messageContent)
if result != expected { if result != expected {
t.Errorf("message rendering does not match expected. Got\n%s, \n\n want:\n%s", result, expected) t.Errorf("message rendering does not match expected. Got\n%s, \n\n want:\n%s", result, expected)

View File

@@ -1,8 +1,8 @@
package chat package chat
import ( import (
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -22,8 +22,11 @@ func SetMessagesVisibility(messageIDs []string, visibility bool) error {
log.Errorln(err) log.Errorln(err)
continue continue
} }
message.MessageType = models.VisibiltyToggled payload := message.GetBroadcastPayload()
_server.sendAll(message) payload["type"] = events.VisibiltyToggled
if err := _server.Broadcast(payload); err != nil {
log.Debugln(err)
}
go webhooks.SendChatEvent(message) go webhooks.SendChatEvent(message)
} }

View File

@@ -1,171 +1,309 @@
package chat package chat
import ( import (
"database/sql" "fmt"
"strings" "strings"
"time" "time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var _db *sql.DB var _datastore *data.Datastore
const (
maxBacklogHours = 5 // Keep backlog max hours worth of messages
maxBacklogNumber = 50 // Return max number of messages in history request
)
func setupPersistence() { func setupPersistence() {
_db = data.GetDatabase() _datastore = data.GetDatastore()
createTable() createMessagesTable()
chatDataPruner := time.NewTicker(5 * time.Minute)
go func() {
runPruner()
for range chatDataPruner.C {
runPruner()
}
}()
} }
func createTable() { func createMessagesTable() {
createTableSQL := `CREATE TABLE IF NOT EXISTS messages ( createTableSQL := `CREATE TABLE IF NOT EXISTS messages (
"id" string NOT NULL PRIMARY KEY, "id" string NOT NULL PRIMARY KEY,
"author" TEXT, "user_id" INTEGER,
"body" TEXT, "body" TEXT,
"messageType" TEXT, "eventType" TEXT,
"visible" INTEGER, "hidden_at" DATETIME,
"timestamp" DATE "timestamp" DATETIME
);` );`
stmt, err := _db.Prepare(createTableSQL) stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal("error creating chat messages table", err)
} }
defer stmt.Close() defer stmt.Close()
if _, err := stmt.Exec(); err != nil { if _, err := stmt.Exec(); err != nil {
log.Warnln(err) log.Fatal("error creating chat messages table", err)
} }
} }
func addMessage(message models.ChatEvent) { func SaveUserMessage(event events.UserMessageEvent) {
tx, err := _db.Begin() saveEvent(event.Id, event.User.Id, event.Body, event.Type, event.HiddenAt, event.Timestamp)
if err != nil { }
log.Fatal(err)
}
stmt, err := tx.Prepare("INSERT INTO messages(id, author, body, messageType, visible, timestamp) values(?, ?, ?, ?, ?, ?)")
func saveEvent(id string, userId string, body string, eventType string, hidden *time.Time, timestamp time.Time) {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil { if err != nil {
log.Fatal(err) log.Errorln("error saving", eventType, err)
return
} }
defer tx.Rollback() // nolint
stmt, err := tx.Prepare("INSERT INTO messages(id, user_id, body, eventType, hidden_at, timestamp) values(?, ?, ?, ?, ?, ?)")
if err != nil {
log.Errorln("error saving", eventType, err)
return
}
defer stmt.Close() defer stmt.Close()
if _, err := stmt.Exec(message.ID, message.Author, message.Body, message.MessageType, 1, message.Timestamp); err != nil { if _, err = stmt.Exec(id, userId, body, eventType, hidden, timestamp); err != nil {
log.Fatal(err) log.Errorln("error saving", eventType, err)
return
} }
if err := tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
log.Fatal(err) log.Errorln("error saving", eventType, err)
return
} }
} }
func getChat(query string) []models.ChatEvent { func getChat(query string) []events.UserMessageEvent {
history := make([]models.ChatEvent, 0) history := make([]events.UserMessageEvent, 0)
rows, err := _db.Query(query) rows, err := _datastore.DB.Query(query)
if err != nil { if err != nil {
log.Fatal(err) log.Errorln("error fetching chat history", err)
return history
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var id string var id string
var author string var userId string
var body string var body string
var messageType models.EventType var messageType models.EventType
var visible int var hiddenAt *time.Time
var timestamp time.Time var timestamp time.Time
err = rows.Scan(&id, &author, &body, &messageType, &visible, &timestamp) var userDisplayName *string
var userDisplayColor *int
var userCreatedAt *time.Time
var userDisabledAt *time.Time
var previousUsernames *string
var userNameChangedAt *time.Time
// Convert a database row into a chat event
err = rows.Scan(&id, &userId, &body, &messageType, &hiddenAt, &timestamp, &userDisplayName, &userDisplayColor, &userCreatedAt, &userDisabledAt, &previousUsernames, &userNameChangedAt)
if err != nil { if err != nil {
log.Debugln(err) log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
log.Error("There is a problem with the chat database. Restore a backup of owncast.db or remove it and start over.")
break break
} }
message := models.ChatEvent{} // System messages and chat actions are special and are not from real users
message.ID = id if messageType == events.SystemMessageSent || messageType == events.ChatActionSent {
message.Author = author name := "Owncast"
message.Body = body userDisplayName = &name
message.MessageType = messageType color := 200
message.Visible = visible == 1 userDisplayColor = &color
message.Timestamp = timestamp }
if previousUsernames == nil {
previousUsernames = userDisplayName
}
if userCreatedAt == nil {
now := time.Now()
userCreatedAt = &now
}
user := user.User{
Id: userId,
AccessToken: "",
DisplayName: *userDisplayName,
DisplayColor: *userDisplayColor,
CreatedAt: *userCreatedAt,
DisabledAt: userDisabledAt,
NameChangedAt: userNameChangedAt,
PreviousNames: strings.Split(*previousUsernames, ","),
}
message := events.UserMessageEvent{
Event: events.Event{
Type: messageType,
Id: id,
Timestamp: timestamp,
},
UserEvent: events.UserEvent{
User: &user,
HiddenAt: hiddenAt,
},
MessageEvent: events.MessageEvent{
Body: body,
RawBody: body,
},
}
history = append(history, message) history = append(history, message)
} }
if err := rows.Err(); err != nil {
log.Fatal(err)
}
return history return history
} }
func getChatModerationHistory() []models.ChatEvent { func GetChatModerationHistory() []events.UserMessageEvent {
var query = "SELECT * FROM messages WHERE messageType == 'CHAT' AND datetime(timestamp) >=datetime('now', '-5 Hour')" // Get all messages regardless of visibility
var query = "SELECT messages.id, user_id, body, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
return getChat(query) return getChat(query)
} }
func getChatHistory() []models.ChatEvent { func GetChatHistory() []events.UserMessageEvent {
// Get all messages sent within the past 5hrs, max 50 // Get all visible messages
var query = "SELECT * FROM (SELECT * FROM messages WHERE datetime(timestamp) >=datetime('now', '-5 Hour') AND visible = 1 ORDER BY timestamp DESC LIMIT 50) ORDER BY timestamp asc" var query = fmt.Sprintf("SELECT id, user_id, body, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM (SELECT * FROM messages LEFT OUTER JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL ORDER BY timestamp DESC LIMIT %d) ORDER BY timestamp asc", maxBacklogNumber)
return getChat(query) return getChat(query)
} }
// SetMessageVisibilityForUserId will bulk change the visibility of messages for a user
// and then send out visibility changed events to chat clients.
func SetMessageVisibilityForUserId(userID string, visible bool) error {
// Get a list of IDs from this user within the 5hr window to send to the connected clients to hide
ids := make([]string, 0)
query := fmt.Sprintf("SELECT messages.id, user_id, body, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID)
messages := getChat(query)
if len(messages) == 0 {
return nil
}
for _, message := range messages {
ids = append(ids, message.Id)
}
// Tell the clients to hide/show these messages.
return SetMessagesVisibility(ids, visible)
}
func saveMessageVisibility(messageIDs []string, visible bool) error { func saveMessageVisibility(messageIDs []string, visible bool) error {
tx, err := _db.Begin() _datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil { if err != nil {
log.Fatal(err) return err
} }
stmt, err := tx.Prepare("UPDATE messages SET visible=? WHERE id IN (?" + strings.Repeat(",?", len(messageIDs)-1) + ")") stmt, err := tx.Prepare("UPDATE messages SET hidden_at=? WHERE id IN (?" + strings.Repeat(",?", len(messageIDs)-1) + ")")
if err != nil { if err != nil {
log.Fatal(err)
return err return err
} }
defer stmt.Close() defer stmt.Close()
var hiddenAt *time.Time
if !visible {
now := time.Now()
hiddenAt = &now
} else {
hiddenAt = nil
}
args := make([]interface{}, len(messageIDs)+1) args := make([]interface{}, len(messageIDs)+1)
args[0] = visible args[0] = hiddenAt
for i, id := range messageIDs { for i, id := range messageIDs {
args[i+1] = id args[i+1] = id
} }
if _, err := stmt.Exec(args...); err != nil { if _, err = stmt.Exec(args...); err != nil {
log.Fatal(err)
return err return err
} }
if err := tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
log.Fatal(err)
return err return err
} }
return nil return nil
} }
func getMessageById(messageID string) (models.ChatEvent, error) { func getMessageById(messageID string) (*events.UserMessageEvent, error) {
var query = "SELECT * FROM messages WHERE id = ?" var query = "SELECT * FROM messages WHERE id = ?"
row := _db.QueryRow(query, messageID) row := _datastore.DB.QueryRow(query, messageID)
var id string var id string
var author string var userId string
var body string var body string
var messageType models.EventType var eventType models.EventType
var visible int var hiddenAt *time.Time
var timestamp time.Time var timestamp time.Time
err := row.Scan(&id, &author, &body, &messageType, &visible, &timestamp) err := row.Scan(&id, &userId, &body, &eventType, &hiddenAt, &timestamp)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)
return models.ChatEvent{}, err return nil, err
} }
return models.ChatEvent{ user := user.GetUserById(userId)
ID: id,
Author: author, return &events.UserMessageEvent{
Body: body, events.Event{
MessageType: messageType, Type: eventType,
Visible: visible == 1, Id: id,
Timestamp: timestamp, Timestamp: timestamp,
},
events.UserEvent{
User: user,
HiddenAt: hiddenAt,
},
events.MessageEvent{
Body: body,
},
}, nil }, nil
} }
// Only keep recent messages so we don't keep more chat data than needed
// for privacy and efficiency reasons.
func runPruner() {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
log.Traceln("Removing chat messages older than", maxBacklogHours, "hours")
deleteStatement := `DELETE FROM messages WHERE timestamp <= datetime('now', 'localtime', ?)`
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
return
}
stmt, err := tx.Prepare(deleteStatement)
if err != nil {
log.Debugln(err)
return
}
defer stmt.Close()
if _, err = stmt.Exec(fmt.Sprintf("-%d hours", maxBacklogHours)); err != nil {
log.Debugln(err)
return
}
if err = tx.Commit(); err != nil {
log.Debugln(err)
return
}
}

View File

@@ -1,191 +1,317 @@
package chat package chat
import ( import (
"fmt" "encoding/json"
"net/http" "net/http"
"sync" "sync"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/net/websocket"
"github.com/gorilla/websocket"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/utils"
) )
var ( var _server *ChatServer
_server *server
)
var l = &sync.RWMutex{} type ChatServer struct {
mu sync.RWMutex
seq uint
clients map[uint]*ChatClient
maxClientCount uint
// Server represents the server which handles the chat. // send outbound message payload to all clients
type server struct { outbound chan []byte
Clients map[string]*Client
pattern string // receive inbound message payload from all clients
listener models.ChatListener inbound chan chatClientEvent
addCh chan *Client // unregister requests from clients.
delCh chan *Client unregister chan *ChatClient
sendAllCh chan models.ChatEvent
pingCh chan models.PingMessage
doneCh chan bool
errCh chan error
} }
// Add adds a client to the server. func NewChat() *ChatServer {
func (s *server) add(c *Client) { server := &ChatServer{
s.addCh <- c clients: map[uint]*ChatClient{},
} outbound: make(chan []byte),
inbound: make(chan chatClientEvent),
// Remove removes a client from the server. unregister: make(chan *ChatClient),
func (s *server) remove(c *Client) { maxClientCount: handleMaxConnectionCount(),
s.delCh <- c
}
// SendToAll sends a message to all of the connected clients.
func (s *server) SendToAll(msg models.ChatEvent) {
s.sendAllCh <- msg
}
// Err handles an error.
func (s *server) err(err error) {
s.errCh <- err
}
func (s *server) sendAll(msg models.ChatEvent) {
l.RLock()
for _, c := range s.Clients {
c.write(msg)
} }
l.RUnlock()
return server
} }
func (s *server) ping() { func (s *ChatServer) Run() {
ping := models.PingMessage{MessageType: models.PING}
l.RLock()
for _, c := range s.Clients {
c.pingch <- ping
}
l.RUnlock()
}
func (s *server) usernameChanged(msg models.NameChangeEvent) {
l.RLock()
for _, c := range s.Clients {
c.usernameChangeChannel <- msg
}
l.RUnlock()
go webhooks.SendChatEventUsernameChanged(msg)
}
func (s *server) userJoined(msg models.UserJoinedEvent) {
l.RLock()
if s.listener.IsStreamConnected() {
for _, c := range s.Clients {
c.userJoinedChannel <- msg
}
}
l.RUnlock()
go webhooks.SendChatEventUserJoined(msg)
}
func (s *server) onConnection(ws *websocket.Conn) {
client := NewClient(ws)
defer func() {
s.removeClient(client)
if err := ws.Close(); err != nil {
log.Debugln(err)
//s.errCh <- err
}
}()
s.add(client)
client.listen()
}
// Listen and serve.
// It serves client connection and broadcast request.
func (s *server) Listen() {
http.Handle(s.pattern, websocket.Handler(s.onConnection))
log.Tracef("Starting the websocket listener on: %s", s.pattern)
for { for {
select { select {
// add new a client case client := <-s.unregister:
case c := <-s.addCh: if _, ok := s.clients[client.id]; ok {
l.Lock() s.mu.Lock()
s.Clients[c.socketID] = c delete(s.clients, client.id)
close(client.send)
if !c.Ignore { s.mu.Unlock()
s.listener.ClientAdded(c.GetViewerClientFromChatClient())
s.sendWelcomeMessageToClient(c)
}
l.Unlock()
// remove a client
case c := <-s.delCh:
s.removeClient(c)
case msg := <-s.sendAllCh:
if data.GetChatDisabled() {
break
} }
if !msg.Empty() { case message := <-s.inbound:
// set defaults before sending msg to anywhere s.eventReceived(message)
msg.SetDefaults()
s.listener.MessageSent(msg)
s.sendAll(msg)
// Store in the message history
if !msg.Ephemeral {
addMessage(msg)
}
// Send webhooks
go webhooks.SendChatEvent(msg)
}
case ping := <-s.pingCh:
fmt.Println("PING?", ping)
case err := <-s.errCh:
log.Trace("Error: ", err.Error())
case <-s.doneCh:
return
} }
} }
} }
func (s *server) removeClient(c *Client) { // Addclient registers new connection as a User.
l.Lock() func (s *ChatServer) Addclient(conn *websocket.Conn, user *user.User, accessToken string, userAgent string) *ChatClient {
if _, ok := s.Clients[c.socketID]; ok { client := &ChatClient{
delete(s.Clients, c.socketID) server: s,
conn: conn,
s.listener.ClientRemoved(c.socketID) User: user,
log.Tracef("The client was connected for %s and sent %d messages (%s)", time.Since(c.ConnectedAt), c.MessageCount, c.ClientID) ipAddress: conn.RemoteAddr().String(),
accessToken: accessToken,
send: make(chan []byte, 256),
UserAgent: userAgent,
ConnectedAt: time.Now(),
} }
l.Unlock()
s.mu.Lock()
{
client.id = s.seq
s.clients[client.id] = client
s.seq++
}
s.mu.Unlock()
log.Traceln("Adding client", client.id, "total count:", len(s.clients))
go client.writePump()
go client.readPump()
client.sendConnectedClientInfo()
if getStatus().Online {
s.sendUserJoinedMessage(client)
s.sendWelcomeMessageToClient(client)
}
return client
} }
func (s *server) sendWelcomeMessageToClient(c *Client) { func (s *ChatServer) sendUserJoinedMessage(c *ChatClient) {
go func() { userJoinedEvent := events.UserJoinedEvent{}
// Add an artificial delay so people notice this message come in. userJoinedEvent.SetDefaults()
time.Sleep(7 * time.Second) userJoinedEvent.User = c.User
welcomeMessage := data.GetServerWelcomeMessage() if err := s.Broadcast(userJoinedEvent.GetBroadcastPayload()); err != nil {
if welcomeMessage != "" { log.Errorln("error adding client to chat server", err)
initialMessage := models.ChatEvent{ClientID: "owncast-server", Author: data.GetServerName(), Body: welcomeMessage, ID: "initial-message-1", MessageType: "SYSTEM", Visible: true, Timestamp: time.Now()} }
c.write(initialMessage)
// Send chat user joined webhook
webhooks.SendChatEventUserJoined(userJoinedEvent)
}
func (s *ChatServer) ClientClosed(c *ChatClient) {
s.mu.Lock()
defer s.mu.Unlock()
c.close()
if _, ok := s.clients[c.id]; ok {
log.Debugln("Deleting", c.id)
delete(s.clients, c.id)
}
}
func (s *ChatServer) HandleClientConnection(w http.ResponseWriter, r *http.Request) {
if data.GetChatDisabled() {
_, _ = w.Write([]byte(events.ChatDisabled))
return
}
// Limit concurrent chat connections
if uint(len(s.clients)) >= s.maxClientCount {
log.Warnln("rejecting incoming client connection as it exceeds the max client count of", s.maxClientCount)
_, _ = w.Write([]byte(events.ErrorMaxConnectionsExceeded))
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Debugln(err)
return
}
accessToken := r.URL.Query().Get("accessToken")
if accessToken == "" {
log.Errorln("Access token is required")
// Return HTTP status code
conn.Close()
return
}
// A user is required to use the websocket
user := user.GetUserByToken(accessToken)
if user == nil {
_ = conn.WriteJSON(events.EventPayload{
"type": events.ErrorNeedsRegistration,
})
// Send error that registration is required
conn.Close()
return
}
// User is disabled therefore we should disconnect.
if user.DisabledAt != nil {
log.Traceln("Disabled user", user.Id, user.DisplayName, "rejected")
_ = conn.WriteJSON(events.EventPayload{
"type": events.ErrorUserDisabled,
})
conn.Close()
return
}
userAgent := r.UserAgent()
s.Addclient(conn, user, accessToken, userAgent)
}
// Broadcast sends message to all connected clients.
func (s *ChatServer) Broadcast(payload events.EventPayload) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, client := range s.clients {
if client == nil {
continue
} }
}()
select {
case client.send <- data:
default:
close(client.send)
delete(s.clients, client.id)
}
}
return nil
}
func (s *ChatServer) Send(payload events.EventPayload, client *ChatClient) {
data, err := json.Marshal(payload)
if err != nil {
log.Errorln(err)
return
}
client.send <- data
}
// DisconnectUser will forcefully disconnect all clients belonging to a user by ID.
func (s *ChatServer) DisconnectUser(userID string) {
s.mu.Lock()
clients, err := GetClientsForUser(userID)
s.mu.Unlock()
if err != nil || clients == nil || len(clients) == 0 {
log.Debugln("Requested to disconnect user", userID, err)
return
}
for _, client := range clients {
log.Traceln("Disconnecting client", client.User.Id, "owned by", client.User.DisplayName)
go func(client *ChatClient) {
event := events.UserDisabledEvent{}
event.SetDefaults()
// Send this disabled event specifically to this single connected client
// to let them know they've been banned.
_server.Send(event.GetBroadcastPayload(), client)
// Give the socket time to send out the above message.
// Unfortunately I don't know of any way to get a real callback to know when
// the message was successfully sent, so give it a couple seconds.
time.Sleep(2 * time.Second)
// Forcefully disconnect if still valid.
if client != nil {
client.close()
}
}(client)
}
}
func (s *ChatServer) eventReceived(event chatClientEvent) {
var typecheck map[string]interface{}
if err := json.Unmarshal(event.data, &typecheck); err != nil {
log.Debugln(err)
}
eventType := typecheck["type"]
switch eventType {
case events.MessageSent:
s.userMessageSent(event)
case events.UserNameChanged:
s.userNameChanged(event)
default:
log.Debugln(eventType, "event not found:", typecheck)
}
}
func (s *ChatServer) sendWelcomeMessageToClient(c *ChatClient) {
// Add an artificial delay so people notice this message come in.
time.Sleep(7 * time.Second)
welcomeMessage := utils.RenderSimpleMarkdown(data.GetServerWelcomeMessage())
if welcomeMessage != "" {
s.sendSystemMessageToClient(c, welcomeMessage)
}
}
func (s *ChatServer) sendAllWelcomeMessage() {
welcomeMessage := utils.RenderSimpleMarkdown(data.GetServerWelcomeMessage())
if welcomeMessage != "" {
clientMessage := events.SystemMessageEvent{
Event: events.Event{},
MessageEvent: events.MessageEvent{
Body: welcomeMessage,
},
}
clientMessage.SetDefaults()
_ = s.Broadcast(clientMessage.GetBroadcastPayload())
}
}
func (s *ChatServer) sendSystemMessageToClient(c *ChatClient, message string) {
clientMessage := events.SystemMessageEvent{
Event: events.Event{},
MessageEvent: events.MessageEvent{
Body: message,
},
}
clientMessage.SetDefaults()
s.Send(clientMessage.GetBroadcastPayload(), c)
}
func (s *ChatServer) sendActionToClient(c *ChatClient, message string) {
clientMessage := events.ActionEvent{
MessageEvent: events.MessageEvent{
Body: message,
},
}
clientMessage.SetDefaults()
clientMessage.RenderBody()
s.Send(clientMessage.GetBroadcastPayload(), c)
} }

29
core/chat/utils.go Normal file
View File

@@ -0,0 +1,29 @@
package chat
import (
"syscall"
log "github.com/sirupsen/logrus"
)
// Set the soft file handler limit as 70% of
// the max as the client connection limit.
func handleMaxConnectionCount() uint {
var rLimit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err != nil {
panic(err)
}
originalLimit := rLimit.Cur
// Set the limit to 70% of max so the machine doesn't die even if it's maxed out for some reason.
proposedLimit := int(float32(rLimit.Max) * 0.7)
rLimit.Cur = uint64(proposedLimit)
if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit); err != nil {
panic(err)
}
log.Traceln("Max process connection count increased from", originalLimit, "to", proposedLimit)
return uint(float32(rLimit.Cur))
}

View File

@@ -1,44 +0,0 @@
package core
import (
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/models"
)
// ChatListenerImpl the implementation of the chat client.
type ChatListenerImpl struct{}
// ClientAdded is for when a client is added the system.
func (cl ChatListenerImpl) ClientAdded(client models.Client) {
SetChatClientActive(client)
}
// ClientRemoved is for when a client disconnects/is removed.
func (cl ChatListenerImpl) ClientRemoved(clientID string) {
RemoveChatClient(clientID)
}
// MessageSent is for when a message is sent.
func (cl ChatListenerImpl) MessageSent(message models.ChatEvent) {
}
// IsStreamConnected will return if the stream is connected.
func (cl ChatListenerImpl) IsStreamConnected() bool {
return IsStreamConnected()
}
// SendMessageToChat sends a message to the chat server.
func SendMessageToChat(message models.ChatEvent) error {
chat.SendMessage(message)
return nil
}
// GetAllChatMessages gets all of the chat messages.
func GetAllChatMessages() []models.ChatEvent {
return chat.GetMessages()
}
func GetModerationChatMessages() []models.ChatEvent {
return chat.GetModerationChatMessages()
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/rtmp" "github.com/owncast/owncast/core/rtmp"
"github.com/owncast/owncast/core/transcoder" "github.com/owncast/owncast/core/transcoder"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
"github.com/owncast/owncast/yp" "github.com/owncast/owncast/yp"
@@ -53,6 +54,8 @@ func Start() error {
log.Errorln("storage error", err) log.Errorln("storage error", err)
} }
user.SetupUsers()
fileWriter.SetupFileWriterReceiverService(&handler) fileWriter.SetupFileWriterReceiverService(&handler)
if err := createInitialOfflineState(); err != nil { if err := createInitialOfflineState(); err != nil {
@@ -62,7 +65,9 @@ func Start() error {
_yp = yp.NewYP(GetStatus) _yp = yp.NewYP(GetStatus)
chat.Setup(ChatListenerImpl{}) if err := chat.Start(GetStatus); err != nil {
log.Errorln(err)
}
// start the rtmp server // start the rtmp server
go rtmp.Start(setStreamAsConnected, setBroadcaster) go rtmp.Start(setStreamAsConnected, setBroadcaster)

View File

@@ -1,198 +0,0 @@
package data
import (
"errors"
"strings"
"time"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
func createAccessTokensTable() {
log.Traceln("Creating access_tokens table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS access_tokens (
"token" string NOT NULL PRIMARY KEY,
"name" string,
"scopes" TEXT,
"timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP,
"last_used" DATETIME
);`
stmt, err := _db.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
if _, err := stmt.Exec(); err != nil {
log.Warnln(err)
}
}
// InsertToken will add a new token to the database.
func InsertToken(token string, name string, scopes []string) error {
log.Println("Adding new access token:", name)
scopesString := strings.Join(scopes, ",")
tx, err := _db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("INSERT INTO access_tokens(token, name, scopes) values(?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
if _, err := stmt.Exec(token, name, scopesString); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// DeleteToken will delete a token from the database.
func DeleteToken(token string) error {
log.Println("Deleting access token:", token)
tx, err := _db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("DELETE FROM access_tokens WHERE token = ?")
if err != nil {
return err
}
defer stmt.Close()
result, err := stmt.Exec(token)
if err != nil {
return err
}
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
tx.Rollback() //nolint
return errors.New(token + " not found")
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// DoesTokenSupportScope will determine if a specific token has access to perform a scoped action.
func DoesTokenSupportScope(token string, scope string) (bool, error) {
// This will split the scopes from comma separated to individual rows
// so we can efficiently find if a token supports a single scope.
// This is SQLite specific, so if we ever support other database
// backends we need to support other methods.
var query = `SELECT count(*) FROM (
WITH RECURSIVE split(token, scope, rest) AS (
SELECT token, '', scopes || ',' FROM access_tokens
UNION ALL
SELECT token,
substr(rest, 0, instr(rest, ',')),
substr(rest, instr(rest, ',')+1)
FROM split
WHERE rest <> '')
SELECT token, scope
FROM split
WHERE scope <> ''
ORDER BY token, scope
) AS token WHERE token.token = ? AND token.scope = ?;`
row := _db.QueryRow(query, token, scope)
var count = 0
err := row.Scan(&count)
return count > 0, err
}
// GetAccessTokens will return all access tokens.
func GetAccessTokens() ([]models.AccessToken, error) { //nolint
tokens := make([]models.AccessToken, 0)
// Get all messages sent within the past day
var query = "SELECT * FROM access_tokens"
rows, err := _db.Query(query)
if err != nil {
return tokens, err
}
defer rows.Close()
for rows.Next() {
var token string
var name string
var scopes string
var timestampString string
var lastUsedString *string
if err := rows.Scan(&token, &name, &scopes, &timestampString, &lastUsedString); err != nil {
log.Error("There is a problem reading the database.", err)
return tokens, err
}
timestamp, err := time.Parse(time.RFC3339, timestampString)
if err != nil {
return tokens, err
}
var lastUsed *time.Time = nil
if lastUsedString != nil {
lastUsedTime, _ := time.Parse(time.RFC3339, *lastUsedString)
lastUsed = &lastUsedTime
}
singleToken := models.AccessToken{
Name: name,
Token: token,
Scopes: strings.Split(scopes, ","),
Timestamp: timestamp,
LastUsed: lastUsed,
}
tokens = append(tokens, singleToken)
}
if err := rows.Err(); err != nil {
return tokens, err
}
return tokens, nil
}
// SetAccessTokenAsUsed will update the last used timestamp for a token.
func SetAccessTokenAsUsed(token string) error {
tx, err := _db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("UPDATE access_tokens SET last_used = CURRENT_TIMESTAMP WHERE token = ?")
if err != nil {
return err
}
defer stmt.Close()
if _, err := stmt.Exec(token); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}

View File

@@ -539,7 +539,9 @@ func VerifySettings() error {
if err := utils.Copy(defaultLogo, filepath.Join(config.DataDirectory, "logo.svg")); err != nil { if err := utils.Copy(defaultLogo, filepath.Join(config.DataDirectory, "logo.svg")); err != nil {
log.Errorln("error copying default logo: ", err) log.Errorln("error copying default logo: ", err)
} }
SetLogoPath("logo.svg") if err := SetLogoPath("logo.svg"); err != nil {
log.Errorln("unable to set default logo to logo.svg", err)
}
} }
return nil return nil
@@ -577,19 +579,25 @@ func FindHighestVideoQualityIndex(qualities []models.StreamOutputVariant) int {
return indexedQualities[0].index return indexedQualities[0].index
} }
// GetUsernameBlocklist will return the blocked usernames as a comma separated string. // GetForbiddenUsernameList will return the blocked usernames as a comma separated string.
func GetUsernameBlocklist() string { func GetForbiddenUsernameList() []string {
usernameString, err := _datastore.GetString(blockedUsernamesKey) usernameString, err := _datastore.GetString(blockedUsernamesKey)
if err != nil { if err != nil {
log.Traceln(blockedUsernamesKey, err) return config.DefaultForbiddenUsernames
return ""
} }
return usernameString if usernameString == "" {
return config.DefaultForbiddenUsernames
}
blocklist := strings.Split(usernameString, ",")
return blocklist
} }
// SetUsernameBlocklist set the username blocklist as a comma separated string. // SetForbiddenUsernameList set the username blocklist as a comma separated string.
func SetUsernameBlocklist(usernames string) error { func SetForbiddenUsernameList(usernames []string) error {
return _datastore.SetString(blockedUsernamesKey, usernames) usernameListString := strings.Join(usernames, ",")
return _datastore.SetString(blockedUsernamesKey, usernameListString)
} }

View File

@@ -17,7 +17,7 @@ import (
) )
const ( const (
schemaVersion = 0 schemaVersion = 1
) )
var _db *sql.DB var _db *sql.DB
@@ -45,7 +45,13 @@ func SetupPersistence(file string) error {
} }
} }
db, err := sql.Open("sqlite3", file) db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s", file))
db.SetMaxOpenConns(1)
_db = db
createWebhooksTable()
createUsersTable(db)
if err != nil { if err != nil {
return err return err
} }
@@ -86,11 +92,6 @@ func SetupPersistence(file string) error {
} }
} }
_db = db
createWebhooksTable()
createAccessTokensTable()
_datastore = &Datastore{} _datastore = &Datastore{}
_datastore.Setup() _datastore.Setup()
@@ -106,13 +107,14 @@ func SetupPersistence(file string) error {
} }
func migrateDatabase(db *sql.DB, from, to int) error { func migrateDatabase(db *sql.DB, from, to int) error {
log.Printf("Migrating database from version %d to %d\n", from, to) log.Printf("Migrating database from version %d to %d", from, to)
dbBackupFile := filepath.Join(config.BackupDirectory, fmt.Sprintf("owncast-v%d.bak", from)) dbBackupFile := filepath.Join(config.BackupDirectory, fmt.Sprintf("owncast-v%d.bak", from))
utils.Backup(db, dbBackupFile) utils.Backup(db, dbBackupFile)
for v := from; v < to; v++ { for v := from; v < to; v++ {
switch v { switch v {
case 0: case 0:
log.Printf("Migration step from %d to %d\n", v, v+1) log.Printf("Migration step from %d to %d", v, v+1)
migrateToSchema1(db)
default: default:
panic("missing database migration step") panic("missing database migration step")
} }

View File

@@ -2,13 +2,18 @@ package data
import ( import (
"fmt" "fmt"
"io/ioutil"
"os"
"testing" "testing"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
dbFile := "../../test/test.db" dbFile, err := ioutil.TempFile(os.TempDir(), "owncast-test-db.db")
if err != nil {
panic(err)
}
SetupPersistence(dbFile) SetupPersistence(dbFile.Name())
m.Run() m.Run()
} }

118
core/data/migrations.go Normal file
View File

@@ -0,0 +1,118 @@
package data
import (
"database/sql"
"time"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
)
func migrateToSchema1(db *sql.DB) {
// Since it's just a backlog of chat messages let's wipe the old messages
// and recreate the table.
// Drop the old messages table
stmt, err := db.Prepare("DROP TABLE messages")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
// Recreate it
createUsersTable(db)
// Migrate access tokens to become chat users
type oldAccessToken struct {
accessToken string
displayName string
scopes string
createdAt time.Time
lastUsedAt *time.Time
}
oldAccessTokens := make([]oldAccessToken, 0)
query := `SELECT * FROM access_tokens`
rows, err := db.Query(query)
if err != nil || rows.Err() != nil {
log.Errorln("error migrating access tokens to schema v1", err, rows.Err())
return
}
defer rows.Close()
for rows.Next() {
var token string
var name string
var scopes string
var timestampString string
var lastUsedString *string
if err := rows.Scan(&token, &name, &scopes, &timestampString, &lastUsedString); err != nil {
log.Error("There is a problem reading the database.", err)
return
}
timestamp, err := time.Parse(time.RFC3339, timestampString)
if err != nil {
return
}
var lastUsed *time.Time = nil
if lastUsedString != nil {
lastUsedTime, _ := time.Parse(time.RFC3339, *lastUsedString)
lastUsed = &lastUsedTime
}
oldToken := oldAccessToken{
accessToken: token,
displayName: name,
scopes: scopes,
createdAt: timestamp,
lastUsedAt: lastUsed,
}
oldAccessTokens = append(oldAccessTokens, oldToken)
}
// Recreate them as users
for _, token := range oldAccessTokens {
color := utils.GenerateRandomDisplayColor()
if err := insertAPIToken(db, token.accessToken, token.displayName, color, token.scopes); err != nil {
log.Errorln("Error migrating access token", err)
}
}
}
func insertAPIToken(db *sql.DB, token string, name string, color int, scopes string) error {
log.Debugln("Adding new access token:", name)
id := shortid.MustGenerate()
tx, err := db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, scopes, type) values(?, ?, ?, ?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
if _, err = stmt.Exec(id, token, name, color, scopes, "API"); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"database/sql" "database/sql"
"encoding/gob" "encoding/gob"
"sync"
// sqlite requires a blank import. // sqlite requires a blank import.
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@@ -12,14 +13,15 @@ import (
// Datastore is the global key/value store for configuration values. // Datastore is the global key/value store for configuration values.
type Datastore struct { type Datastore struct {
db *sql.DB DB *sql.DB
cache map[string][]byte cache map[string][]byte
DbLock *sync.Mutex
} }
func (ds *Datastore) warmCache() { func (ds *Datastore) warmCache() {
log.Traceln("Warming config value cache") log.Traceln("Warming config value cache")
res, err := ds.db.Query("SELECT key, value FROM datastore") res, err := ds.DB.Query("SELECT key, value FROM datastore")
if err != nil || res.Err() != nil { if err != nil || res.Err() != nil {
log.Errorln("error warming config cache", err, res.Err()) log.Errorln("error warming config cache", err, res.Err())
} }
@@ -48,7 +50,7 @@ func (ds *Datastore) Get(key string) (ConfigEntry, error) {
var resultKey string var resultKey string
var resultValue []byte var resultValue []byte
row := ds.db.QueryRow("SELECT key, value FROM datastore WHERE key = ? LIMIT 1", key) row := ds.DB.QueryRow("SELECT key, value FROM datastore WHERE key = ? LIMIT 1", key)
if err := row.Scan(&resultKey, &resultValue); err != nil { if err := row.Scan(&resultKey, &resultValue); err != nil {
return ConfigEntry{}, err return ConfigEntry{}, err
} }
@@ -63,36 +65,26 @@ func (ds *Datastore) Get(key string) (ConfigEntry, error) {
// Save will save the ConfigEntry to the database. // Save will save the ConfigEntry to the database.
func (ds *Datastore) Save(e ConfigEntry) error { func (ds *Datastore) Save(e ConfigEntry) error {
ds.DbLock.Lock()
defer ds.DbLock.Unlock()
var dataGob bytes.Buffer var dataGob bytes.Buffer
enc := gob.NewEncoder(&dataGob) enc := gob.NewEncoder(&dataGob)
if err := enc.Encode(e.Value); err != nil { if err := enc.Encode(e.Value); err != nil {
return err return err
} }
tx, err := ds.db.Begin() tx, err := ds.DB.Begin()
if err != nil { if err != nil {
return err return err
} }
var stmt *sql.Stmt var stmt *sql.Stmt
var count int stmt, err = tx.Prepare("INSERT INTO datastore (key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
row := ds.db.QueryRow("SELECT COUNT(*) FROM datastore WHERE key = ? LIMIT 1", e.Key) if err != nil {
if err := row.Scan(&count); err != nil {
return err return err
} }
_, err = stmt.Exec(e.Key, dataGob.Bytes())
if count == 0 {
stmt, err = tx.Prepare("INSERT INTO datastore(key, value) values(?, ?)")
if err != nil {
return err
}
_, err = stmt.Exec(e.Key, dataGob.Bytes())
} else {
stmt, err = tx.Prepare("UPDATE datastore SET value=? WHERE key=?")
if err != nil {
return err
}
_, err = stmt.Exec(dataGob.Bytes(), e.Key)
}
if err != nil { if err != nil {
return err return err
} }
@@ -110,7 +102,8 @@ func (ds *Datastore) Save(e ConfigEntry) error {
// Setup will create the datastore table and perform initial initialization. // Setup will create the datastore table and perform initial initialization.
func (ds *Datastore) Setup() { func (ds *Datastore) Setup() {
ds.cache = make(map[string][]byte) ds.cache = make(map[string][]byte)
ds.db = GetDatabase() ds.DB = GetDatabase()
ds.DbLock = &sync.Mutex{}
createTableSQL := `CREATE TABLE IF NOT EXISTS datastore ( createTableSQL := `CREATE TABLE IF NOT EXISTS datastore (
"key" string NOT NULL PRIMARY KEY, "key" string NOT NULL PRIMARY KEY,
@@ -118,7 +111,7 @@ func (ds *Datastore) Setup() {
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
);` );`
stmt, err := ds.db.Prepare(createTableSQL) stmt, err := ds.DB.Prepare(createTableSQL)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -137,7 +130,7 @@ func (ds *Datastore) Setup() {
// Reset will delete all config entries in the datastore and start over. // Reset will delete all config entries in the datastore and start over.
func (ds *Datastore) Reset() { func (ds *Datastore) Reset() {
sql := "DELETE FROM datastore" sql := "DELETE FROM datastore"
stmt, err := ds.db.Prepare(sql) stmt, err := ds.DB.Prepare(sql)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
@@ -150,3 +143,7 @@ func (ds *Datastore) Reset() {
PopulateDefaults() PopulateDefaults()
} }
func GetDatastore() *Datastore {
return _datastore
}

37
core/data/users.go Normal file
View File

@@ -0,0 +1,37 @@
package data
import (
"database/sql"
log "github.com/sirupsen/logrus"
)
func createUsersTable(db *sql.DB) {
log.Traceln("Creating users table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS users (
"id" TEXT,
"access_token" string NOT NULL,
"display_name" TEXT NOT NULL,
"display_color" NUMBER NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"previous_names" TEXT DEFAULT '',
"namechanged_at" TIMESTAMP,
"scopes" TEXT,
"type" TEXT DEFAULT 'STANDARD',
"last_used" DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, access_token),
UNIQUE(id, access_token)
);CREATE INDEX index ON users (id, access_token)`
stmt, err := db.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
}

View File

@@ -33,7 +33,7 @@ func createWebhooksTable() {
// InsertWebhook will add a new webhook to the database. // InsertWebhook will add a new webhook to the database.
func InsertWebhook(url string, events []models.EventType) (int, error) { func InsertWebhook(url string, events []models.EventType) (int, error) {
log.Println("Adding new webhook:", url) log.Traceln("Adding new webhook:", url)
eventsString := strings.Join(events, ",") eventsString := strings.Join(events, ",")
@@ -67,7 +67,7 @@ func InsertWebhook(url string, events []models.EventType) (int, error) {
// DeleteWebhook will delete a webhook from the database. // DeleteWebhook will delete a webhook from the database.
func DeleteWebhook(id int) error { func DeleteWebhook(id int) error {
log.Println("Deleting webhook:", id) log.Traceln("Deleting webhook:", id)
tx, err := _db.Begin() tx, err := _db.Begin()
if err != nil { if err != nil {
@@ -86,7 +86,7 @@ func DeleteWebhook(id int) error {
} }
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 { if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
tx.Rollback() //nolint _ = tx.Rollback()
return errors.New(fmt.Sprint(id) + " not found") return errors.New(fmt.Sprint(id) + " not found")
} }

View File

@@ -7,7 +7,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/geoip" "github.com/owncast/owncast/geoip"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
@@ -86,22 +85,6 @@ func RemoveChatClient(clientID string) {
l.Unlock() l.Unlock()
} }
func GetChatClients() []models.Client {
l.RLock()
clients := make([]models.Client, 0)
for _, client := range _stats.ChatClients {
chatClient := chat.GetClient(client.ClientID)
if chatClient != nil {
clients = append(clients, chatClient.GetViewerClientFromChatClient())
} else {
clients = append(clients, client)
}
}
l.RUnlock()
return clients
}
// SetViewerIdActive sets a client as active and connected. // SetViewerIdActive sets a client as active and connected.
func SetViewerIdActive(id string) { func SetViewerIdActive(id string) {
l.Lock() l.Lock()

View File

@@ -11,6 +11,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/config" "github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/rtmp" "github.com/owncast/owncast/core/rtmp"
"github.com/owncast/owncast/core/transcoder" "github.com/owncast/owncast/core/transcoder"
@@ -72,10 +73,15 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) {
go webhooks.SendStreamStatusEvent(models.StreamStarted) go webhooks.SendStreamStatusEvent(models.StreamStarted)
transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings)) transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings))
_ = chat.SendSystemAction("Stay tuned, the stream is starting!", true)
chat.SendAllWelcomeMessage()
} }
// SetStreamAsDisconnected sets the stream as disconnected. // SetStreamAsDisconnected sets the stream as disconnected.
func SetStreamAsDisconnected() { func SetStreamAsDisconnected() {
_ = chat.SendSystemAction("The stream is ending.", true)
_stats.StreamConnected = false _stats.StreamConnected = false
_stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: true} _stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: true}
_broadcaster = nil _broadcaster = nil

View File

@@ -0,0 +1,264 @@
package user
import (
"database/sql"
"errors"
"strings"
"time"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
)
// ExternalAPIUser represents a single 3rd party integration that uses an access token.
// This struct mostly matches the User struct so they can be used interchangeably.
type ExternalAPIUser struct {
Id string `json:"id"`
AccessToken string `json:"accessToken"`
DisplayName string `json:"displayName"`
DisplayColor int `json:"displayColor"`
CreatedAt time.Time `json:"createdAt"`
Scopes []string `json:"scopes"`
Type string `json:"type,omitempty"` // Should be API
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
const (
// ScopeCanSendChatMessages will allow sending chat messages as itself.
ScopeCanSendChatMessages = "CAN_SEND_MESSAGES"
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
// ScopeHasAdminAccess will allow performing administrative actions on the server.
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
)
// For a scope to be seen as "valid" it must live in this slice.
var validAccessTokenScopes = []string{
ScopeCanSendChatMessages,
ScopeCanSendSystemMessages,
ScopeHasAdminAccess,
}
// InsertToken will add a new token to the database.
func InsertExternalAPIUser(token string, name string, color int, scopes []string) error {
log.Traceln("Adding new API user:", name)
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
scopesString := strings.Join(scopes, ",")
id := shortid.MustGenerate()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
if _, err = stmt.Exec(id, token, name, color, scopesString, "API", name); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// DeleteExternalAPIUser will delete a token from the database.
func DeleteExternalAPIUser(token string) error {
log.Traceln("Deleting access token:", token)
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("UPDATE users SET disabled_at = ? WHERE access_token = ?")
if err != nil {
return err
}
defer stmt.Close()
result, err := stmt.Exec(time.Now(), token)
if err != nil {
return err
}
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
tx.Rollback() //nolint
return errors.New(token + " not found")
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
// GetExternalAPIUserForAccessTokenAndScope will determine if a specific token has access to perform a scoped action.
func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*ExternalAPIUser, error) {
// This will split the scopes from comma separated to individual rows
// so we can efficiently find if a token supports a single scope.
// This is SQLite specific, so if we ever support other database
// backends we need to support other methods.
var query = `SELECT id, access_token, scopes, display_name, display_color, created_at, last_used FROM (
WITH RECURSIVE split(id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS (
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users
UNION ALL
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at,
substr(rest, 0, instr(rest, ',')),
substr(rest, instr(rest, ',')+1)
FROM split
WHERE rest <> '')
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope
FROM split
WHERE scope <> ''
ORDER BY access_token, scope
) AS token WHERE token.access_token = ? AND token.scope = ?`
row := _datastore.DB.QueryRow(query, token, scope)
integration, err := makeExternalAPIUserFromRow(row)
return integration, err
}
// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token.
func GetIntegrationNameForAccessToken(token string) *string {
query := "SELECT display_name FROM users WHERE access_token IS ? AND disabled_at IS NULL"
row := _datastore.DB.QueryRow(query, token)
var name string
err := row.Scan(&name)
if err != nil {
log.Warnln(err)
return nil
}
return &name
}
// GetExternalAPIUser will return all access tokens.
func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint
// Get all messages sent within the past day
var query = "SELECT id, access_token, display_name, display_color, scopes, created_at, last_used FROM users WHERE type IS 'API' AND disabled_at IS NULL"
rows, err := _datastore.DB.Query(query)
if err != nil {
return []ExternalAPIUser{}, err
}
defer rows.Close()
integrations, err := makeExternalAPIUsersFromRows(rows)
return integrations, err
}
// SetExternalAPIUserAccessTokenAsUsed will update the last used timestamp for a token.
func SetExternalAPIUserAccessTokenAsUsed(token string) error {
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?")
if err != nil {
return err
}
defer stmt.Close()
if _, err := stmt.Exec(token); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) {
var id string
var accessToken string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
err := row.Scan(&id, &accessToken, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt)
if err != nil {
log.Errorln(err)
return nil, err
}
integration := ExternalAPIUser{
Id: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt,
}
return &integration, nil
}
func makeExternalAPIUsersFromRows(rows *sql.Rows) ([]ExternalAPIUser, error) {
integrations := make([]ExternalAPIUser, 0)
for rows.Next() {
var id string
var accessToken string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
err := rows.Scan(&id, &accessToken, &displayName, &displayColor, &scopes, &createdAt, &lastUsedAt)
if err != nil {
log.Errorln(err)
return nil, err
}
integration := ExternalAPIUser{
Id: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
Scopes: strings.Split(scopes, ","),
LastUsedAt: lastUsedAt,
}
integrations = append(integrations, integration)
}
return integrations, nil
}
// HasValidScopes will verify that all the scopes provided are valid.
func HasValidScopes(scopes []string) bool {
for _, scope := range scopes {
_, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope)
if !foundInSlice {
log.Errorln("Invalid scope", scope)
return false
}
}
return true
}

261
core/user/user.go Normal file
View File

@@ -0,0 +1,261 @@
package user
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
"github.com/teris-io/shortid"
log "github.com/sirupsen/logrus"
)
var _datastore *data.Datastore
type User struct {
Id string `json:"id"`
AccessToken string `json:"-"`
DisplayName string `json:"displayName"`
DisplayColor int `json:"displayColor"`
CreatedAt time.Time `json:"createdAt"`
DisabledAt *time.Time `json:"disabledAt,omitempty"`
PreviousNames []string `json:"previousNames"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
}
func (u *User) IsEnabled() bool {
return u.DisabledAt == nil
}
func SetupUsers() {
_datastore = data.GetDatastore()
}
func CreateAnonymousUser(username string) (*User, error) {
id := shortid.MustGenerate()
accessToken, err := utils.GenerateAccessToken()
if err != nil {
log.Errorln("Unable to create access token for new user")
return nil, err
}
var displayName = username
if displayName == "" {
displayName = utils.GeneratePhrase()
}
displayColor := utils.GenerateRandomDisplayColor()
user := &User{
Id: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: time.Now(),
}
if err := create(user); err != nil {
return nil, err
}
return user, nil
}
func ChangeUsername(userId string, username string) {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
log.Debugln(err)
}
}()
stmt, err := tx.Prepare("UPDATE users SET display_name = ?, previous_names = previous_names || ?, namechanged_at = ? WHERE id = ?")
if err != nil {
log.Debugln(err)
}
defer stmt.Close()
_, err = stmt.Exec(username, fmt.Sprintf(",%s", username), time.Now(), userId)
if err != nil {
log.Errorln(err)
}
if err := tx.Commit(); err != nil {
log.Errorln("error changing display name of user", userId, err)
}
}
func create(user *User) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?, ?)")
if err != nil {
log.Debugln(err)
}
defer stmt.Close()
_, err = stmt.Exec(user.Id, user.AccessToken, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
if err != nil {
log.Errorln("error creating new user", err)
}
return tx.Commit()
}
func SetEnabled(userID string, enabled bool) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint
var stmt *sql.Stmt
if !enabled {
stmt, err = tx.Prepare("UPDATE users SET disabled_at=DATETIME('now', 'localtime') WHERE id IS ?")
} else {
stmt, err = tx.Prepare("UPDATE users SET disabled_at=null WHERE id IS ?")
}
if err != nil {
return err
}
defer stmt.Close()
if _, err := stmt.Exec(userID); err != nil {
return err
}
return tx.Commit()
}
// GetUserByToken will return a user by an access token.
func GetUserByToken(token string) *User {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE access_token = ?"
row := _datastore.DB.QueryRow(query, token)
return getUserFromRow(row)
}
// GetUserById will return a user by a user ID.
func GetUserById(id string) *User {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE id = ?"
row := _datastore.DB.QueryRow(query, id)
if row == nil {
log.Errorln(row)
return nil
}
return getUserFromRow(row)
}
// GetDisabledUsers will return back all the currently disabled users that are not API users.
func GetDisabledUsers() []*User {
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'"
rows, err := _datastore.DB.Query(query)
if err != nil {
log.Errorln(err)
return nil
}
defer rows.Close()
users := getUsersFromRows(rows)
sort.Slice(users, func(i, j int) bool {
return users[i].DisabledAt.Before(*users[j].DisabledAt)
})
return users
}
func getUsersFromRows(rows *sql.Rows) []*User {
users := make([]*User, 0)
for rows.Next() {
var id string
var displayName string
var displayColor int
var createdAt time.Time
var disabledAt *time.Time
var previousUsernames string
var userNameChangedAt *time.Time
if err := rows.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil {
log.Errorln("error creating collection of users from results", err)
return nil
}
user := &User{
Id: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: disabledAt,
PreviousNames: strings.Split(previousUsernames, ","),
NameChangedAt: userNameChangedAt,
}
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.Before(users[j].CreatedAt)
})
return users
}
func getUserFromRow(row *sql.Row) *User {
var id string
var displayName string
var displayColor int
var createdAt time.Time
var disabledAt *time.Time
var previousUsernames string
var userNameChangedAt *time.Time
if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil {
return nil
}
return &User{
Id: id,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: disabledAt,
PreviousNames: strings.Split(previousUsernames, ","),
NameChangedAt: userNameChangedAt,
}
}

View File

@@ -1,18 +1,19 @@
package webhooks package webhooks
import ( import (
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
) )
func SendChatEvent(chatEvent models.ChatEvent) { func SendChatEvent(chatEvent *events.UserMessageEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: chatEvent.MessageType, Type: chatEvent.GetMessageType(),
EventData: &WebhookChatMessage{ EventData: &WebhookChatMessage{
Author: chatEvent.Author, User: chatEvent.User,
Body: chatEvent.Body, Body: chatEvent.Body,
RawBody: chatEvent.RawBody, RawBody: chatEvent.RawBody,
ID: chatEvent.ID, ID: chatEvent.Id,
Visible: chatEvent.Visible, Visible: chatEvent.HiddenAt == nil,
Timestamp: &chatEvent.Timestamp, Timestamp: &chatEvent.Timestamp,
}, },
} }
@@ -20,7 +21,7 @@ func SendChatEvent(chatEvent models.ChatEvent) {
SendEventToWebhooks(webhookEvent) SendEventToWebhooks(webhookEvent)
} }
func SendChatEventUsernameChanged(event models.NameChangeEvent) { func SendChatEventUsernameChanged(event events.NameChangeEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.UserNameChanged, Type: models.UserNameChanged,
EventData: event, EventData: event,
@@ -29,7 +30,7 @@ func SendChatEventUsernameChanged(event models.NameChangeEvent) {
SendEventToWebhooks(webhookEvent) SendEventToWebhooks(webhookEvent)
} }
func SendChatEventUserJoined(event models.UserJoinedEvent) { func SendChatEventUserJoined(event events.UserJoinedEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.UserNameChanged, Type: models.UserNameChanged,
EventData: event, EventData: event,

View File

@@ -8,6 +8,8 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
) )
@@ -18,7 +20,7 @@ type WebhookEvent struct {
} }
type WebhookChatMessage struct { type WebhookChatMessage struct {
Author string `json:"author,omitempty"` User *user.User `json:"user,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
RawBody string `json:"rawBody,omitempty"` RawBody string `json:"rawBody,omitempty"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`

File diff suppressed because one or more lines are too long

3
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5 github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5
github.com/aws/aws-sdk-go v1.40.0 github.com/aws/aws-sdk-go v1.40.0
github.com/go-ole/go-ole v1.2.4 // indirect github.com/go-ole/go-ole v1.2.4 // indirect
github.com/gorilla/websocket v1.4.2
github.com/grafov/m3u8 v0.11.1 github.com/grafov/m3u8 v0.11.1
github.com/jonboulle/clockwork v0.2.2 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
@@ -27,7 +28,7 @@ require (
github.com/tklauser/go-sysconf v0.3.5 // indirect github.com/tklauser/go-sysconf v0.3.5 // indirect
github.com/yuin/goldmark v1.4.0 github.com/yuin/goldmark v1.4.0
golang.org/x/mod v0.4.2 golang.org/x/mod v0.4.2
golang.org/x/net v0.0.0-20210614182718-04defd469f4e golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 golang.org/x/time v0.0.0-20201208040808-7e3f01d25324
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect

5
go.sum
View File

@@ -16,6 +16,8 @@ github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA= github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -99,8 +101,9 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=

View File

@@ -38,8 +38,6 @@ func main() {
config.LogDirectory = *logDirectory config.LogDirectory = *logDirectory
} }
log.Infoln(config.GetReleaseString())
if *backupDirectory != "" { if *backupDirectory != "" {
config.BackupDirectory = *backupDirectory config.BackupDirectory = *backupDirectory
} }
@@ -52,6 +50,7 @@ func main() {
} }
configureLogging(*enableDebugOptions, *enableVerboseLogging) configureLogging(*enableDebugOptions, *enableVerboseLogging)
log.Infoln(config.GetReleaseString())
// Allows a user to restore a specific database backup // Allows a user to restore a specific database backup
if *restoreDatabaseFile != "" { if *restoreDatabaseFile != "" {

View File

@@ -1,53 +0,0 @@
package models
import (
"time"
log "github.com/sirupsen/logrus"
)
const (
// ScopeCanSendUserMessages will allow sending chat messages as users.
ScopeCanSendUserMessages = "CAN_SEND_MESSAGES"
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
// ScopeHasAdminAccess will allow performing administrative actions on the server.
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
)
// For a scope to be seen as "valid" it must live in this slice.
var validAccessTokenScopes = []string{
ScopeCanSendUserMessages,
ScopeCanSendSystemMessages,
ScopeHasAdminAccess,
}
// AccessToken gives access to 3rd party code to access specific Owncast APIs.
type AccessToken struct {
Token string `json:"token"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
Timestamp time.Time `json:"timestamp"`
LastUsed *time.Time `json:"lastUsed"`
}
// HasValidScopes will verify that all the scopes provided are valid.
// This is not a efficient method.
func HasValidScopes(scopes []string) bool {
for _, scope := range scopes {
if !findItemInSlice(validAccessTokenScopes, scope) {
log.Errorln("Invalid scope", scope)
return false
}
}
return true
}
func findItemInSlice(slice []string, value string) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
}

View File

@@ -1,9 +0,0 @@
package models
// ChatListener represents the listener for the chat server.
type ChatListener interface {
ClientAdded(client Client)
ClientRemoved(clientID string)
MessageSent(message ChatEvent)
IsStreamConnected() bool
}

View File

@@ -1,10 +0,0 @@
package models
// NameChangeEvent represents a user changing their name in chat.
type NameChangeEvent struct {
OldName string `json:"oldName"`
NewName string `json:"newName"`
Image string `json:"image"`
Type EventType `json:"type"`
ID string `json:"id"`
}

View File

@@ -1,6 +1,10 @@
package models package models
import "time" import (
"time"
"github.com/owncast/owncast/utils"
)
// Webhook is an event that is sent to 3rd party, external services with details about something that took place within an Owncast server. // Webhook is an event that is sent to 3rd party, external services with details about something that took place within an Owncast server.
type Webhook struct { type Webhook struct {
@@ -25,7 +29,7 @@ var validEvents = []EventType{
// This is not a efficient method. // This is not a efficient method.
func HasValidEvents(events []EventType) bool { func HasValidEvents(events []EventType) bool {
for _, event := range events { for _, event := range events {
if !findItemInSlice(validEvents, event) { if _, foundInSlice := utils.FindInSlice(validEvents, event); !foundInSlice {
return false return false
} }
} }

View File

@@ -2,7 +2,7 @@ openapi: 3.0.1
info: info:
title: Owncast title: Owncast
description: Owncast is a self-hosted live video and web chat server for use with existing popular broadcasting software. The following APIs represent the state in the development branch. description: Owncast is a self-hosted live video and web chat server for use with existing popular broadcasting software. The following APIs represent the state in the development branch.
version: '0.0.7' version: '0.0.8-develop'
contact: contact:
name: Gabe Kangas name: Gabe Kangas
email: gabek@real-ity.com email: gabek@real-ity.com
@@ -27,6 +27,11 @@ components:
items: items:
$ref: "#/components/schemas/Client" $ref: "#/components/schemas/Client"
UserArray:
type: array
items:
$ref: "#/components/schemas/User"
LogEntryArray: LogEntryArray:
type: array type: array
items: items:
@@ -58,6 +63,7 @@ components:
userAgent: userAgent:
description: The web client used to connect to this server description: The web client used to connect to this server
type: string type: string
example: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
ipAddress: ipAddress:
description: The public IP address of this client description: The public IP address of this client
type: string type: string
@@ -77,6 +83,8 @@ components:
type: string type: string
timeZone: timeZone:
type: string type: string
user:
$ref: "#/components/schemas/User"
x-last-modified: 1602052347511 x-last-modified: 1602052347511
BasicResponse: BasicResponse:
@@ -242,6 +250,30 @@ components:
format: date-time format: date-time
description: When this webhook was last used. description: When this webhook was last used.
User:
type: object
properties:
id:
type: string
description: User ID
example: yklw5Imng
displayName:
type: string
description: The user-facing disaplay name for this user.
example: awesome-pizza
displayColor:
type: integer
description: Hue value for displaying in the UI.
example: 42
createdAt:
type: string
format: date-time
description: When this account was originally registered/created.
previousNames:
type: string
description: Comma separated list of names previously used by this user.
example: "awesome-pizza,user42"
securitySchemes: securitySchemes:
AdminBasicAuth: AdminBasicAuth:
type: http type: http
@@ -251,8 +283,20 @@ components:
type: http type: http
scheme: bearer scheme: bearer
description: 3rd party integration auth where a service user must provide an access token. description: 3rd party integration auth where a service user must provide an access token.
UserToken:
type: apiKey
name: accessToken
in: query
description: 3rd party integration auth where a service user must provide an access token.
responses: responses:
UsersResponse:
description: A collection of users.
content:
application/json:
schema:
$ref: "#/components/schemas/UserArray"
ClientsResponse: ClientsResponse:
description: Successful response of an array of clients description: Successful response of an array of clients
content: content:
@@ -266,12 +310,16 @@ components:
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36
(KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36
ipAddress: "172.217.164.110" ipAddress: "172.217.164.110"
username: coolperson42
clientID: 2ba20dd34f911c198df3218ddc64c740
geo: geo:
countryCode: US countryCode: US
regionName: California regionName: California
timeZone: America/Los_Angeles timeZone: America/Los_Angeles
user:
id: yklw5Imng
displayName: awesome-pizza
displayColor: 42
createdAt: "2021-07-08T20:21:25.303402404-07:00"
previousNames: "awesome-pizza,coolPerson23"
LogsResponse: LogsResponse:
description: Response of server log entries description: Response of server log entries
@@ -337,6 +385,15 @@ paths:
schema: schema:
$ref: "#/components/schemas/InstanceDetails" $ref: "#/components/schemas/InstanceDetails"
/api/ping:
get:
summary: Mark the current viewer as active.
description: For tracking viewer count, periodically hit the ping endpoint.
tags: ["Server"]
responses:
"200":
description: "Successful ping"
/api/status: /api/status:
get: get:
summary: Current Status summary: Current Status
@@ -376,11 +433,48 @@ paths:
sessionMaxViewerCount: 12 sessionMaxViewerCount: 12
viewerCount: 7 viewerCount: 7
/api/chat/register:
post:
summary: Register a chat user
description: Register a user that returns an access token for accessing chat.
tags: ["Chat"]
security:
- UserToken: []
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
displayName:
type: string
description: Optionally provide a display name you want to assign to this user when registering.
responses:
"200":
description: ""
content:
application/json:
schema:
type: object
properties:
id:
type: string
description: The new user's id.
accessToken:
type: string
description: The access token used for accessing chat.
displayName:
type: string
description: The user-facing name displayed for this user.
/api/chat: /api/chat:
get: get:
summary: Historical Chat Messages summary: Chat Messages Backlog
description: Used to get all chat messages prior to connecting to the websocket. description: Used to get chat messages prior to connecting to the websocket.
tags: ["Chat"] tags: ["Chat"]
security:
- UserToken: []
responses: responses:
"200": "200":
description: "" description: ""
@@ -586,6 +680,17 @@ paths:
"200": "200":
$ref: "#/components/responses/ClientsResponse" $ref: "#/components/responses/ClientsResponse"
/api/admin/users/disabled:
get:
summary: Return a list of currently connected clients
description: Return a list of currently connected clients with optional geo details.
tags: ["Admin"]
security:
- AdminBasicAuth: []
responses:
"200":
$ref: "#/components/responses/UsersResponse"
/api/admin/logs: /api/admin/logs:
get: get:
summary: Return recent log entries summary: Return recent log entries
@@ -708,6 +813,32 @@ paths:
"200": "200":
$ref: "#/components/responses/BasicResponse" $ref: "#/components/responses/BasicResponse"
/api/admin/chat/users/setenabled:
post:
summary: Enable or disable a single user.
description: Enable or disable a single user. Disabling will also hide all the user's chat messages.
requestBody:
content:
application/json:
schema:
type: object
properties:
userId:
type: string
description: User ID to act upon.
example: "yklw5Imng"
enabled:
type: boolean
description: Set the enabled state of this user.
tags: ["Admin"]
security:
- AdminBasicAuth: []
responses:
"200":
$ref: "#/components/responses/BasicResponse"
/api/admin/config/key: /api/admin/config/key:
post: post:
summary: Set the stream key. summary: Set the stream key.
@@ -1170,10 +1301,10 @@ paths:
value: Streaming my favorite game, Desert Bus. value: Streaming my favorite game, Desert Bus.
/api/integrations/chat/user: /api/integrations/chat/send:
post: post:
summary: Send a user chat message. summary: Send a chat message.
description: Send a chat message on behalf of a user. Could be a bot name or a real user. description: Send a chat message on behalf of a 3rd party integration, bot or service.
tags: ["Integrations"] tags: ["Integrations"]
security: security:
- AccessToken: [] - AccessToken: []
@@ -1184,9 +1315,6 @@ paths:
schema: schema:
type: object type: object
properties: properties:
user:
type: string
description: The user you want to send this message as.
body: body:
type: string type: string
description: The message text that will be sent as the user. description: The message text that will be sent as the user.

File diff suppressed because one or more lines are too long

View File

@@ -6,9 +6,12 @@ import (
"strings" "strings"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request)
// RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given // RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given
// the stream key as the password and and a hardcoded "admin" for username. // the stream key as the password and and a hardcoded "admin" for username.
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc { func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
@@ -45,33 +48,54 @@ func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
} }
} }
func RequireAccessToken(scope string, handler http.HandlerFunc) http.HandlerFunc { func accessDenied(w http.ResponseWriter) {
w.WriteHeader(http.StatusUnauthorized) //nolint
w.Write([]byte("unauthorized")) //nolint
}
// RequireExternalAPIAccessToken will validate a 3rd party access token.
func RequireExternalAPIAccessToken(scope string, handler ExternalAccessTokenHandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := strings.Split(r.Header.Get("Authorization"), "Bearer ") authHeader := strings.Split(r.Header.Get("Authorization"), "Bearer ")
token := strings.Join(authHeader, "") token := strings.Join(authHeader, "")
if len(authHeader) == 0 || token == "" { if len(authHeader) == 0 || token == "" {
log.Warnln("invalid access token") log.Warnln("invalid access token")
w.WriteHeader(http.StatusUnauthorized) //nolint accessDenied(w)
w.Write([]byte("invalid access token")) //nolint
return return
} }
if accepted, err := data.DoesTokenSupportScope(token, scope); err != nil { integration, err := user.GetExternalAPIUserForAccessTokenAndScope(token, scope)
w.WriteHeader(http.StatusInternalServerError) //nolint if integration == nil || err != nil {
w.Write([]byte(err.Error())) //nolint accessDenied(w)
return return
} else if !accepted { }
log.Warnln("invalid access token")
w.WriteHeader(http.StatusUnauthorized) //nolint handler(*integration, w, r)
w.Write([]byte("invalid access token")) //nolint
if err := user.SetExternalAPIUserAccessTokenAsUsed(token); err != nil {
log.Debugln("token not found when updating last_used timestamp")
}
})
}
// RequireUserAccessToken will validate a provided user's access token and make sure the associated user is enabled.
// Not to be used for validating 3rd party access.
func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessToken := r.URL.Query().Get("accessToken")
if accessToken == "" {
accessDenied(w)
return
}
// A user is required to use the websocket
user := user.GetUserByToken(accessToken)
if user == nil || !user.IsEnabled() {
accessDenied(w)
return return
} }
handler(w, r) handler(w, r)
if err := data.SetAccessTokenAsUsed(token); err != nil {
log.Debugln(token, "not found when updating last_used timestamp")
}
}) })
} }

View File

@@ -11,7 +11,7 @@ import (
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/controllers/admin" "github.com/owncast/owncast/controllers/admin"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/yp" "github.com/owncast/owncast/yp"
) )
@@ -30,15 +30,8 @@ func Start() error {
// custom emoji supported in the chat // custom emoji supported in the chat
http.HandleFunc("/api/emoji", controllers.GetCustomEmoji) http.HandleFunc("/api/emoji", controllers.GetCustomEmoji)
// websocket chat server
go func() {
if err := chat.Start(); err != nil {
log.Fatalln(err)
}
}()
// chat rest api // chat rest api
http.HandleFunc("/api/chat", controllers.GetChatMessages) http.HandleFunc("/api/chat", middleware.RequireUserAccessToken(controllers.GetChatMessages))
// web config api // web config api
http.HandleFunc("/api/config", controllers.GetWebConfig) http.HandleFunc("/api/config", controllers.GetWebConfig)
@@ -64,6 +57,9 @@ func Start() error {
// tell the backend you're an active viewer // tell the backend you're an active viewer
http.HandleFunc("/api/ping", controllers.Ping) http.HandleFunc("/api/ping", controllers.Ping)
// register a new chat user
http.HandleFunc("/api/chat/register", controllers.RegisterAnonymousChatUser)
// Authenticated admin requests // Authenticated admin requests
// Current inbound broadcaster // Current inbound broadcaster
@@ -82,7 +78,7 @@ func Start() error {
http.HandleFunc("/api/admin/hardwarestats", middleware.RequireAdminAuth(admin.GetHardwareStats)) http.HandleFunc("/api/admin/hardwarestats", middleware.RequireAdminAuth(admin.GetHardwareStats))
// Get a a detailed list of currently connected clients // Get a a detailed list of currently connected clients
http.HandleFunc("/api/admin/clients", middleware.RequireAdminAuth(controllers.GetConnectedClients)) http.HandleFunc("/api/admin/clients", middleware.RequireAdminAuth(admin.GetConnectedClients))
// Get all logs // Get all logs
http.HandleFunc("/api/admin/logs", middleware.RequireAdminAuth(admin.GetLogs)) http.HandleFunc("/api/admin/logs", middleware.RequireAdminAuth(admin.GetLogs))
@@ -95,6 +91,13 @@ func Start() error {
// Update chat message visibility // Update chat message visibility
http.HandleFunc("/api/admin/chat/updatemessagevisibility", middleware.RequireAdminAuth(admin.UpdateMessageVisibility)) http.HandleFunc("/api/admin/chat/updatemessagevisibility", middleware.RequireAdminAuth(admin.UpdateMessageVisibility))
// Enable/disable a user
http.HandleFunc("/api/admin/chat/users/setenabled", middleware.RequireAdminAuth(admin.UpdateUserEnabled))
// Get a list of disabled users
http.HandleFunc("/api/admin/chat/users/disabled", middleware.RequireAdminAuth(admin.GetDisabledUsers))
// Update config values // Update config values
// Change the current streaming key in memory // Change the current streaming key in memory
@@ -119,7 +122,7 @@ func Start() error {
http.HandleFunc("/api/admin/config/chat/disable", middleware.RequireAdminAuth(admin.SetChatDisabled)) http.HandleFunc("/api/admin/config/chat/disable", middleware.RequireAdminAuth(admin.SetChatDisabled))
// Set chat usernames that are not allowed // Set chat usernames that are not allowed
http.HandleFunc("/api/admin/config/chat/disallowedusernames", middleware.RequireAdminAuth(admin.SetUsernameBlocklist)) http.HandleFunc("/api/admin/config/chat/forbiddenusernames", middleware.RequireAdminAuth(admin.SetForbiddenUsernameList))
// Set video codec // Set video codec
http.HandleFunc("/api/admin/config/video/codec", middleware.RequireAdminAuth(admin.SetVideoCodec)) http.HandleFunc("/api/admin/config/video/codec", middleware.RequireAdminAuth(admin.SetVideoCodec))
@@ -134,34 +137,37 @@ func Start() error {
http.HandleFunc("/api/admin/webhooks/create", middleware.RequireAdminAuth(admin.CreateWebhook)) http.HandleFunc("/api/admin/webhooks/create", middleware.RequireAdminAuth(admin.CreateWebhook))
// Get all access tokens // Get all access tokens
http.HandleFunc("/api/admin/accesstokens", middleware.RequireAdminAuth(admin.GetAccessTokens)) http.HandleFunc("/api/admin/accesstokens", middleware.RequireAdminAuth(admin.GetExternalAPIUsers))
// Delete a single access token // Delete a single access token
http.HandleFunc("/api/admin/accesstokens/delete", middleware.RequireAdminAuth(admin.DeleteAccessToken)) http.HandleFunc("/api/admin/accesstokens/delete", middleware.RequireAdminAuth(admin.DeleteExternalAPIUser))
// Create a single access token // Create a single access token
http.HandleFunc("/api/admin/accesstokens/create", middleware.RequireAdminAuth(admin.CreateAccessToken)) http.HandleFunc("/api/admin/accesstokens/create", middleware.RequireAdminAuth(admin.CreateExternalAPIUser))
// Send a system message to chat // Send a system message to chat
http.HandleFunc("/api/integrations/chat/system", middleware.RequireAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessage)) http.HandleFunc("/api/integrations/chat/system", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage))
// Send a user message to chat // Send a user message to chat *NO LONGER SUPPORTED
http.HandleFunc("/api/integrations/chat/user", middleware.RequireAccessToken(models.ScopeCanSendUserMessages, admin.SendUserMessage)) http.HandleFunc("/api/integrations/chat/user", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendUserMessage))
// Send a message to chat as a specific 3rd party bot/integration based on its access token
http.HandleFunc("/api/integrations/chat/send", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendIntegrationChatMessage))
// Send a user action to chat // Send a user action to chat
http.HandleFunc("/api/integrations/chat/action", middleware.RequireAccessToken(models.ScopeCanSendSystemMessages, admin.SendChatAction)) http.HandleFunc("/api/integrations/chat/action", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendChatAction))
// Hide chat message // Hide chat message
http.HandleFunc("/api/integrations/chat/messagevisibility", middleware.RequireAccessToken(models.ScopeHasAdminAccess, admin.UpdateMessageVisibility)) http.HandleFunc("/api/integrations/chat/messagevisibility", middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalUpdateMessageVisibility))
// Stream title // Stream title
http.HandleFunc("/api/integrations/streamtitle", middleware.RequireAccessToken(models.ScopeHasAdminAccess, admin.SetStreamTitle)) http.HandleFunc("/api/integrations/streamtitle", middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalSetStreamTitle))
// Get chat history // Get chat history
http.HandleFunc("/api/integrations/chat", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetChatMessages)) http.HandleFunc("/api/integrations/chat", middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, controllers.ExternalGetChatMessages))
// Connected clients // Connected clients
http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients)) http.HandleFunc("/api/integrations/clients", middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalGetConnectedClients))
// Logo path // Logo path
http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogo)) http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogo))
@@ -211,6 +217,11 @@ func Start() error {
// set custom style css // set custom style css
http.HandleFunc("/api/admin/config/customstyles", middleware.RequireAdminAuth(admin.SetCustomStyles)) http.HandleFunc("/api/admin/config/customstyles", middleware.RequireAdminAuth(admin.SetCustomStyles))
// websocket
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
chat.HandleClientConnection(w, r)
})
port := config.WebServerPort port := config.WebServerPort
ip := config.WebServerIP ip := config.WebServerIP

View File

@@ -2,52 +2,39 @@ const { test } = require('@jest/globals');
var request = require('supertest'); var request = require('supertest');
request = request('http://127.0.0.1:8080'); request = request('http://127.0.0.1:8080');
const WebSocket = require('ws'); const registerChat = require('./lib/chat').registerChat;
var ws; const sendChatMessage = require('./lib/chat').sendChatMessage;
const testMessageId = Math.random().toString(36).substring(7); var userDisplayName;
const username = 'user' + Math.floor(Math.random() * 100);
const message = Math.floor(Math.random() * 100) + ' test 123'; const message = Math.floor(Math.random() * 100) + ' test 123';
const messageRaw = message + ' *and some markdown too*';
const messageMarkdown = '<p>' + message + ' <em>and some markdown too</em></p>'
const date = new Date().toISOString();
const testMessage = { const testMessage = {
author: username, body: message,
body: messageRaw, type: 'CHAT',
id: testMessageId,
type: 'CHAT',
visible: true,
timestamp: date,
}; };
test('can send a chat message', (done) => { test('can send a chat message', async (done) => {
ws = new WebSocket('ws://127.0.0.1:8080/entry', { const registration = await registerChat();
origin: 'http://localhost', const accessToken = registration.accessToken;
userDisplayName = registration.displayName;
sendChatMessage(testMessage, accessToken, done);
}); });
function onOpen() { test('can fetch chat messages', async (done) => {
ws.send(JSON.stringify(testMessage), function() { const res = await request
ws.close(); .get('/api/admin/chat/messages')
done(); .auth('admin', 'abc123')
}); .expect(200);
}
ws.on('open', onOpen); const expectedBody = `<p>${testMessage.body}</p>`
}); const message = res.body.filter(function (msg) {
return msg.body === expectedBody
test('can fetch chat messages', (done) => { })[0];
request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200)
.then((res) => { expect(message.body).toBe(expectedBody);
const message = res.body.filter(function(msg) { expect(message.user.displayName).toBe(userDisplayName);
return msg.id = testMessageId; expect(message.type).toBe(testMessage.type);
})[0];
done();
expect(message.author).toBe(testMessage.author);
expect(message.body).toBe(messageMarkdown);
expect(message.date).toBe(testMessage.date);
expect(message.type).toBe(testMessage.type);
done();
});
}); });

View File

@@ -2,38 +2,26 @@ const { test } = require('@jest/globals');
var request = require('supertest'); var request = require('supertest');
request = request('http://127.0.0.1:8080'); request = request('http://127.0.0.1:8080');
const WebSocket = require('ws'); const registerChat = require('./lib/chat').registerChat;
var ws; const sendChatMessage = require('./lib/chat').sendChatMessage;
const testVisibilityMessage = { const testVisibilityMessage = {
author: "username",
body: "message " + Math.floor(Math.random() * 100), body: "message " + Math.floor(Math.random() * 100),
type: 'CHAT', type: 'CHAT',
visible: true,
timestamp: new Date().toISOString()
}; };
test('can send a chat message', (done) => { test('can send a chat message', async (done) => {
ws = new WebSocket('ws://127.0.0.1:8080/entry', { const registration = await registerChat();
origin: 'http://localhost', const accessToken = registration.accessToken;
});
sendChatMessage(testVisibilityMessage, accessToken, done);
function onOpen() { });
ws.send(JSON.stringify(testVisibilityMessage), function () {
ws.close();
done();
});
}
ws.on('open', onOpen);
});
var messageId;
test('verify we can make API call to mark message as hidden', async (done) => { test('verify we can make API call to mark message as hidden', async (done) => {
const res = await request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200) const res = await request.get('/api/admin/chat/messages').auth('admin', 'abc123').expect(200)
const message = res.body[0]; const message = res.body[0];
messageId = message.id; const messageId = message.id;
await request.post('/api/admin/chat/updatemessagevisibility') await request.post('/api/admin/chat/updatemessagevisibility')
.auth('admin', 'abc123') .auth('admin', 'abc123')
.send({ "idArray": [messageId], "visible": false }).expect(200); .send({ "idArray": [messageId], "visible": false }).expect(200);
@@ -46,9 +34,9 @@ test('verify message has become hidden', async (done) => {
.auth('admin', 'abc123') .auth('admin', 'abc123')
const message = res.body.filter(obj => { const message = res.body.filter(obj => {
return obj.id === messageId; return obj.body === `<p>${testVisibilityMessage.body}</p>`;
}); });
expect(message.length).toBe(1); expect(message.length).toBe(1);
expect(message[0].visible).toBe(false); expect(message[0].hiddenAt).toBeTruthy();
done(); done();
}); });

View File

@@ -0,0 +1,66 @@
const { test } = require('@jest/globals');
var request = require('supertest');
request = request('http://127.0.0.1:8080');
const registerChat = require('./lib/chat').registerChat;
const sendChatMessage = require('./lib/chat').sendChatMessage;
const testVisibilityMessage = {
body: "message " + Math.floor(Math.random() * 100),
type: 'CHAT',
};
var userId
var accessToken
test('can register a user', async (done) => {
const registration = await registerChat();
userId = registration.id;
accessToken = registration.accessToken;
done();
});
test('can send a chat message', async (done) => {
sendChatMessage(testVisibilityMessage, accessToken, done);
});
test('can disable a user', async (done) => {
// To allow for visually being able to see the test hiding the
// message add a short delay.
await new Promise((r) => setTimeout(r, 1500));
await request.post('/api/admin/chat/users/setenabled').send({ "userId": userId, "enabled": false })
.auth('admin', 'abc123').expect(200);
done();
});
test('verify user is disabled', async (done) => {
const response = await request.get('/api/admin/chat/users/disabled').auth('admin', 'abc123').expect(200);
const tokenCheck = response.body.filter((user) => user.id === userId)
expect(tokenCheck).toHaveLength(1);
done();
});
test('verify messages from user are hidden', async (done) => {
const response = await request.get('/api/admin/chat/messages')
.auth('admin', 'abc123')
.expect(200);
const message = response.body.filter(obj => {
return obj.user.id === userId;
});
expect(message[0].hiddenAt).toBeTruthy();
done();
});
test('can re-enable a user', async (done) => {
await request.post('/api/admin/chat/users/setenabled').send({ "userId": userId, "enabled": true })
.auth('admin', 'abc123').expect(200);
done();
});
test('verify user is enabled', async (done) => {
const response = await request.get('/api/admin/chat/users/disabled').auth('admin', 'abc123').expect(200);
const tokenCheck = response.body.filter((user) => user.id === userId)
expect(tokenCheck).toHaveLength(0);
done();
});

View File

@@ -31,6 +31,8 @@ const s3Config = {
region: randomString(), region: randomString(),
}; };
const forbiddenUsernames = [randomString(), randomString(), randomString()];
test('set server name', async (done) => { test('set server name', async (done) => {
const res = await sendConfigChangeRequest('name', serverName); const res = await sendConfigChangeRequest('name', serverName);
done(); done();
@@ -81,6 +83,11 @@ test('set s3 configuration', async (done) => {
done(); done();
}); });
test('set forbidden usernames', async (done) => {
const res = await sendConfigChangeRequest('chat/forbiddenusernames', forbiddenUsernames);
done();
});
test('verify updated config values', async (done) => { test('verify updated config values', async (done) => {
const res = await request.get('/api/config'); const res = await request.get('/api/config');
expect(res.body.name).toBe(serverName); expect(res.body.name).toBe(serverName);
@@ -122,6 +129,7 @@ test('admin configuration is correct', (done) => {
expect(res.body.instanceDetails.socialHandles).toStrictEqual( expect(res.body.instanceDetails.socialHandles).toStrictEqual(
socialHandles socialHandles
); );
expect(res.body.forbiddenUsernames).toStrictEqual(forbiddenUsernames);
expect(res.body.videoSettings.latencyLevel).toBe(latencyLevel); expect(res.body.videoSettings.latencyLevel).toBe(latencyLevel);
expect(res.body.videoSettings.videoQualityVariants[0].framerate).toBe( expect(res.body.videoSettings.videoQualityVariants[0].framerate).toBe(

View File

@@ -49,39 +49,53 @@ test('check that webhook was deleted', (done) => {
}); });
test('create access token', async (done) => { test('create access token', async (done) => {
const name = 'test token'; const name = 'Automated integration test';
const scopes = ['CAN_SEND_SYSTEM_MESSAGES']; const scopes = ['CAN_SEND_SYSTEM_MESSAGES', 'CAN_SEND_MESSAGES'];
const res = await sendIntegrationsChangePayload('accesstokens/create', { const res = await sendIntegrationsChangePayload('accesstokens/create', {
name: name, name: name,
scopes: scopes, scopes: scopes,
}); });
expect(res.body.token).toBeTruthy(); expect(res.body.accessToken).toBeTruthy();
expect(res.body.timestamp).toBeTruthy(); expect(res.body.createdAt).toBeTruthy();
expect(res.body.name).toBe(name); expect(res.body.displayName).toBe(name);
expect(res.body.scopes).toStrictEqual(scopes); expect(res.body.scopes).toStrictEqual(scopes);
accessToken = res.body.token; accessToken = res.body.accessToken;
done(); done();
}); });
test('check access tokens', (done) => { test('check access tokens', async (done) => {
request.get('/api/admin/accesstokens') const res = await request.get('/api/admin/accesstokens')
.auth('admin', 'abc123').expect(200) .auth('admin', 'abc123').expect(200)
.then((res) => { const tokenCheck = res.body.filter((token) => token.accessToken === accessToken)
expect(res.body).toHaveLength(1); expect(tokenCheck).toHaveLength(1);
expect(res.body[0].token).toBe(accessToken); done();
done();
});
}); });
test('send a system message using access token', async (done) => { test('send a system message using access token', async (done) => {
const payload = {body: 'test 1234'}; const payload = {body: 'This is a test system message from the automated integration test'};
const res = await request.post('/api/integrations/chat/system') const res = await request.post('/api/integrations/chat/system')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.send(payload).expect(200); .send(payload).expect(200);
done(); done();
}); });
test('send an external integration message using access token', async (done) => {
const payload = {body: 'This is a test external message from the automated integration test'};
const res = await request.post('/api/integrations/chat/send')
.set('Authorization', 'Bearer ' + accessToken)
.send(payload).expect(200);
done();
});
test('send an external integration action using access token', async (done) => {
const payload = {body: 'This is a test external action from the automated integration test'};
const res = await request.post('/api/integrations/chat/action')
.set('Authorization', 'Bearer ' + accessToken)
.send(payload).expect(200);
done();
});
test('delete access token', async (done) => { test('delete access token', async (done) => {
const res = await sendIntegrationsChangePayload('accesstokens/delete', { const res = await sendIntegrationsChangePayload('accesstokens/delete', {
token: accessToken, token: accessToken,
@@ -90,13 +104,12 @@ test('delete access token', async (done) => {
done(); done();
}); });
test('check token delete was successful', (done) => { test('check token delete was successful', async (done) => {
request.get('/api/admin/accesstokens') const res = await request.get('/api/admin/accesstokens')
.auth('admin', 'abc123').expect(200) .auth('admin', 'abc123').expect(200)
.then((res) => { const tokenCheck = res.body.filter((token) => token.accessToken === accessToken)
expect(res.body).toHaveLength(0); expect(tokenCheck).toHaveLength(0);
done(); done();
});
}); });
async function sendIntegrationsChangePayload(endpoint, payload) { async function sendIntegrationsChangePayload(endpoint, payload) {

View File

@@ -0,0 +1,33 @@
var request = require('supertest');
request = request('http://127.0.0.1:8080');
const WebSocket = require('ws');
async function registerChat() {
try {
const response = await request.post('/api/chat/register');
return response.body;
} catch (e) {
console.error(e);
}
}
function sendChatMessage(message, accessToken, done) {
const ws = new WebSocket(
`ws://localhost:8080/ws?accessToken=${accessToken}`,
{
origin: 'http://localhost:8080',
}
);
function onOpen() {
ws.send(JSON.stringify(message), function () {
ws.close();
done();
});
}
ws.on('open', onOpen);
}
module.exports.sendChatMessage = sendChatMessage;
module.exports.registerChat = registerChat;

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,5 @@
const usernames = [ const WebSocket = require('ws');
'User ' + Math.floor(Math.random() * 100), const fetch = require('node-fetch');
'User ' + Math.floor(Math.random() * 100),
'User ' + Math.floor(Math.random() * 100),
'User ' + Math.floor(Math.random() * 100),
];
const messages = [ const messages = [
'I am a test message', 'I am a test message',
@@ -12,50 +8,64 @@ const messages = [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
'Sed pulvinar proin gravida hendrerit. Mauris in aliquam sem fringilla ut morbi tincidunt augue. In cursus turpis massa tincidunt dui.', 'Sed pulvinar proin gravida hendrerit. Mauris in aliquam sem fringilla ut morbi tincidunt augue. In cursus turpis massa tincidunt dui.',
'Feugiat in ante metus dictum at tempor commodo ullamcorper. Nunc aliquet bibendum enim facilisis gravida neque convallis a. Vitae tortor condimentum lacinia quis vel eros donec ac odio.', 'Feugiat in ante metus dictum at tempor commodo ullamcorper. Nunc aliquet bibendum enim facilisis gravida neque convallis a. Vitae tortor condimentum lacinia quis vel eros donec ac odio.',
'Here is _some_ **markdown**!',
]; ];
var availableMessages = messages.slice(); var availableMessages = messages.slice();
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:8080/entry', { async function registerChat() {
origin: 'http://watch.owncast.online', const options = {
}); method: 'POST',
headers: {
ws.on('open', function open() { 'Content-Type': 'application/json'
setTimeout(sendMessage, 15000); }
});
ws.on('error', function incoming(data) {
console.log(data);
});
function sendMessage() {
if (availableMessages.length == 0) {
availableMessages = messages.slice();
} }
const id = Math.random().toString(36).substring(7); try {
const username = usernames[Math.floor(Math.random() * usernames.length)]; const response = await fetch('http://localhost:8080/api/chat/register', options);
const messageIndex = Math.floor(Math.random() * availableMessages.length); const result = await response.json();
const message = availableMessages[messageIndex]; return result;
availableMessages.splice(messageIndex, 1); } catch(e) {
console.error(e);
const testMessage = { }
author: username,
body: message,
image: 'https://robohash.org/' + username,
id: id,
type: 'CHAT',
visible: true,
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(testMessage));
const nextMessageTimeout = (Math.floor(Math.random() * (25 - 10)) + 10) * 100;
setTimeout(sendMessage, nextMessageTimeout);
} }
async function sendMessage() {
const registration = await registerChat();
const accessToken = registration.accessToken;
function send() {
if (availableMessages.length == 0) {
availableMessages = messages.slice();
}
const messageIndex = Math.floor(Math.random() * availableMessages.length);
const message = availableMessages[messageIndex];
availableMessages.splice(messageIndex, 1);
const testMessage = {
body: message,
type: 'CHAT',
};
ws.send(JSON.stringify(testMessage));
const nextMessageTimeout = (Math.floor(Math.random() * (25 + 10) * 10));
setTimeout(sendMessage, nextMessageTimeout);
}
const ws = new WebSocket(`ws://localhost:8080/ws?accessToken=${accessToken}`, {
origin: 'http://localhost:8080',
});
ws.on('open', function open() {
setTimeout(send, 1000);
});
ws.on('error', function incoming(data) {
console.log(data);
});
}
sendMessage();

View File

@@ -14,7 +14,7 @@ const messages = [
const WebSocket = require('ws'); const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:8080/entry', { const ws = new WebSocket('ws://localhost:8080/ws', {
origin: 'http://watch.owncast.online', origin: 'http://watch.owncast.online',
}); });

80
test/load/chatLoadTest.js Normal file
View File

@@ -0,0 +1,80 @@
const WebSocket = require('ws');
const fetch = require('node-fetch');
var connectionCount = 0;
const targetConnectionCount = 5000;
const messages = [
'I am a test message',
'this is fake',
'i write emoji 😀',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
'Sed pulvinar proin gravida hendrerit. Mauris in aliquam sem fringilla ut morbi tincidunt augue. In cursus turpis massa tincidunt dui.',
'Feugiat in ante metus dictum at tempor commodo ullamcorper. Nunc aliquet bibendum enim facilisis gravida neque convallis a. Vitae tortor condimentum lacinia quis vel eros donec ac odio.',
'Here is _some_ **markdown**!',
];
var availableMessages = messages.slice();
async function registerChat() {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}
try {
const response = await fetch('http://localhost:8080/api/chat/register', options);
const result = await response.json();
return result;
} catch(e) {
console.error(e);
}
}
async function runSingleUserIteration() {
const registration = await registerChat();
const accessToken = registration.accessToken;
function sendTestMessage() {
if (availableMessages.length == 0) {
availableMessages = messages.slice();
}
const messageIndex = Math.floor(Math.random() * availableMessages.length);
const message = availableMessages[messageIndex];
availableMessages.splice(messageIndex, 1);
const testMessage = {
body: message,
type: 'CHAT',
};
ws.send(JSON.stringify(testMessage));
// After this message is sent then run it again.
setTimeout(runSingleUserIteration, 20);
}
const ws = new WebSocket(`ws://localhost:8080/ws?accessToken=${accessToken}`, {
origin: 'http://localhost:8080',
});
// When the websocket connects then send a chat message.
ws.on('open', function open() {
connectionCount++;
console.log(connectionCount + '/' + targetConnectionCount, " chat clients.")
if (connectionCount === targetConnectionCount) {
process.exit();
}
setTimeout(sendTestMessage, 5);
});
ws.on('error', function incoming(data) {
console.error(data);
});
}
runSingleUserIteration();

View File

@@ -1,14 +0,0 @@
module.exports = { createTestMessageObject };
function createTestMessageObject(userContext, events, done) {
const randomNumber = Math.floor((Math.random() * 10) + 1);
const author = "load-test-user-" + randomNumber
const data = {
author: author,
body: "Test 12345. " + randomNumber,
type: "CHAT"
};
// set the "data" variable for the virtual user to use in the subsequent action
userContext.vars.data = data;
return done();
}

View File

@@ -1,32 +0,0 @@
config:
target: "ws://localhost:8080/entry"
processor: "./websocketTest.js"
ensure:
p95: 200
maxErrorRate: 1
phases:
- duration: 30
arrivalRate: 5
rampTo: 5
name: "Warming up"
- duration: 240
arrivalRate: 5
rampTo: 40
name: "Max load"
ws:
subprotocols:
- json
headers:
Connection: Upgrade
Origin: http://localhost:8080
Sec-WebSocket-Version: 13
scenarios:
- engine: "ws"
flow:
- function: "createTestMessageObject"
- send: "{{ data }}"
- think: 30 # Each client should stay connected for 30 seconds

View File

@@ -1,8 +1,9 @@
package utils package utils
import ( import (
"crypto/rand"
"encoding/base64" "encoding/base64"
"math/rand"
"time"
) )
const tokenLength = 32 const tokenLength = 32
@@ -17,7 +18,8 @@ func GenerateAccessToken() (string, error) {
// case the caller should not continue. // case the caller should not continue.
func generateRandomBytes(n int) ([]byte, error) { func generateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n) b := make([]byte, n)
_, err := rand.Read(b) rand.Seed(time.Now().UTC().UnixNano())
_, err := rand.Read(b) //nolint
// Note that err == nil only if we read len(b) bytes. // Note that err == nil only if we read len(b) bytes.
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -32,7 +32,7 @@ func Restore(backupFile string, databaseFile string) error {
defer gz.Close() defer gz.Close()
var b bytes.Buffer var b bytes.Buffer
if _, err := io.Copy(&b, gz); err != nil { if _, err := io.Copy(&b, gz); err != nil { // nolint
return fmt.Errorf("Unable to read backup file %s", err) return fmt.Errorf("Unable to read backup file %s", err)
} }
@@ -59,12 +59,13 @@ func Restore(backupFile string, databaseFile string) error {
func Backup(db *sql.DB, backupFile string) { func Backup(db *sql.DB, backupFile string) {
log.Traceln("Backing up database to", backupFile) log.Traceln("Backing up database to", backupFile)
BackupDirectory := filepath.Dir(backupFile) backupDirectory := filepath.Dir(backupFile)
if !DoesFileExists(BackupDirectory) { if !DoesFileExists(backupDirectory) {
err := os.MkdirAll(BackupDirectory, 0700) err := os.MkdirAll(backupDirectory, 0700)
if err != nil { if err != nil {
log.Fatalln(err) log.Errorln("unable to create backup directory. check permissions and ownership.", backupDirectory, err)
return
} }
} }

636
utils/phraseGenerator.go Normal file
View File

@@ -0,0 +1,636 @@
package utils
import (
"fmt"
"math/rand"
"time"
)
// Name generator values from https://raw.githubusercontent.com/railroadmanuk/random_names/master/random_names.go
var (
// taken from https://github.com/docker/docker/blob/master/pkg/namesgenerator/names-generator.go
left = [...]string{
"admiring",
"adoring",
"affectionate",
"agitated",
"amazing",
"angry",
"awesome",
"blissful",
"boring",
"brave",
"clever",
"cocky",
"compassionate",
"competent",
"condescending",
"confident",
"cranky",
"dark",
"dazzling",
"determined",
"distracted",
"dope",
"dreamy",
"eager",
"ecstatic",
"elastic",
"elated",
"elegant",
"eloquent",
"epic",
"fervent",
"festive",
"flamboyant",
"fly",
"focused",
"friendly",
"frosty",
"gallant",
"gifted",
"goofy",
"goth",
"gracious",
"happy",
"hardcore",
"heuristic",
"hopeful",
"hungry",
"industrial",
"infallible",
"inspiring",
"jolly",
"jovial",
"keen",
"kind",
"laughing",
"loving",
"lucid",
"mystifying",
"modest",
"musing",
"naughty",
"nervous",
"nifty",
"nostalgic",
"objective",
"optimistic",
"peaceful",
"pedantic",
"pensive",
"practical",
"priceless",
"quirky",
"quizzical",
"radical",
"relaxed",
"reverent",
"romantic",
"sad",
"serene",
"sharp",
"silly",
"sleepy",
"stoic",
"stupefied",
"suspicious",
"tender",
"thirsty",
"trusting",
"ultimate",
"unruffled",
"upbeat",
"vibrant",
"vigilant",
"vigorous",
"wizardly",
"wonderful",
"xenodochial",
"youthful",
"zealous",
"zen",
}
// Docker, starting from 0.7.x, generates names from notable scientists and hackers.
// Please, for any amazing man that you add to the list, consider adding an equally amazing woman to it, and vice versa.
right = [...]string{
// Muhammad ibn Jābir al-Ḥarrānī al-Battānī was a founding father of astronomy. https://en.wikipedia.org/wiki/Mu%E1%B8%A5ammad_ibn_J%C4%81bir_al-%E1%B8%A4arr%C4%81n%C4%AB_al-Batt%C4%81n%C4%AB
"albattani",
// Frances E. Allen, became the first female IBM Fellow in 1989. In 2006, she became the first female recipient of the ACM's Turing Award. https://en.wikipedia.org/wiki/Frances_E._Allen
"allen",
// June Almeida - Scottish virologist who took the first pictures of the rubella virus - https://en.wikipedia.org/wiki/June_Almeida
"almeida",
// Maria Gaetana Agnesi - Italian mathematician, philosopher, theologian and humanitarian. She was the first woman to write a mathematics handbook and the first woman appointed as a Mathematics Professor at a University. https://en.wikipedia.org/wiki/Maria_Gaetana_Agnesi
"agnesi",
// Archimedes was a physicist, engineer and mathematician who invented too many things to list them here. https://en.wikipedia.org/wiki/Archimedes
"archimedes",
// Maria Ardinghelli - Italian translator, mathematician and physicist - https://en.wikipedia.org/wiki/Maria_Ardinghelli
"ardinghelli",
// Aryabhata - Ancient Indian mathematician-astronomer during 476-550 CE https://en.wikipedia.org/wiki/Aryabhata
"aryabhata",
// Wanda Austin - Wanda Austin is the President and CEO of The Aerospace Corporation, a leading architect for the US security space programs. https://en.wikipedia.org/wiki/Wanda_Austin
"austin",
// Charles Babbage invented the concept of a programmable computer. https://en.wikipedia.org/wiki/Charles_Babbage.
"babbage",
// Stefan Banach - Polish mathematician, was one of the founders of modern functional analysis. https://en.wikipedia.org/wiki/Stefan_Banach
"banach",
// John Bardeen co-invented the transistor - https://en.wikipedia.org/wiki/John_Bardeen
"bardeen",
// Jean Bartik, born Betty Jean Jennings, was one of the original programmers for the ENIAC computer. https://en.wikipedia.org/wiki/Jean_Bartik
"bartik",
// Laura Bassi, the world's first female professor https://en.wikipedia.org/wiki/Laura_Bassi
"bassi",
// Hugh Beaver, British engineer, founder of the Guinness Book of World Records https://en.wikipedia.org/wiki/Hugh_Beaver
"beaver",
// Alexander Graham Bell - an eminent Scottish-born scientist, inventor, engineer and innovator who is credited with inventing the first practical telephone - https://en.wikipedia.org/wiki/Alexander_Graham_Bell
"bell",
// Karl Friedrich Benz - a German automobile engineer. Inventor of the first practical motorcar. https://en.wikipedia.org/wiki/Karl_Benz
"benz",
// Homi J Bhabha - was an Indian nuclear physicist, founding director, and professor of physics at the Tata Institute of Fundamental Research. Colloquially known as "father of Indian nuclear programme"- https://en.wikipedia.org/wiki/Homi_J._Bhabha
"bhabha",
// Bhaskara II - Ancient Indian mathematician-astronomer whose work on calculus predates Newton and Leibniz by over half a millennium - https://en.wikipedia.org/wiki/Bh%C4%81skara_II#Calculus
"bhaskara",
// Elizabeth Blackwell - American doctor and first American woman to receive a medical degree - https://en.wikipedia.org/wiki/Elizabeth_Blackwell
"blackwell",
// Niels Bohr is the father of quantum theory. https://en.wikipedia.org/wiki/Niels_Bohr.
"bohr",
// Kathleen Booth, she's credited with writing the first assembly language. https://en.wikipedia.org/wiki/Kathleen_Booth
"booth",
// Anita Borg - Anita Borg was the founding director of the Institute for Women and Technology (IWT). https://en.wikipedia.org/wiki/Anita_Borg
"borg",
// Satyendra Nath Bose - He provided the foundation for BoseEinstein statistics and the theory of the BoseEinstein condensate. - https://en.wikipedia.org/wiki/Satyendra_Nath_Bose
"bose",
// Evelyn Boyd Granville - She was one of the first African-American woman to receive a Ph.D. in mathematics; she earned it in 1949 from Yale University. https://en.wikipedia.org/wiki/Evelyn_Boyd_Granville
"boyd",
// Brahmagupta - Ancient Indian mathematician during 598-670 CE who gave rules to compute with zero - https://en.wikipedia.org/wiki/Brahmagupta#Zero
"brahmagupta",
// Walter Houser Brattain co-invented the transistor - https://en.wikipedia.org/wiki/Walter_Houser_Brattain
"brattain",
// Emmett Brown invented time travel. https://en.wikipedia.org/wiki/Emmett_Brown (thanks Brian Goff)
"brown",
// Rachel Carson - American marine biologist and conservationist, her book Silent Spring and other writings are credited with advancing the global environmental movement. https://en.wikipedia.org/wiki/Rachel_Carson
"carson",
// Subrahmanyan Chandrasekhar - Astrophysicist known for his mathematical theory on different stages and evolution in structures of the stars. He has won nobel prize for physics - https://en.wikipedia.org/wiki/Subrahmanyan_Chandrasekhar
"chandrasekhar",
//Claude Shannon - The father of information theory and founder of digital circuit design theory. (https://en.wikipedia.org/wiki/Claude_Shannon)
"shannon",
// Joan Clarke - Bletchley Park code breaker during the Second World War who pioneered techniques that remained top secret for decades. Also an accomplished numismatist https://en.wikipedia.org/wiki/Joan_Clarke
"clarke",
// Jane Colden - American botanist widely considered the first female American botanist - https://en.wikipedia.org/wiki/Jane_Colden
"colden",
// Gerty Theresa Cori - American biochemist who became the third woman—and first American woman—to win a Nobel Prize in science, and the first woman to be awarded the Nobel Prize in Physiology or Medicine. Cori was born in Prague. https://en.wikipedia.org/wiki/Gerty_Cori
"cori",
// Seymour Roger Cray was an American electrical engineer and supercomputer architect who designed a series of computers that were the fastest in the world for decades. https://en.wikipedia.org/wiki/Seymour_Cray
"cray",
// This entry reflects a husband and wife team who worked together:
// Joan Curran was a Welsh scientist who developed radar and invented chaff, a radar countermeasure. https://en.wikipedia.org/wiki/Joan_Curran
// Samuel Curran was an Irish physicist who worked alongside his wife during WWII and invented the proximity fuse. https://en.wikipedia.org/wiki/Samuel_Curran
"curran",
// Marie Curie discovered radioactivity. https://en.wikipedia.org/wiki/Marie_Curie.
"curie",
// Charles Darwin established the principles of natural evolution. https://en.wikipedia.org/wiki/Charles_Darwin.
"darwin",
// Leonardo Da Vinci invented too many things to list here. https://en.wikipedia.org/wiki/Leonardo_da_Vinci.
"davinci",
// Edsger Wybe Dijkstra was a Dutch computer scientist and mathematical scientist. https://en.wikipedia.org/wiki/Edsger_W._Dijkstra.
"dijkstra",
// Donna Dubinsky - played an integral role in the development of personal digital assistants (PDAs) serving as CEO of Palm, Inc. and co-founding Handspring. https://en.wikipedia.org/wiki/Donna_Dubinsky
"dubinsky",
// Annie Easley - She was a leading member of the team which developed software for the Centaur rocket stage and one of the first African-Americans in her field. https://en.wikipedia.org/wiki/Annie_Easley
"easley",
// Thomas Alva Edison, prolific inventor https://en.wikipedia.org/wiki/Thomas_Edison
"edison",
// Albert Einstein invented the general theory of relativity. https://en.wikipedia.org/wiki/Albert_Einstein
"einstein",
// Gertrude Elion - American biochemist, pharmacologist and the 1988 recipient of the Nobel Prize in Medicine - https://en.wikipedia.org/wiki/Gertrude_Elion
"elion",
// Douglas Engelbart gave the mother of all demos: https://en.wikipedia.org/wiki/Douglas_Engelbart
"engelbart",
// Euclid invented geometry. https://en.wikipedia.org/wiki/Euclid
"euclid",
// Leonhard Euler invented large parts of modern mathematics. https://de.wikipedia.org/wiki/Leonhard_Euler
"euler",
// Pierre de Fermat pioneered several aspects of modern mathematics. https://en.wikipedia.org/wiki/Pierre_de_Fermat
"fermat",
// Enrico Fermi invented the first nuclear reactor. https://en.wikipedia.org/wiki/Enrico_Fermi.
"fermi",
// Richard Feynman was a key contributor to quantum mechanics and particle physics. https://en.wikipedia.org/wiki/Richard_Feynman
"feynman",
// Benjamin Franklin is famous for his experiments in electricity and the invention of the lightning rod.
"franklin",
// Galileo was a founding father of modern astronomy, and faced politics and obscurantism to establish scientific truth. https://en.wikipedia.org/wiki/Galileo_Galilei
"galileo",
// William Henry "Bill" Gates III is an American business magnate, philanthropist, investor, computer programmer, and inventor. https://en.wikipedia.org/wiki/Bill_Gates
"gates",
// Adele Goldberg, was one of the designers and developers of the Smalltalk language. https://en.wikipedia.org/wiki/Adele_Goldberg_(computer_scientist)
"goldberg",
// Adele Goldstine, born Adele Katz, wrote the complete technical description for the first electronic digital computer, ENIAC. https://en.wikipedia.org/wiki/Adele_Goldstine
"goldstine",
// Shafi Goldwasser is a computer scientist known for creating theoretical foundations of modern cryptography. Winner of 2012 ACM Turing Award. https://en.wikipedia.org/wiki/Shafi_Goldwasser
"goldwasser",
// James Golick, all around gangster.
"golick",
// Jane Goodall - British primatologist, ethologist, and anthropologist who is considered to be the world's foremost expert on chimpanzees - https://en.wikipedia.org/wiki/Jane_Goodall
"goodall",
// Lois Haibt - American computer scientist, part of the team at IBM that developed FORTRAN - https://en.wikipedia.org/wiki/Lois_Haibt
"haibt",
// Margaret Hamilton - Director of the Software Engineering Division of the MIT Instrumentation Laboratory, which developed on-board flight software for the Apollo space program. https://en.wikipedia.org/wiki/Margaret_Hamilton_(scientist)
"hamilton",
// Stephen Hawking pioneered the field of cosmology by combining general relativity and quantum mechanics. https://en.wikipedia.org/wiki/Stephen_Hawking
"hawking",
// Werner Heisenberg was a founding father of quantum mechanics. https://en.wikipedia.org/wiki/Werner_Heisenberg
"heisenberg",
// Grete Hermann was a German philosopher noted for her philosophical work on the foundations of quantum mechanics. https://en.wikipedia.org/wiki/Grete_Hermann
"hermann",
// Jaroslav Heyrovský was the inventor of the polarographic method, father of the electroanalytical method, and recipient of the Nobel Prize in 1959. His main field of work was polarography. https://en.wikipedia.org/wiki/Jaroslav_Heyrovsk%C3%BD
"heyrovsky",
// Dorothy Hodgkin was a British biochemist, credited with the development of protein crystallography. She was awarded the Nobel Prize in Chemistry in 1964. https://en.wikipedia.org/wiki/Dorothy_Hodgkin
"hodgkin",
// Erna Schneider Hoover revolutionized modern communication by inventing a computerized telephone switching method. https://en.wikipedia.org/wiki/Erna_Schneider_Hoover
"hoover",
// Grace Hopper developed the first compiler for a computer programming language and is credited with popularizing the term "debugging" for fixing computer glitches. https://en.wikipedia.org/wiki/Grace_Hopper
"hopper",
// Frances Hugle, she was an American scientist, engineer, and inventor who contributed to the understanding of semiconductors, integrated circuitry, and the unique electrical principles of microscopic materials. https://en.wikipedia.org/wiki/Frances_Hugle
"hugle",
// Hypatia - Greek Alexandrine Neoplatonist philosopher in Egypt who was one of the earliest mothers of mathematics - https://en.wikipedia.org/wiki/Hypatia
"hypatia",
// Yeong-Sil Jang was a Korean scientist and astronomer during the Joseon Dynasty; he invented the first metal printing press and water gauge. https://en.wikipedia.org/wiki/Jang_Yeong-sil
"jang",
// Betty Jennings - one of the original programmers of the ENIAC. https://en.wikipedia.org/wiki/ENIAC - https://en.wikipedia.org/wiki/Jean_Bartik
"jennings",
// Mary Lou Jepsen, was the founder and chief technology officer of One Laptop Per Child (OLPC), and the founder of Pixel Qi. https://en.wikipedia.org/wiki/Mary_Lou_Jepsen
"jepsen",
// Katherine Coleman Goble Johnson - American physicist and mathematician contributed to the NASA. https://en.wikipedia.org/wiki/Katherine_Johnson
"johnson",
// Irène Joliot-Curie - French scientist who was awarded the Nobel Prize for Chemistry in 1935. Daughter of Marie and Pierre Curie. https://en.wikipedia.org/wiki/Ir%C3%A8ne_Joliot-Curie
"joliot",
// Karen Spärck Jones came up with the concept of inverse document frequency, which is used in most search engines today. https://en.wikipedia.org/wiki/Karen_Sp%C3%A4rck_Jones
"jones",
// A. P. J. Abdul Kalam - is an Indian scientist aka Missile Man of India for his work on the development of ballistic missile and launch vehicle technology - https://en.wikipedia.org/wiki/A._P._J._Abdul_Kalam
"kalam",
// Susan Kare, created the icons and many of the interface elements for the original Apple Macintosh in the 1980s, and was an original employee of NeXT, working as the Creative Director. https://en.wikipedia.org/wiki/Susan_Kare
"kare",
// Mary Kenneth Keller, Sister Mary Kenneth Keller became the first American woman to earn a PhD in Computer Science in 1965. https://en.wikipedia.org/wiki/Mary_Kenneth_Keller
"keller",
// Har Gobind Khorana - Indian-American biochemist who shared the 1968 Nobel Prize for Physiology - https://en.wikipedia.org/wiki/Har_Gobind_Khorana
"khorana",
// Jack Kilby invented silicone integrated circuits and gave Silicon Valley its name. - https://en.wikipedia.org/wiki/Jack_Kilby
"kilby",
// Maria Kirch - German astronomer and first woman to discover a comet - https://en.wikipedia.org/wiki/Maria_Margarethe_Kirch
"kirch",
// Donald Knuth - American computer scientist, author of "The Art of Computer Programming" and creator of the TeX typesetting system. https://en.wikipedia.org/wiki/Donald_Knuth
"knuth",
// Sophie Kowalevski - Russian mathematician responsible for important original contributions to analysis, differential equations and mechanics - https://en.wikipedia.org/wiki/Sofia_Kovalevskaya
"kowalevski",
// Marie-Jeanne de Lalande - French astronomer, mathematician and cataloguer of stars - https://en.wikipedia.org/wiki/Marie-Jeanne_de_Lalande
"lalande",
// Hedy Lamarr - Actress and inventor. The principles of her work are now incorporated into modern Wi-Fi, CDMA and Bluetooth technology. https://en.wikipedia.org/wiki/Hedy_Lamarr
"lamarr",
// Leslie B. Lamport - American computer scientist. Lamport is best known for his seminal work in distributed systems and was the winner of the 2013 Turing Award. https://en.wikipedia.org/wiki/Leslie_Lamport
"lamport",
// Mary Leakey - British paleoanthropologist who discovered the first fossilized Proconsul skull - https://en.wikipedia.org/wiki/Mary_Leakey
"leakey",
// Henrietta Swan Leavitt - she was an American astronomer who discovered the relation between the luminosity and the period of Cepheid variable stars. https://en.wikipedia.org/wiki/Henrietta_Swan_Leavitt
"leavitt",
//Daniel Lewin - Mathematician, Akamai co-founder, soldier, 9/11 victim-- Developed optimization techniques for routing traffic on the internet. Died attempting to stop the 9-11 hijackers. https://en.wikipedia.org/wiki/Daniel_Lewin
"lewin",
// Ruth Lichterman - one of the original programmers of the ENIAC. https://en.wikipedia.org/wiki/ENIAC - https://en.wikipedia.org/wiki/Ruth_Teitelbaum
"lichterman",
// Barbara Liskov - co-developed the Liskov substitution principle. Liskov was also the winner of the Turing Prize in 2008. - https://en.wikipedia.org/wiki/Barbara_Liskov
"liskov",
// Ada Lovelace invented the first algorithm. https://en.wikipedia.org/wiki/Ada_Lovelace (thanks James Turnbull)
"lovelace",
// Auguste and Louis Lumière - the first filmmakers in history - https://en.wikipedia.org/wiki/Auguste_and_Louis_Lumi%C3%A8re
"lumiere",
// Mahavira - Ancient Indian mathematician during 9th century AD who discovered basic algebraic identities - https://en.wikipedia.org/wiki/Mah%C4%81v%C4%ABra_(mathematician)
"mahavira",
// Maria Mayer - American theoretical physicist and Nobel laureate in Physics for proposing the nuclear shell model of the atomic nucleus - https://en.wikipedia.org/wiki/Maria_Mayer
"mayer",
// John McCarthy invented LISP: https://en.wikipedia.org/wiki/John_McCarthy_(computer_scientist)
"mccarthy",
// Barbara McClintock - a distinguished American cytogeneticist, 1983 Nobel Laureate in Physiology or Medicine for discovering transposons. https://en.wikipedia.org/wiki/Barbara_McClintock
"mcclintock",
// Malcolm McLean invented the modern shipping container: https://en.wikipedia.org/wiki/Malcom_McLean
"mclean",
// Kay McNulty - one of the original programmers of the ENIAC. https://en.wikipedia.org/wiki/ENIAC - https://en.wikipedia.org/wiki/Kathleen_Antonelli
"mcnulty",
// Lise Meitner - Austrian/Swedish physicist who was involved in the discovery of nuclear fission. The element meitnerium is named after her - https://en.wikipedia.org/wiki/Lise_Meitner
"meitner",
// Carla Meninsky, was the game designer and programmer for Atari 2600 games Dodge 'Em and Warlords. https://en.wikipedia.org/wiki/Carla_Meninsky
"meninsky",
// Johanna Mestorf - German prehistoric archaeologist and first female museum director in Germany - https://en.wikipedia.org/wiki/Johanna_Mestorf
"mestorf",
// Marvin Minsky - Pioneer in Artificial Intelligence, co-founder of the MIT's AI Lab, won the Turing Award in 1969. https://en.wikipedia.org/wiki/Marvin_Minsky
"minsky",
// Maryam Mirzakhani - an Iranian mathematician and the first woman to win the Fields Medal. https://en.wikipedia.org/wiki/Maryam_Mirzakhani
"mirzakhani",
// Samuel Morse - contributed to the invention of a single-wire telegraph system based on European telegraphs and was a co-developer of the Morse code - https://en.wikipedia.org/wiki/Samuel_Morse
"morse",
// Ian Murdock - founder of the Debian project - https://en.wikipedia.org/wiki/Ian_Murdock
"murdock",
// John von Neumann - todays computer architectures are based on the von Neumann architecture. https://en.wikipedia.org/wiki/Von_Neumann_architecture
"neumann",
// Isaac Newton invented classic mechanics and modern optics. https://en.wikipedia.org/wiki/Isaac_Newton
"newton",
// Florence Nightingale, more prominently known as a nurse, was also the first female member of the Royal Statistical Society and a pioneer in statistical graphics https://en.wikipedia.org/wiki/Florence_Nightingale#Statistics_and_sanitary_reform
"nightingale",
// Alfred Nobel - a Swedish chemist, engineer, innovator, and armaments manufacturer (inventor of dynamite) - https://en.wikipedia.org/wiki/Alfred_Nobel
"nobel",
// Emmy Noether, German mathematician. Noether's Theorem is named after her. https://en.wikipedia.org/wiki/Emmy_Noether
"noether",
// Poppy Northcutt. Poppy Northcutt was the first woman to work as part of NASAs Mission Control. http://www.businessinsider.com/poppy-northcutt-helped-apollo-astronauts-2014-12?op=1
"northcutt",
// Robert Noyce invented silicone integrated circuits and gave Silicon Valley its name. - https://en.wikipedia.org/wiki/Robert_Noyce
"noyce",
// Panini - Ancient Indian linguist and grammarian from 4th century CE who worked on the world's first formal system - https://en.wikipedia.org/wiki/P%C4%81%E1%B9%87ini#Comparison_with_modern_formal_systems
"panini",
// Ambroise Pare invented modern surgery. https://en.wikipedia.org/wiki/Ambroise_Par%C3%A9
"pare",
// Louis Pasteur discovered vaccination, fermentation and pasteurization. https://en.wikipedia.org/wiki/Louis_Pasteur.
"pasteur",
// Cecilia Payne-Gaposchkin was an astronomer and astrophysicist who, in 1925, proposed in her Ph.D. thesis an explanation for the composition of stars in terms of the relative abundances of hydrogen and helium. https://en.wikipedia.org/wiki/Cecilia_Payne-Gaposchkin
"payne",
// Radia Perlman is a software designer and network engineer and most famous for her invention of the spanning-tree protocol (STP). https://en.wikipedia.org/wiki/Radia_Perlman
"perlman",
// Rob Pike was a key contributor to Unix, Plan 9, the X graphic system, utf-8, and the Go programming language. https://en.wikipedia.org/wiki/Rob_Pike
"pike",
// Henri Poincaré made fundamental contributions in several fields of mathematics. https://en.wikipedia.org/wiki/Henri_Poincar%C3%A9
"poincare",
// Laura Poitras is a director and producer whose work, made possible by open source crypto tools, advances the causes of truth and freedom of information by reporting disclosures by whistleblowers such as Edward Snowden. https://en.wikipedia.org/wiki/Laura_Poitras
"poitras",
// Claudius Ptolemy - a Greco-Egyptian writer of Alexandria, known as a mathematician, astronomer, geographer, astrologer, and poet of a single epigram in the Greek Anthology - https://en.wikipedia.org/wiki/Ptolemy
"ptolemy",
// C. V. Raman - Indian physicist who won the Nobel Prize in 1930 for proposing the Raman effect. - https://en.wikipedia.org/wiki/C._V._Raman
"raman",
// Srinivasa Ramanujan - Indian mathematician and autodidact who made extraordinary contributions to mathematical analysis, number theory, infinite series, and continued fractions. - https://en.wikipedia.org/wiki/Srinivasa_Ramanujan
"ramanujan",
// Sally Kristen Ride was an American physicist and astronaut. She was the first American woman in space, and the youngest American astronaut. https://en.wikipedia.org/wiki/Sally_Ride
"ride",
// Rita Levi-Montalcini - Won Nobel Prize in Physiology or Medicine jointly with colleague Stanley Cohen for the discovery of nerve growth factor (https://en.wikipedia.org/wiki/Rita_Levi-Montalcini)
"montalcini",
// Dennis Ritchie - co-creator of UNIX and the C programming language. - https://en.wikipedia.org/wiki/Dennis_Ritchie
"ritchie",
// Wilhelm Conrad Röntgen - German physicist who was awarded the first Nobel Prize in Physics in 1901 for the discovery of X-rays (Röntgen rays). https://en.wikipedia.org/wiki/Wilhelm_R%C3%B6ntgen
"roentgen",
// Rosalind Franklin - British biophysicist and X-ray crystallographer whose research was critical to the understanding of DNA - https://en.wikipedia.org/wiki/Rosalind_Franklin
"rosalind",
// Meghnad Saha - Indian astrophysicist best known for his development of the Saha equation, used to describe chemical and physical conditions in stars - https://en.wikipedia.org/wiki/Meghnad_Saha
"saha",
// Jean E. Sammet developed FORMAC, the first widely used computer language for symbolic manipulation of mathematical formulas. https://en.wikipedia.org/wiki/Jean_E._Sammet
"sammet",
// Carol Shaw - Originally an Atari employee, Carol Shaw is said to be the first female video game designer. https://en.wikipedia.org/wiki/Carol_Shaw_(video_game_designer)
"shaw",
// Dame Stephanie "Steve" Shirley - Founded a software company in 1962 employing women working from home. https://en.wikipedia.org/wiki/Steve_Shirley
"shirley",
// William Shockley co-invented the transistor - https://en.wikipedia.org/wiki/William_Shockley
"shockley",
// Françoise Barré-Sinoussi - French virologist and Nobel Prize Laureate in Physiology or Medicine; her work was fundamental in identifying HIV as the cause of AIDS. https://en.wikipedia.org/wiki/Fran%C3%A7oise_Barr%C3%A9-Sinoussi
"sinoussi",
// Betty Snyder - one of the original programmers of the ENIAC. https://en.wikipedia.org/wiki/ENIAC - https://en.wikipedia.org/wiki/Betty_Holberton
"snyder",
// Frances Spence - one of the original programmers of the ENIAC. https://en.wikipedia.org/wiki/ENIAC - https://en.wikipedia.org/wiki/Frances_Spence
"spence",
// Richard Matthew Stallman - the founder of the Free Software movement, the GNU project, the Free Software Foundation, and the League for Programming Freedom. He also invented the concept of copyleft to protect the ideals of this movement, and enshrined this concept in the widely-used GPL (General Public License) for software. https://en.wikiquote.org/wiki/Richard_Stallman
"stallman",
// Michael Stonebraker is a database research pioneer and architect of Ingres, Postgres, VoltDB and SciDB. Winner of 2014 ACM Turing Award. https://en.wikipedia.org/wiki/Michael_Stonebraker
"stonebraker",
// Janese Swanson (with others) developed the first of the Carmen Sandiego games. She went on to found Girl Tech. https://en.wikipedia.org/wiki/Janese_Swanson
"swanson",
// Aaron Swartz was influential in creating RSS, Markdown, Creative Commons, Reddit, and much of the internet as we know it today. He was devoted to freedom of information on the web. https://en.wikiquote.org/wiki/Aaron_Swartz
"swartz",
// Bertha Swirles was a theoretical physicist who made a number of contributions to early quantum theory. https://en.wikipedia.org/wiki/Bertha_Swirles
"swirles",
// Nikola Tesla invented the AC electric system and every gadget ever used by a James Bond villain. https://en.wikipedia.org/wiki/Nikola_Tesla
"tesla",
// Ken Thompson - co-creator of UNIX and the C programming language - https://en.wikipedia.org/wiki/Ken_Thompson
"thompson",
// Linus Torvalds invented Linux and Git. https://en.wikipedia.org/wiki/Linus_Torvalds
"torvalds",
// Alan Turing was a founding father of computer science. https://en.wikipedia.org/wiki/Alan_Turing.
"turing",
// Varahamihira - Ancient Indian mathematician who discovered trigonometric formulae during 505-587 CE - https://en.wikipedia.org/wiki/Var%C4%81hamihira#Contributions
"varahamihira",
// Sir Mokshagundam Visvesvaraya - is a notable Indian engineer. He is a recipient of the Indian Republic's highest honour, the Bharat Ratna, in 1955. On his birthday, 15 September is celebrated as Engineer's Day in India in his memory - https://en.wikipedia.org/wiki/Visvesvaraya
"visvesvaraya",
// Christiane Nüsslein-Volhard - German biologist, won Nobel Prize in Physiology or Medicine in 1995 for research on the genetic control of embryonic development. https://en.wikipedia.org/wiki/Christiane_N%C3%BCsslein-Volhard
"volhard",
// Marlyn Wescoff - one of the original programmers of the ENIAC. https://en.wikipedia.org/wiki/ENIAC - https://en.wikipedia.org/wiki/Marlyn_Meltzer
"wescoff",
// Andrew Wiles - Notable British mathematician who proved the enigmatic Fermat's Last Theorem - https://en.wikipedia.org/wiki/Andrew_Wiles
"wiles",
// Roberta Williams, did pioneering work in graphical adventure games for personal computers, particularly the King's Quest series. https://en.wikipedia.org/wiki/Roberta_Williams
"williams",
// Sophie Wilson designed the first Acorn Micro-Computer and the instruction set for ARM processors. https://en.wikipedia.org/wiki/Sophie_Wilson
"wilson",
// Jeannette Wing - co-developed the Liskov substitution principle. - https://en.wikipedia.org/wiki/Jeannette_Wing
"wing",
// Steve Wozniak invented the Apple I and Apple II. https://en.wikipedia.org/wiki/Steve_Wozniak
"wozniak",
// The Wright brothers, Orville and Wilbur - credited with inventing and building the world's first successful airplane and making the first controlled, powered and sustained heavier-than-air human flight - https://en.wikipedia.org/wiki/Wright_brothers
"wright",
// Rosalyn Sussman Yalow - Rosalyn Sussman Yalow was an American medical physicist, and a co-winner of the 1977 Nobel Prize in Physiology or Medicine for development of the radioimmunoassay technique. https://en.wikipedia.org/wiki/Rosalyn_Sussman_Yalow
"yalow",
// Ada Yonath - an Israeli crystallographer, the first woman from the Middle East to win a Nobel prize in the sciences. https://en.wikipedia.org/wiki/Ada_Yonath
"yonath",
// Misc names that are fun to add including bands and musicians I like.
// Trent Reznor
"reznor",
// Jennifer Parkin
"ayria",
// https://en.wikipedia.org/wiki/Iris_(American_band)
"iris",
// https://theprodigy.com/
"prodigy",
// https://en.wikipedia.org/wiki/Rush_(band)
"rush",
// Animal Crossing characters that aren't human names
"barold", "nook", "zucker", "cherry", "cookie", "beardo", "deli",
// Matrix character names
"trinity", "neo", "apoc", "dozer", "morpheus", "tank", "switch",
// Random fun nouns
"multipass", "pizza", "dna",
// Video game characters
"mario", "zelda", "link",
// Ultimate frisbee terminology for Ginger
"huck", "hammer", "scoober", "disc", "frisbee",
}
)
func GeneratePhrase() string {
r := rand.New(rand.NewSource(time.Now().UnixNano())) //nolint
left_index := int(r.Float32() * float32(len(left)))
right_index := int(r.Float32() * float32(len(right)))
return fmt.Sprintf("%s-%s", left[left_index], right[right_index])
}

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"math/rand"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
@@ -237,3 +238,20 @@ func CleanupDirectory(path string) {
log.Fatalln("Unable to create directory. Please check the ownership and permissions", err) log.Fatalln("Unable to create directory. Please check the ownership and permissions", err)
} }
} }
func FindInSlice(slice []string, val string) (int, bool) {
for i, item := range slice {
if item == val {
return i, true
}
}
return -1, false
}
// GenerateRandomDisplayColor will return a random _hue_ to be used when displaying a user.
// The UI should determine the right saturation and lightness in order to make it look right.
func GenerateRandomDisplayColor() int {
rangeLower := 0
rangeUpper := 360
return rangeLower + rand.Intn(rangeUpper-rangeLower+1) //nolint
}

View File

@@ -1,6 +1,7 @@
<html> <html>
<head> <head>
<base target="_blank" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" /> <link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />

View File

@@ -3,6 +3,7 @@
<head> <head>
<title>Owncast</title> <title>Owncast</title>
<base target="_blank" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
@@ -35,10 +36,10 @@
<link href="./styles/user-content.css" rel="stylesheet" /> <link href="./styles/user-content.css" rel="stylesheet" />
<link href="./styles/app.css" rel="stylesheet" /> <link href="./styles/app.css" rel="stylesheet" />
<!-- The following script tags are not required for the app to run, <!-- The following script tags are not required for the app to run,
however they will make it load a lot faster (fewer round trips) when HTTP/2 is used. however they will make it load a lot faster (fewer round trips) when HTTP/2 is used.
If you wish to re-generate this list, run the following shell command If you wish to re-generate this list, run the following shell command
(assuming a linux or unix-ish system): (assuming a linux or unix-ish system):
find webroot | grep -E '\.js$' | sed -E 's|webroot(.*)|<script type="preload" src="\1"></script>|' find webroot | grep -E '\.js$' | sed -E 's|webroot(.*)|<script type="preload" src="\1"></script>|'
Don't load/preload app-standalone-chat.js or app-video-only.js. Don't load/preload app-standalone-chat.js or app-video-only.js.
@@ -120,4 +121,4 @@
</noscript> </noscript>
</body> </body>
</html> </html>

View File

@@ -4,21 +4,32 @@ const html = htm.bind(h);
import Chat from './components/chat/chat.js'; import Chat from './components/chat/chat.js';
import Websocket from './utils/websocket.js'; import Websocket from './utils/websocket.js';
import { getLocalStorage, generateUsername } from './utils/helpers.js'; import { getLocalStorage, setLocalStorage } from './utils/helpers.js';
import { KEY_USERNAME } from './utils/constants.js'; import { KEY_EMBED_CHAT_ACCESS_TOKEN } from './utils/constants.js';
import { registerChat } from './chat/register.js';
export default class StandaloneChat extends Component { export default class StandaloneChat extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
websocket: new Websocket(true), // Send along the "ignoreClient" flag so this isn't counted as a viewer
chatEnabled: true, // always true for standalone chat chatEnabled: true, // always true for standalone chat
username: getLocalStorage(KEY_USERNAME) || generateUsername(), username: null,
}; };
this.isRegistering = false;
this.hasConfiguredChat = false;
this.websocket = null; this.websocket = null;
this.handleUsernameChange = this.handleUsernameChange.bind(this); this.handleUsernameChange = this.handleUsernameChange.bind(this);
// If this is the first time setting the config
// then setup chat if it's enabled.
const chatBlocked = getLocalStorage('owncast_chat_blocked');
if (!chatBlocked && !this.hasConfiguredChat) {
this.setupChatAuth();
}
this.hasConfiguredChat = true;
} }
handleUsernameChange(newName) { handleUsernameChange(newName) {
@@ -27,17 +38,52 @@ export default class StandaloneChat extends Component {
}); });
} }
async setupChatAuth(force) {
var accessToken = getLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN);
const randomInt = Math.floor(Math.random() * 100) + 1
var username = 'chat-embed-' + randomInt;
if (!accessToken || force) {
try {
this.isRegistering = true;
const registration = await registerChat(username);
accessToken = registration.accessToken;
username = registration.displayName;
setLocalStorage(KEY_EMBED_CHAT_ACCESS_TOKEN, accessToken);
this.isRegistering = false;
} catch (e) {
console.error('registration error:', e);
}
}
if (this.state.websocket) {
this.state.websocket.shutdown();
this.setState({
websocket: null,
});
}
// Without a valid access token he websocket connection will be rejected.
const websocket = new Websocket(accessToken);
this.setState({
username,
websocket,
accessToken,
});
}
render(props, state) { render(props, state) {
const { username, websocket } = state; const { username, websocket, accessToken } = state;
return ( return html`
html` <${Chat}
<${Chat} websocket=${websocket}
websocket=${websocket} username=${username}
username=${username} accessToken=${accessToken}
messagesOnly messagesOnly
ignoreClient />
/> `;
`
);
} }
} }

View File

@@ -7,7 +7,9 @@ import SocialIconsList from './components/platform-logos-list.js';
import UsernameForm from './components/chat/username.js'; import UsernameForm from './components/chat/username.js';
import VideoPoster from './components/video-poster.js'; import VideoPoster from './components/video-poster.js';
import Chat from './components/chat/chat.js'; import Chat from './components/chat/chat.js';
import Websocket from './utils/websocket.js'; import Websocket, { CALLBACKS, SOCKET_MESSAGE_TYPES } from './utils/websocket.js';
import { registerChat } from './chat/register.js';
import ExternalActionModal, { import ExternalActionModal, {
ExternalActionButton, ExternalActionButton,
} from './components/external-action-modal.js'; } from './components/external-action-modal.js';
@@ -17,7 +19,6 @@ import {
classNames, classNames,
clearLocalStorage, clearLocalStorage,
debounce, debounce,
generateUsername,
getLocalStorage, getLocalStorage,
getOrientation, getOrientation,
hasTouchScreen, hasTouchScreen,
@@ -27,7 +28,10 @@ import {
setLocalStorage, setLocalStorage,
} from './utils/helpers.js'; } from './utils/helpers.js';
import { import {
CHAT_MAX_MESSAGE_LENGTH,
EST_SOCKET_PAYLOAD_BUFFER,
HEIGHT_SHORT_WIDE, HEIGHT_SHORT_WIDE,
KEY_ACCESS_TOKEN,
KEY_CHAT_DISPLAYED, KEY_CHAT_DISPLAYED,
KEY_USERNAME, KEY_USERNAME,
MESSAGE_OFFLINE, MESSAGE_OFFLINE,
@@ -54,10 +58,13 @@ export default class App extends Component {
this.windowBlurred = false; this.windowBlurred = false;
this.state = { this.state = {
websocket: new Websocket(), websocket: null,
displayChat: chatStorage === null ? true : chatStorage, canChat: false, // all of chat functionality (panel + username)
displayChatPanel: chatStorage === null ? true : (chatStorage === 'true'), // just the chat panel
chatInputEnabled: false, // chat input box state chatInputEnabled: false, // chat input box state
username: getLocalStorage(KEY_USERNAME) || generateUsername(), accessToken: null,
username: getLocalStorage(KEY_USERNAME),
isRegistering: false,
touchKeyboardActive: false, touchKeyboardActive: false,
configData: { configData: {
@@ -86,7 +93,7 @@ export default class App extends Component {
this.playerRestartTimer = null; this.playerRestartTimer = null;
this.offlineTimer = null; this.offlineTimer = null;
this.statusTimer = null; this.statusTimer = null;
this.disableChatTimer = null; this.disableChatInputTimer = null;
this.streamDurationTimer = null; this.streamDurationTimer = null;
// misc dom events // misc dom events
@@ -116,6 +123,14 @@ export default class App extends Component {
// fetch events // fetch events
this.getConfig = this.getConfig.bind(this); this.getConfig = this.getConfig.bind(this);
this.getStreamStatus = this.getStreamStatus.bind(this); this.getStreamStatus = this.getStreamStatus.bind(this);
// user events
this.handleWebsocketMessage = this.handleWebsocketMessage.bind(this);
// chat
this.hasConfiguredChat = false;
this.setupChatAuth = this.setupChatAuth.bind(this);
this.disableChat = this.disableChat.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -144,7 +159,7 @@ export default class App extends Component {
clearInterval(this.playerRestartTimer); clearInterval(this.playerRestartTimer);
clearInterval(this.offlineTimer); clearInterval(this.offlineTimer);
clearInterval(this.statusTimer); clearInterval(this.statusTimer);
clearTimeout(this.disableChatTimer); clearTimeout(this.disableChatInputTimer);
clearInterval(this.streamDurationTimer); clearInterval(this.streamDurationTimer);
window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('blur', this.handleWindowBlur);
@@ -197,10 +212,20 @@ export default class App extends Component {
} }
setConfigData(data = {}) { setConfigData(data = {}) {
const { name, summary } = data; const { name, summary, chatDisabled } = data;
window.document.title = name; window.document.title = name;
// If this is the first time setting the config
// then setup chat if it's enabled.
const chatBlocked = getLocalStorage('owncast_chat_blocked');
if (!chatBlocked && !this.hasConfiguredChat && !chatDisabled) {
this.setupChatAuth();
}
this.hasConfiguredChat = true;
this.setState({ this.setState({
canChat: !chatBlocked,
configData: { configData: {
...data, ...data,
summary: summary && addNewlines(summary), summary: summary && addNewlines(summary),
@@ -274,7 +299,7 @@ export default class App extends Component {
TIMER_DISABLE_CHAT_AFTER_OFFLINE - TIMER_DISABLE_CHAT_AFTER_OFFLINE -
(Date.now() - new Date(this.state.lastDisconnectTime)); (Date.now() - new Date(this.state.lastDisconnectTime));
const countdown = remainingChatTime < 0 ? 0 : remainingChatTime; const countdown = remainingChatTime < 0 ? 0 : remainingChatTime;
this.disableChatTimer = setTimeout(this.disableChatInput, countdown); this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown);
this.setState({ this.setState({
streamOnline: false, streamOnline: false,
streamStatusMessage: MESSAGE_OFFLINE, streamStatusMessage: MESSAGE_OFFLINE,
@@ -294,8 +319,8 @@ export default class App extends Component {
// play video! // play video!
handleOnlineMode() { handleOnlineMode() {
this.player.startPlayer(); this.player.startPlayer();
clearTimeout(this.disableChatTimer); clearTimeout(this.disableChatInputTimer);
this.disableChatTimer = null; this.disableChatInputTimer = null;
this.streamDurationTimer = setInterval( this.streamDurationTimer = setInterval(
this.setCurrentStreamDuration, this.setCurrentStreamDuration,
@@ -332,6 +357,8 @@ export default class App extends Component {
this.setState({ this.setState({
username: newName, username: newName,
}); });
this.sendUsernameChange(newName);
} }
handleFormFocus() { handleFormFocus() {
@@ -351,16 +378,12 @@ export default class App extends Component {
} }
handleChatPanelToggle() { handleChatPanelToggle() {
const { displayChat: curDisplayed } = this.state; const { displayChatPanel: curDisplayed } = this.state;
const displayChat = !curDisplayed; const displayChat = !curDisplayed;
if (displayChat) { setLocalStorage(KEY_CHAT_DISPLAYED, displayChat);
setLocalStorage(KEY_CHAT_DISPLAYED, displayChat);
} else {
clearLocalStorage(KEY_CHAT_DISPLAYED);
}
this.setState({ this.setState({
displayChat, displayChatPanel: displayChat,
}); });
} }
@@ -371,7 +394,7 @@ export default class App extends Component {
} }
handleNetworkingError(error) { handleNetworkingError(error) {
console.log(`>>> App Error: ${error}`); console.error(`>>> App Error: ${error}`);
} }
handleWindowResize() { handleWindowResize() {
@@ -492,11 +515,95 @@ export default class App extends Component {
}); });
} }
handleWebsocketMessage(e) {
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
// User has been actively disabled on the backend. Turn off chat for them.
this.handleBlockedChat();
} else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_NEEDS_REGISTRATION && !this.isRegistering) {
// User needs an access token, so start the user auth flow.
this.state.websocket.shutdown();
this.setState({websocket: null});
this.setupChatAuth(true);
} else if (e.type === SOCKET_MESSAGE_TYPES.ERROR_MAX_CONNECTIONS_EXCEEDED) {
// Chat server cannot support any more chat clients. Turn off chat for them.
this.disableChat();
} else if (e.type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
// When connected the user will return an event letting us know what our
// user details are so we can display them properly.
const {user} = e;
const {displayName} = user;
this.setState({username: displayName});
}
}
handleBlockedChat() {
setLocalStorage('owncast_chat_blocked', true);
this.disableChat();
}
disableChat() {
this.state.websocket.shutdown();
this.setState({ websocket: null, canChat: false });
}
async setupChatAuth(force) {
var accessToken = getLocalStorage(KEY_ACCESS_TOKEN);
var username = getLocalStorage(KEY_USERNAME);
if (!accessToken || force) {
try {
this.isRegistering = true;
const registration = await registerChat(this.state.username);
accessToken = registration.accessToken;
username = registration.displayName;
setLocalStorage(KEY_ACCESS_TOKEN, accessToken);
setLocalStorage(KEY_USERNAME, username);
this.isRegistering = false;
} catch (e) {
console.error('registration error:', e);
}
}
if (this.state.websocket) {
this.state.websocket.shutdown();
this.setState({
websocket: null
});
}
// Without a valid access token he websocket connection will be rejected.
const websocket = new Websocket(accessToken);
websocket.addListener(
CALLBACKS.RAW_WEBSOCKET_MESSAGE_RECEIVED,
this.handleWebsocketMessage
);
this.setState({
username,
websocket,
accessToken,
});
}
sendUsernameChange(newName) {
const nameChange = {
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
newName,
};
this.state.websocket.send(nameChange);
}
render(props, state) { render(props, state) {
const { const {
chatInputEnabled, chatInputEnabled,
configData, configData,
displayChat, displayChatPanel,
canChat,
isPlaying, isPlaying,
orientation, orientation,
playerActive, playerActive,
@@ -512,7 +619,6 @@ export default class App extends Component {
externalAction, externalAction,
lastDisconnectTime, lastDisconnectTime,
} = state; } = state;
const { const {
version: appVersion, version: appVersion,
logo = TEMP_IMAGE, logo = TEMP_IMAGE,
@@ -524,6 +630,7 @@ export default class App extends Component {
chatDisabled, chatDisabled,
externalActions, externalActions,
customStyles, customStyles,
maxSocketPayloadSize,
} = configData; } = configData;
const bgUserLogo = { backgroundImage: `url(${logo})` }; const bgUserLogo = { backgroundImage: `url(${logo})` };
@@ -544,13 +651,13 @@ export default class App extends Component {
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait; const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait;
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight; const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
const shouldDisplayChat = displayChat && !chatDisabled; const shouldDisplayChat = displayChatPanel && canChat && !chatDisabled;
const usernameStyle = chatDisabled ? 'none' : 'flex';
const extraAppClasses = classNames({ const extraAppClasses = classNames({
'config-loading': configData.loading, 'config-loading': configData.loading,
chat: shouldDisplayChat, chat: shouldDisplayChat,
'no-chat': !shouldDisplayChat, 'chat-hidden': !displayChatPanel && canChat && !chatDisabled, // hide panel
'chat-disabled': !canChat || chatDisabled,
'single-col': singleColMode, 'single-col': singleColMode,
'bg-gray-800': singleColMode && shouldDisplayChat, 'bg-gray-800': singleColMode && shouldDisplayChat,
'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL, 'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL,
@@ -587,6 +694,19 @@ export default class App extends Component {
onClose=${this.closeExternalActionModal} onClose=${this.closeExternalActionModal}
/>`; />`;
const chat = this.state.websocket
? html`
<${Chat}
websocket=${websocket}
username=${username}
chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name}
accessToken=${this.state.accessToken}
inputMaxBytes=${(maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER) || CHAT_MAX_MESSAGE_LENGTH}
/>
`
: null;
return html` return html`
<div <div
id="app-container" id="app-container"
@@ -620,7 +740,6 @@ export default class App extends Component {
<div <div
id="user-options-container" id="user-options-container"
class="flex flex-row justify-end items-center flex-no-wrap" class="flex flex-row justify-end items-center flex-no-wrap"
style=${{ display: usernameStyle }}
> >
<${UsernameForm} <${UsernameForm}
username=${username} username=${username}
@@ -633,6 +752,7 @@ export default class App extends Component {
id="chat-toggle" id="chat-toggle"
onClick=${this.handleChatPanelToggle} onClick=${this.handleChatPanelToggle}
class="flex cursor-pointer text-center justify-center items-center min-w-12 h-full bg-gray-800 hover:bg-gray-700" class="flex cursor-pointer text-center justify-center items-center min-w-12 h-full bg-gray-800 hover:bg-gray-700"
style=${{ display: chatDisabled ? 'none' : 'block' }}
> >
💬 💬
</button> </button>
@@ -712,13 +832,7 @@ export default class App extends Component {
</span> </span>
</footer> </footer>
<${Chat} ${chat} ${externalActionModal}
websocket=${websocket}
username=${username}
chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name}
/>
${externalActionModal}
</div> </div>
`; `;
} }

View File

@@ -0,0 +1,19 @@
import {URL_CHAT_REGISTRATION} from "../utils/constants.js";
export async function registerChat(username) {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({displayName: username})
}
try {
const response = await fetch(URL_CHAT_REGISTRATION, options);
const result = await response.json();
return result;
} catch(e) {
console.error(e);
}
}

View File

@@ -10,6 +10,9 @@ import {
getCaretPosition, getCaretPosition,
convertToText, convertToText,
convertOnPaste, convertOnPaste,
createEmojiMarkup,
trimNbsp,
emojify,
} from '../../utils/chat.js'; } from '../../utils/chat.js';
import { import {
getLocalStorage, getLocalStorage,
@@ -19,7 +22,6 @@ import {
import { import {
URL_CUSTOM_EMOJIS, URL_CUSTOM_EMOJIS,
KEY_CHAT_FIRST_MESSAGE_SENT, KEY_CHAT_FIRST_MESSAGE_SENT,
CHAT_MAX_MESSAGE_LENGTH,
CHAT_CHAR_COUNT_BUFFER, CHAT_CHAR_COUNT_BUFFER,
CHAT_OK_KEYCODES, CHAT_OK_KEYCODES,
CHAT_KEY_MODIFIERS, CHAT_KEY_MODIFIERS,
@@ -38,10 +40,10 @@ export default class ChatInput extends Component {
this.state = { this.state = {
inputHTML: '', inputHTML: '',
inputText: '', // for counting inputCharsLeft: props.inputMaxBytes,
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH,
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT), hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
emojiPicker: null, emojiPicker: null,
emojiList: null,
}; };
this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this); this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this);
@@ -71,6 +73,7 @@ export default class ChatInput extends Component {
return response.json(); return response.json();
}) })
.then((json) => { .then((json) => {
const emojiList = json;
const emojiPicker = new EmojiButton({ const emojiPicker = new EmojiButton({
zIndex: 100, zIndex: 100,
theme: 'owncast', // see chat.css theme: 'owncast', // see chat.css
@@ -91,7 +94,7 @@ export default class ChatInput extends Component {
this.formMessageInput.current.focus(); this.formMessageInput.current.focus();
replaceCaret(this.formMessageInput.current); replaceCaret(this.formMessageInput.current);
}); });
this.setState({ emojiPicker }); this.setState({ emojiList, emojiPicker });
}) })
.catch((error) => { .catch((error) => {
// this.handleNetworkingError(`Emoji Fetch: ${error}`); // this.handleNetworkingError(`Emoji Fetch: ${error}`);
@@ -106,18 +109,23 @@ export default class ChatInput extends Component {
} }
handleEmojiSelected(emoji) { handleEmojiSelected(emoji) {
const { inputHTML } = this.state; const { inputHTML, inputCharsLeft } = this.state;
// if we're already at char limit, don't do anything
if (inputCharsLeft < 0) {
return;
}
let content = ''; let content = '';
if (emoji.url) { if (emoji.url) {
const url = location.protocol + '//' + location.host + '/' + emoji.url; content = createEmojiMarkup(emoji, false);
const name = url.split('\\').pop().split('/').pop();
content = '<img class="emoji" alt="' + name + '" src="' + url + '"/>';
} else { } else {
content = emoji.emoji; content = emoji.emoji;
} }
const newHTML = inputHTML + content;
const charsLeft = this.calculateCurrentBytesLeft(newHTML);
this.setState({ this.setState({
inputHTML: inputHTML + content, inputHTML: inputHTML + content,
inputCharsLeft: charsLeft,
}); });
// a hacky way add focus back into input field // a hacky way add focus back into input field
setTimeout(() => { setTimeout(() => {
@@ -159,23 +167,33 @@ export default class ChatInput extends Component {
if (possibilities.length > 0) { if (possibilities.length > 0) {
this.suggestion = possibilities[this.completionIndex]; this.suggestion = possibilities[this.completionIndex];
const newHTML = inputHTML.substring(0, at + 1) + this.suggestion + ' ' + inputHTML.substring(position);
this.setState({ this.setState({
inputHTML: inputHTML: newHTML,
inputHTML.substring(0, at + 1) + inputCharsLeft: this.calculateCurrentBytesLeft(newHTML),
this.suggestion +
' ' +
inputHTML.substring(position),
}); });
} }
return true; return true;
} }
// replace :emoji: with the emoji <img>
injectEmoji() {
const { inputHTML, emojiList } = this.state;
const textValue = convertToText(inputHTML);
const processedHTML = emojify(inputHTML, emojiList);
if (textValue != convertToText(processedHTML)) {
this.setState({
inputHTML: processedHTML,
});
return true;
}
return false;
}
handleMessageInputKeydown(event) { handleMessageInputKeydown(event) {
const formField = this.formMessageInput.current;
let textValue = formField.textContent; // get this only to count chars
const newStates = {};
let numCharsLeft = CHAT_MAX_MESSAGE_LENGTH - textValue.length;
const key = event && event.key; const key = event && event.key;
if (key === 'Enter') { if (key === 'Enter') {
@@ -196,37 +214,32 @@ export default class ChatInput extends Component {
if (key === 'Tab') { if (key === 'Tab') {
if (this.autoCompleteNames()) { if (this.autoCompleteNames()) {
event.preventDefault(); event.preventDefault();
// value could have been changed, update char count
textValue = formField.textContent;
numCharsLeft = CHAT_MAX_MESSAGE_LENGTH - textValue.length;
} }
} }
if (numCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) { // if new input pushes the potential chars over, don't do anything
newStates.inputText = textValue; const formField = this.formMessageInput.current;
this.setState(newStates); const tempCharsLeft = this.calculateCurrentBytesLeft(formField.innerHTML);
if (tempCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) {
if (!this.modifierKeyPressed) { if (!this.modifierKeyPressed) {
event.preventDefault(); // prevent typing more event.preventDefault(); // prevent typing more
} }
return; return;
} }
newStates.inputText = textValue;
this.setState(newStates);
} }
handleMessageInputKeyup(event) { handleMessageInputKeyup(event) {
const formField = this.formMessageInput.current;
const textValue = formField.textContent; // get this only to count chars
const { key } = event; const { key } = event;
if (key === 'Control' || key === 'Shift') { if (key === 'Control' || key === 'Shift') {
this.prepNewLine = false; this.prepNewLine = false;
} }
if (CHAT_KEY_MODIFIERS.includes(key)) { if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = false; this.modifierKeyPressed = false;
} }
if (key === ':' || key === ';') {
this.injectEmoji();
}
this.setState({ this.setState({
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH - textValue.length, inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH - textValue.length,
}); });
@@ -239,11 +252,11 @@ export default class ChatInput extends Component {
handlePaste(event) { handlePaste(event) {
// don't allow paste if too much text already // don't allow paste if too much text already
if (CHAT_MAX_MESSAGE_LENGTH - this.state.inputText.length < 0) { if (this.state.inputCharsLeft < 0) {
event.preventDefault(); event.preventDefault();
return; return;
} }
convertOnPaste(event); convertOnPaste(event, this.state.emojiList);
this.handleMessageInputKeydown(event); this.handleMessageInputKeydown(event);
} }
@@ -253,16 +266,15 @@ export default class ChatInput extends Component {
} }
sendMessage() { sendMessage() {
const { handleSendMessage } = this.props; const { handleSendMessage, inputMaxBytes } = this.props;
const { hasSentFirstChatMessage, inputHTML, inputText } = this.state; const { hasSentFirstChatMessage, inputHTML, inputCharsLeft } = this.state;
if (CHAT_MAX_MESSAGE_LENGTH - inputText.length < 0) { if (inputCharsLeft < 0) {
return; return;
} }
const message = convertToText(inputHTML); const message = convertToText(inputHTML);
const newStates = { const newStates = {
inputHTML: '', inputHTML: '',
inputText: '', inputCharsLeft: inputMaxBytes,
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH,
}; };
handleSendMessage(message); handleSendMessage(message);
@@ -277,13 +289,23 @@ export default class ChatInput extends Component {
} }
handleContentEditableChange(event) { handleContentEditableChange(event) {
this.setState({ inputHTML: event.target.value }); const value = event.target.value;
this.setState({
inputHTML: value,
inputCharsLeft: this.calculateCurrentBytesLeft(value),
});
}
calculateCurrentBytesLeft(inputContent) {
const { inputMaxBytes } = this.props;
const curBytes = new Blob([trimNbsp(inputContent)]).size;
return inputMaxBytes - curBytes;
} }
render(props, state) { render(props, state) {
const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } = const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } =
state; state;
const { inputEnabled } = props; const { inputEnabled, inputMaxBytes } = props;
const emojiButtonStyle = { const emojiButtonStyle = {
display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none', display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none',
}; };
@@ -348,7 +370,7 @@ export default class ChatInput extends Component {
</span> </span>
<span id="message-form-warning" class="text-red-600 text-xs" <span id="message-form-warning" class="text-red-600 text-xs"
>${inputCharsLeft}/${CHAT_MAX_MESSAGE_LENGTH}</span >${inputCharsLeft} bytes</span
> >
</div> </div>
</div> </div>

View File

@@ -4,8 +4,8 @@ import Mark from '/js/web_modules/markjs/dist/mark.es6.min.js';
const html = htm.bind(h); const html = htm.bind(h);
import { import {
messageBubbleColorForString, messageBubbleColorForHue,
textColorForString, textColorForHue,
} from '../../utils/user-colors.js'; } from '../../utils/user-colors.js';
import { convertToText } from '../../utils/chat.js'; import { convertToText } from '../../utils/chat.js';
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
@@ -28,8 +28,9 @@ export default class ChatMessageView extends Component {
async componentDidMount() { async componentDidMount() {
const { message, username } = this.props; const { message, username } = this.props;
const { body } = message;
if (message && username) { if (message && username) {
const { body } = message;
const formattedMessage = await formatMessageText(body, username); const formattedMessage = await formatMessageText(body, username);
this.setState({ this.setState({
formattedMessage, formattedMessage,
@@ -39,22 +40,24 @@ export default class ChatMessageView extends Component {
render() { render() {
const { message } = this.props; const { message } = this.props;
const { author, timestamp, visible } = message; const { user, timestamp } = message;
const { displayName, displayColor, createdAt } = user;
const { formattedMessage } = this.state; const { formattedMessage } = this.state;
if (!formattedMessage) { if (!formattedMessage) {
return null; return null;
} }
const formattedTimestamp = formatTimestamp(timestamp); const formattedTimestamp = `Sent at ${formatTimestamp(timestamp)}`;
const userMetadata = `${displayName} first joined ${formatTimestamp(createdAt)}`;
const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM; const isSystemMessage = message.type === SOCKET_MESSAGE_TYPES.SYSTEM;
const authorTextColor = isSystemMessage const authorTextColor = isSystemMessage
? { color: '#fff' } ? { color: '#fff' }
: { color: textColorForString(author) }; : { color: textColorForHue(displayColor) };
const backgroundStyle = isSystemMessage const backgroundStyle = isSystemMessage
? { backgroundColor: '#667eea' } ? { backgroundColor: '#667eea' }
: { backgroundColor: messageBubbleColorForString(author) }; : { backgroundColor: messageBubbleColorForHue(displayColor) };
const messageClassString = isSystemMessage const messageClassString = isSystemMessage
? getSystemMessageClassString() ? getSystemMessageClassString()
: getChatMessageClassString(); : getChatMessageClassString();
@@ -66,8 +69,8 @@ export default class ChatMessageView extends Component {
title=${formattedTimestamp} title=${formattedTimestamp}
> >
<div class="message-content break-words w-full"> <div class="message-content break-words w-full">
<div style=${authorTextColor} class="message-author font-bold"> <div style=${authorTextColor} class="message-author font-bold" title=${userMetadata}>
${author} ${displayName}
</div> </div>
<div <div
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2" class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
@@ -87,7 +90,7 @@ function getChatMessageClassString() {
return 'message flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm'; return 'message flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm';
} }
export async function formatMessageText(message, username) { export async function formatMessageText(message, username) {
let formattedText = getMessageWithEmbeds(message); let formattedText = getMessageWithEmbeds(message);
formattedText = convertToMarkup(formattedText); formattedText = convertToMarkup(formattedText);
return await highlightUsername(formattedText, username); return await highlightUsername(formattedText, username);
@@ -156,7 +159,7 @@ function formatTimestamp(sentAt) {
return ''; return '';
} }
let diffInDays = getDiffInDaysFromNow(sentAt); //(new Date() - sentAt) / (24 * 3600 * 1000); let diffInDays = getDiffInDaysFromNow(sentAt);
if (diffInDays >= 1) { if (diffInDays >= 1) {
return ( return (
`Sent at ${sentAt.toLocaleDateString('en-US', { `Sent at ${sentAt.toLocaleDateString('en-US', {
@@ -165,7 +168,7 @@ function formatTimestamp(sentAt) {
); );
} }
return `Sent at ${sentAt.toLocaleTimeString()}`; return `${sentAt.toLocaleTimeString()}`;
} }
/* /*

View File

@@ -8,13 +8,12 @@ import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
import { import {
jumpToBottom, jumpToBottom,
debounce, debounce,
getLocalStorage, setLocalStorage,
} from '../../utils/helpers.js'; } from '../../utils/helpers.js';
import { extraUserNamesFromMessageHistory } from '../../utils/chat.js'; import { extraUserNamesFromMessageHistory } from '../../utils/chat.js';
import { import {
URL_CHAT_HISTORY, URL_CHAT_HISTORY,
MESSAGE_JUMPTOBOTTOM_BUFFER, MESSAGE_JUMPTOBOTTOM_BUFFER,
KEY_CUSTOM_USERNAME_SET,
} from '../../utils/constants.js'; } from '../../utils/constants.js';
export default class Chat extends Component { export default class Chat extends Component {
@@ -33,6 +32,7 @@ export default class Chat extends Component {
this.websocket = null; this.websocket = null;
this.receivedFirstMessages = false; this.receivedFirstMessages = false;
this.receivedMessageUpdate = false; this.receivedMessageUpdate = false;
this.hasFetchedHistory = false;
this.windowBlurred = false; this.windowBlurred = false;
this.numMessagesSinceBlur = 0; this.numMessagesSinceBlur = 0;
@@ -52,7 +52,6 @@ export default class Chat extends Component {
componentDidMount() { componentDidMount() {
this.setupWebSocketCallbacks(); this.setupWebSocketCallbacks();
this.getChatHistory();
window.addEventListener('resize', this.handleWindowResize); window.addEventListener('resize', this.handleWindowResize);
@@ -93,23 +92,25 @@ export default class Chat extends Component {
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { username: prevName } = prevProps; const { username: prevName } = prevProps;
const { username } = this.props; const { username, accessToken } = this.props;
const { messages: prevMessages } = prevState; const { messages: prevMessages } = prevState;
const { messages } = this.state; const { messages } = this.state;
// if username updated, send a message
if (prevName !== username) {
this.sendUsernameChange(prevName, username);
}
// scroll to bottom of messages list when new ones come in // scroll to bottom of messages list when new ones come in
if (messages.length !== prevMessages.length) { if (messages.length !== prevMessages.length) {
this.setState({ this.setState({
newMessagesReceived: true, newMessagesReceived: true,
}); });
} }
// Fetch chat history
if (!this.hasFetchedHistory && accessToken) {
this.hasFetchedHistory = true;
this.getChatHistory(accessToken);
}
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('resize', this.handleWindowResize);
if (!this.props.messagesOnly) { if (!this.props.messagesOnly) {
@@ -138,8 +139,8 @@ export default class Chat extends Component {
} }
// fetch chat history // fetch chat history
getChatHistory() { getChatHistory(accessToken) {
fetch(URL_CHAT_HISTORY) fetch(URL_CHAT_HISTORY + `?accessToken=${accessToken}`)
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
throw new Error(`Network response was not ok ${response.ok}`); throw new Error(`Network response was not ok ${response.ok}`);
@@ -153,30 +154,20 @@ export default class Chat extends Component {
messages: this.state.messages.concat(data), messages: this.state.messages.concat(data),
chatUserNames, chatUserNames,
}); });
this.scrollToBottom();
}) })
.catch((error) => { .catch((error) => {
this.handleNetworkingError(`Fetch getChatHistory: ${error}`); this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
}); });
} }
sendUsernameChange(oldName, newName) {
clearTimeout(this.sendUserJoinedEvent);
const nameChange = {
type: SOCKET_MESSAGE_TYPES.NAME_CHANGE,
oldName,
newName,
};
this.websocket.send(nameChange);
}
receivedWebsocketMessage(message) { receivedWebsocketMessage(message) {
this.handleMessage(message); this.handleMessage(message);
} }
handleNetworkingError(error) { handleNetworkingError(error) {
// todo: something more useful // todo: something more useful
console.log(error); console.error('chat error', error);
} }
// handle any incoming message // handle any incoming message
@@ -247,13 +238,6 @@ export default class Chat extends Component {
this.setState({ this.setState({
webSocketConnected: true, webSocketConnected: true,
}); });
const hasPreviouslySetCustomUsername = getLocalStorage(
KEY_CUSTOM_USERNAME_SET
);
if (hasPreviouslySetCustomUsername && !this.props.ignoreClient) {
this.sendJoinedMessage();
}
} }
websocketDisconnected() { websocketDisconnected() {
@@ -269,38 +253,20 @@ export default class Chat extends Component {
const { username } = this.props; const { username } = this.props;
const message = { const message = {
body: content, body: content,
author: username, type: SOCKET_MESSAGE_TYPES.CHAT,
type: SOCKET_MESSAGE_TYPES.CHAT,
}; };
this.websocket.send(message); this.websocket.send(message);
} }
sendJoinedMessage() {
const { username } = this.props;
const message = {
username: username,
type: SOCKET_MESSAGE_TYPES.USER_JOINED,
};
// Artificial delay so people who join and immediately
// leave don't get counted.
this.sendUserJoinedEvent = setTimeout(
function () {
this.websocket.send(message);
}.bind(this),
5000
);
}
updateAuthorList(message) { updateAuthorList(message) {
const { type } = message; const { type } = message;
const nameList = this.state.chatUserNames; const nameList = this.state.chatUserNames;
if ( if (
type === SOCKET_MESSAGE_TYPES.CHAT && type === SOCKET_MESSAGE_TYPES.CHAT &&
!nameList.includes(message.author) !nameList.includes(message.user.displayName)
) { ) {
return nameList.push(message.author); return nameList.push(message.user.displayName);
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
const { oldName, newName } = message; const { oldName, newName } = message;
const oldNameIndex = nameList.indexOf(oldName); const oldNameIndex = nameList.indexOf(oldName);
@@ -373,7 +339,7 @@ export default class Chat extends Component {
} }
render(props, state) { render(props, state) {
const { username, messagesOnly, chatInputEnabled } = props; const { username, messagesOnly, chatInputEnabled, inputMaxBytes } = props;
const { messages, chatUserNames, webSocketConnected } = state; const { messages, chatUserNames, webSocketConnected } = state;
const messageList = messages const messageList = messages
@@ -416,6 +382,7 @@ export default class Chat extends Component {
chatUserNames=${chatUserNames} chatUserNames=${chatUserNames}
inputEnabled=${webSocketConnected && chatInputEnabled} inputEnabled=${webSocketConnected && chatInputEnabled}
handleSendMessage=${this.submitChat} handleSendMessage=${this.submitChat}
inputMaxBytes=${inputMaxBytes}
/> />
</div> </div>
</section> </section>

View File

@@ -12,33 +12,35 @@ export default function Message(props) {
if (type === SOCKET_MESSAGE_TYPES.CHAT || type === SOCKET_MESSAGE_TYPES.SYSTEM) { if (type === SOCKET_MESSAGE_TYPES.CHAT || type === SOCKET_MESSAGE_TYPES.SYSTEM) {
return html`<${ChatMessageView} ...${props} />`; return html`<${ChatMessageView} ...${props} />`;
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) { } else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
const { oldName, newName } = message; const { oldName, user } = message;
const { displayName } = user;
return ( return (
html` html`
<div class="message message-name-change flex items-center justify-start p-3"> <div class="message message-name-change flex items-center justify-start p-3">
<div class="message-content flex flex-row items-center justify-center text-sm w-full"> <div class="message-content flex flex-row items-center justify-center text-sm w-full">
<div class="text-white text-center opacity-50 overflow-hidden break-words"> <div class="text-white text-center opacity-50 overflow-hidden break-words">
<span class="font-bold">${oldName}</span> is now known as <span class="font-bold">${newName}</span>. <span class="font-bold">${oldName}</span> is now known as <span class="font-bold">${displayName}</span>.
</div> </div>
</div> </div>
</div> </div>
` `
); );
} else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) { } else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) {
const { username } = message; const { user } = message
const { displayName } = user;
return ( return (
html` html`
<div class="message message-user-joined flex items-center justify-start p-3"> <div class="message message-user-joined flex items-center justify-start p-3">
<div class="message-content flex flex-row items-center justify-center text-sm w-full"> <div class="message-content flex flex-row items-center justify-center text-sm w-full">
<div class="text-white text-center opacity-50 overflow-hidden break-words"> <div class="text-white text-center opacity-50 overflow-hidden break-words">
<span class="font-bold">${username}</span> joined the chat. <span class="font-bold">${displayName}</span> joined the chat.
</div> </div>
</div> </div>
</div> </div>
` `
); );
} else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) { } else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) {
const { author, body } = message; const { body } = message;
const formattedMessage = `${body}` const formattedMessage = `${body}`
return ( return (
html` html`
@@ -51,6 +53,8 @@ export default function Message(props) {
</div> </div>
` `
); );
} else if (type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
// noop for now
} else { } else {
console.log("Unknown message type:", type); console.log("Unknown message type:", type);
} }

View File

@@ -24,7 +24,7 @@ export default class ExternalActionModal extends Component {
onClose: this.props.onClose, onClose: this.props.onClose,
}); });
} catch (e) { } catch (e) {
console.log('micromodal error: ', e); console.error('modal error: ', e);
} }
} }

View File

@@ -155,7 +155,7 @@ class OwncastPlayer {
const response = await fetch('/api/video/variants'); const response = await fetch('/api/video/variants');
qualities = await response.json(); qualities = await response.json();
} catch (e) { } catch (e) {
console.log(e); console.error(e);
} }
var MenuItem = videojs.getComponent('MenuItem'); var MenuItem = videojs.getComponent('MenuItem');

View File

@@ -7,41 +7,41 @@ import {
// Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position // Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position
export function getCaretPosition(editableDiv) { export function getCaretPosition(editableDiv) {
var caretPos = 0, var caretPos = 0,
sel, range; sel, range;
if (window.getSelection) { if (window.getSelection) {
sel = window.getSelection(); sel = window.getSelection();
if (sel.rangeCount) { if (sel.rangeCount) {
range = sel.getRangeAt(0); range = sel.getRangeAt(0);
if (range.commonAncestorContainer.parentNode == editableDiv) { if (range.commonAncestorContainer.parentNode == editableDiv) {
caretPos = range.endOffset; caretPos = range.endOffset;
} }
} }
} else if (document.selection && document.selection.createRange) { } else if (document.selection && document.selection.createRange) {
range = document.selection.createRange(); range = document.selection.createRange();
if (range.parentElement() == editableDiv) { if (range.parentElement() == editableDiv) {
var tempEl = document.createElement("span"); var tempEl = document.createElement("span");
editableDiv.insertBefore(tempEl, editableDiv.firstChild); editableDiv.insertBefore(tempEl, editableDiv.firstChild);
var tempRange = range.duplicate(); var tempRange = range.duplicate();
tempRange.moveToElementText(tempEl); tempRange.moveToElementText(tempEl);
tempRange.setEndPoint("EndToEnd", range); tempRange.setEndPoint("EndToEnd", range);
caretPos = tempRange.text.length; caretPos = tempRange.text.length;
} }
} }
return caretPos; return caretPos;
} }
// Might not need this anymore // Might not need this anymore
// Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div // Pieced together from parts of https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
export function setCaretPosition(editableDiv, position) { export function setCaretPosition(editableDiv, position) {
var range = document.createRange(); var range = document.createRange();
var sel = window.getSelection(); var sel = window.getSelection();
range.selectNode(editableDiv); range.selectNode(editableDiv);
range.setStart(editableDiv.childNodes[0], position); range.setStart(editableDiv.childNodes[0], position);
range.collapse(true); range.collapse(true);
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
} }
@@ -55,9 +55,9 @@ export function generatePlaceholderText(isEnabled, hasSentFirstChatMessage) {
export function extraUserNamesFromMessageHistory(messages) { export function extraUserNamesFromMessageHistory(messages) {
const list = []; const list = [];
if (messages) { if (messages) {
messages.forEach(function(message) { messages.forEach(function (message) {
if (!list.includes(message.author)) { if (!list.includes(message.user.displayName)) {
list.push(message.author); list.push(message.user.displayName);
} }
}); });
} }
@@ -86,6 +86,9 @@ export function convertToText(str = '') {
// Replace `<p>` (from IE). // Replace `<p>` (from IE).
value = value.replace(/<p>/gi, '\n'); value = value.replace(/<p>/gi, '\n');
// Cleanup the emoji titles.
value = value.replace(/\u200C{2}/gi, '');
// Trim each line. // Trim each line.
value = value value = value
.split('\n') .split('\n')
@@ -109,7 +112,7 @@ export function convertToText(str = '') {
You would call this when a user pastes from You would call this when a user pastes from
the clipboard into a `contenteditable` area. the clipboard into a `contenteditable` area.
*/ */
export function convertOnPaste( event = { preventDefault() {} }) { export function convertOnPaste(event = { preventDefault() { } }, emojiList) {
// Prevent paste. // Prevent paste.
event.preventDefault(); event.preventDefault();
@@ -136,8 +139,47 @@ export function convertOnPaste( event = { preventDefault() {} }) {
// Clean up text. // Clean up text.
value = convertToText(value); value = convertToText(value);
const HTML = emojify(value, emojiList);
// Insert text. // Insert text.
if (typeof document.execCommand === 'function') { if (typeof document.execCommand === 'function') {
document.execCommand('insertText', false, value); document.execCommand('insertHTML', false, HTML);
} }
} }
export function createEmojiMarkup(data, isCustom) {
const emojiUrl = isCustom ? data.emoji : data.url;
const emojiName = (isCustom ? data.name : data.url.split('\\').pop().split('/').pop().split('.').shift()).toLowerCase();
return '<img class="emoji" alt=":' + emojiName + ':" title=":' + emojiName + ':" src="' + emojiUrl + '"/>';
}
// trim html white space characters from ends of messages for more accurate counting
export function trimNbsp(html) {
return html.replace(/^(?:&nbsp;|\s)+|(?:&nbsp;|\s)+$/ig,'');
}
export function emojify(HTML, emojiList) {
const textValue = convertToText(HTML)
for (var lastPos = textValue.length; lastPos >= 0; lastPos--) {
const endPos = textValue.lastIndexOf(':', lastPos);
if (endPos <= 0) {
break;
}
const startPos = textValue.lastIndexOf(':', endPos - 1);
if (startPos === -1) {
break;
}
const typedEmoji = textValue.substring(startPos + 1, endPos).trim();
const emojiIndex = emojiList.findIndex(function (emojiItem) {
return emojiItem.name.toLowerCase() === typedEmoji.toLowerCase();
});
if (emojiIndex != -1) {
const emojiImgElement = createEmojiMarkup(emojiList[emojiIndex], true)
HTML = HTML.replace(":" + typedEmoji + ":", emojiImgElement)
}
}
return HTML;
}

View File

@@ -8,9 +8,8 @@ export const URL_VIEWER_PING = `/api/ping`;
// TODO: This directory is customizable in the config. So we should expose this via the config API. // TODO: This directory is customizable in the config. So we should expose this via the config API.
export const URL_STREAM = `/hls/stream.m3u8`; export const URL_STREAM = `/hls/stream.m3u8`;
export const URL_WEBSOCKET = `${ export const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
location.protocol === 'https:' ? 'wss' : 'ws' export const URL_CHAT_REGISTRATION = `/api/chat/register`;
}://${location.host}/entry`;
export const TIMER_STATUS_UPDATE = 5000; // ms export const TIMER_STATUS_UPDATE = 5000; // ms
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
@@ -26,6 +25,8 @@ export const MESSAGE_ONLINE = 'Stream is online.';
export const URL_OWNCAST = 'https://owncast.online'; // used in footer export const URL_OWNCAST = 'https://owncast.online'; // used in footer
export const PLAYER_VOLUME = 'owncast_volume'; export const PLAYER_VOLUME = 'owncast_volume';
export const KEY_ACCESS_TOKEN = 'owncast_access_token';
export const KEY_EMBED_CHAT_ACCESS_TOKEN = 'owncast_embed_chat_access_token';
export const KEY_USERNAME = 'owncast_username'; export const KEY_USERNAME = 'owncast_username';
export const KEY_CUSTOM_USERNAME_SET = 'owncast_custom_username_set'; export const KEY_CUSTOM_USERNAME_SET = 'owncast_custom_username_set';
export const KEY_CHAT_DISPLAYED = 'owncast_chat'; export const KEY_CHAT_DISPLAYED = 'owncast_chat';
@@ -35,6 +36,7 @@ export const CHAT_INITIAL_PLACEHOLDER_TEXT =
export const CHAT_PLACEHOLDER_TEXT = 'Message'; export const CHAT_PLACEHOLDER_TEXT = 'Message';
export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.'; export const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.';
export const CHAT_MAX_MESSAGE_LENGTH = 500; export const CHAT_MAX_MESSAGE_LENGTH = 500;
export const EST_SOCKET_PAYLOAD_BUFFER = 512;
export const CHAT_CHAR_COUNT_BUFFER = 20; export const CHAT_CHAR_COUNT_BUFFER = 20;
export const CHAT_OK_KEYCODES = [ export const CHAT_OK_KEYCODES = [
'ArrowLeft', 'ArrowLeft',

View File

@@ -92,10 +92,6 @@ export function getOrientation(forTouch = false) {
} }
} }
export function generateUsername() {
return `User ${Math.floor(Math.random() * 42) + 1}`;
}
export function padLeft(text, pad, size) { export function padLeft(text, pad, size) {
return String(pad.repeat(size) + text).slice(-size); return String(pad.repeat(size) + text).slice(-size);
} }
@@ -122,7 +118,6 @@ export function setVHvar() {
var vh = window.innerHeight * 0.01; var vh = window.innerHeight * 0.01;
// Then we set the value in the --vh custom property to the root of the document // Then we set the value in the --vh custom property to the root of the document
document.documentElement.style.setProperty('--vh', `${vh}px`); document.documentElement.style.setProperty('--vh', `${vh}px`);
console.log('== new vh', vh);
} }
export function doesObjectSupportFunction(object, functionName) { export function doesObjectSupportFunction(object, functionName) {

View File

@@ -1,31 +1,17 @@
export function messageBubbleColorForString(str) { export function messageBubbleColorForHue(hue) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
// eslint-disable-next-line
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Tweak these to adjust the result of the color // Tweak these to adjust the result of the color
const saturation = 25; const saturation = 45;
const lightness = 45; const lightness = 50;
const alpha = 'var(--message-background-alpha)'; const alpha = 'var(--message-background-alpha)';
const hue = parseInt(Math.abs(hash), 16) % 360;
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
} }
export function textColorForString(str) { export function textColorForHue(hue) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
// eslint-disable-next-line
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Tweak these to adjust the result of the color // Tweak these to adjust the result of the color
const saturation = 80; const saturation = 80;
const lightness = 80; const lightness = 80;
const alpha = 0.8; const alpha = 0.8;
const hue = parseInt(Math.abs(hash), 16) % 360;
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`; return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`;
} }

View File

@@ -11,23 +11,25 @@ export const SOCKET_MESSAGE_TYPES = {
PONG: 'PONG', PONG: 'PONG',
SYSTEM: 'SYSTEM', SYSTEM: 'SYSTEM',
USER_JOINED: 'USER_JOINED', USER_JOINED: 'USER_JOINED',
CHAT_ACTION: 'CHAT_ACTION' CHAT_ACTION: 'CHAT_ACTION',
CONNECTED_USER_INFO: 'CONNECTED_USER_INFO',
ERROR_USER_DISABLED: 'ERROR_USER_DISABLED',
ERROR_NEEDS_REGISTRATION: 'ERROR_NEEDS_REGISTRATION',
ERROR_MAX_CONNECTIONS_EXCEEDED: 'ERROR_MAX_CONNECTIONS_EXCEEDED',
}; };
const IGNORE_CLIENT_FLAG = 'IGNORE_CLIENT';
export const CALLBACKS = { export const CALLBACKS = {
RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived', RAW_WEBSOCKET_MESSAGE_RECEIVED: 'rawWebsocketMessageReceived',
WEBSOCKET_CONNECTED: 'websocketConnected', WEBSOCKET_CONNECTED: 'websocketConnected',
WEBSOCKET_DISCONNECTED: 'websocketDisconnected',
} }
const TIMER_WEBSOCKET_RECONNECT = 5000; // ms const TIMER_WEBSOCKET_RECONNECT = 5000; // ms
export default class Websocket { export default class Websocket {
constructor(ignoreClient) { constructor(accessToken) {
this.websocket = null; this.websocket = null;
this.websocketReconnectTimer = null; this.websocketReconnectTimer = null;
this.accessToken = accessToken;
this.websocketConnectedListeners = []; this.websocketConnectedListeners = [];
this.websocketDisconnectListeners = []; this.websocketDisconnectListeners = [];
@@ -36,15 +38,18 @@ export default class Websocket {
this.send = this.send.bind(this); this.send = this.send.bind(this);
this.createAndConnect = this.createAndConnect.bind(this); this.createAndConnect = this.createAndConnect.bind(this);
this.scheduleReconnect = this.scheduleReconnect.bind(this); this.scheduleReconnect = this.scheduleReconnect.bind(this);
this.shutdown = this.shutdown.bind(this);
this.ignoreClient = ignoreClient; this.isShutdown = false;
this.createAndConnect(); this.createAndConnect();
} }
createAndConnect() { createAndConnect() {
const extraFlags = this.ignoreClient ? [IGNORE_CLIENT_FLAG] : []; const url = new URL(URL_WEBSOCKET);
const ws = new WebSocket(URL_WEBSOCKET, extraFlags); url.searchParams.append('accessToken', this.accessToken);
const ws = new WebSocket(url.toString());
ws.onopen = this.onOpen.bind(this); ws.onopen = this.onOpen.bind(this);
ws.onclose = this.onClose.bind(this); ws.onclose = this.onClose.bind(this);
ws.onerror = this.onError.bind(this); ws.onerror = this.onError.bind(this);
@@ -79,6 +84,11 @@ export default class Websocket {
this.websocket.send(messageJSON); this.websocket.send(messageJSON);
} }
shutdown() {
this.isShutdown = true;
this.websocket.close();
}
// Private methods // Private methods
// Fire the callbacks of the listeners. // Fire the callbacks of the listeners.
@@ -116,14 +126,18 @@ export default class Websocket {
this.websocket = null; this.websocket = null;
this.notifyWebsocketDisconnectedListeners(); this.notifyWebsocketDisconnectedListeners();
this.handleNetworkingError('Websocket closed.'); this.handleNetworkingError('Websocket closed.');
this.scheduleReconnect(); if (!this.isShutdown) {
this.scheduleReconnect();
}
} }
// On ws error just close the socket and let it re-connect again for now. // On ws error just close the socket and let it re-connect again for now.
onError(e) { onError(e) {
this.handleNetworkingError(`Socket error: ${JSON.parse(e)}`); this.handleNetworkingError(`Socket error: ${JSON.parse(e)}`);
this.websocket.close(); this.websocket.close();
this.scheduleReconnect(); if (!this.isShutdown) {
this.scheduleReconnect();
}
} }
scheduleReconnect() { scheduleReconnect() {
@@ -142,9 +156,15 @@ export default class Websocket {
try { try {
var model = JSON.parse(e.data); var model = JSON.parse(e.data);
} catch (e) { } catch (e) {
console.log(e); // console.log(e, e.data);
return;
} }
if (!model.type) {
console.error("No type provided", model);
return;
}
// Send PONGs // Send PONGs
if (model.type === SOCKET_MESSAGE_TYPES.PING) { if (model.type === SOCKET_MESSAGE_TYPES.PING) {
this.sendPong(); this.sendPong();

View File

@@ -137,12 +137,16 @@ header {
} }
/* *********** overrides when chat is off ***************************** */ /* *********** overrides when chat is off ***************************** */
.chat-disabled #user-options-container {
display: none;
}
.no-chat footer { .chat-disabled footer,
.chat-hidden footer {
justify-content: center; justify-content: center;
} }
.no-chat #chat-toggle { .chat-hidden #chat-toggle {
opacity: .75; opacity: .75;
} }
@@ -200,7 +204,8 @@ header {
} }
.single-col.chat #video-container, .single-col.chat #video-container,
.single-col.no-chat #video-container, .single-col.chat-disabled #video-container,
.single-col.chat-hidden #video-container,
.single-col #video-container #video, .single-col #video-container #video,
.single-col.chat #video-container #video { .single-col.chat #video-container #video {
width: 100vw; width: 100vw;