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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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..."
git clone https://github.com/owncast/owncast-admin 2> /dev/null
cd owncast-admin
git checkout gek/chat-user-refactor
echo "Installing npm modules for the owncast admin..."
npm --silent install 2> /dev/null

View file

@ -42,6 +42,13 @@ func GetCommit() string {
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.
func GetReleaseString() string {
var versionNumber = VersionNumber

View file

@ -9,16 +9,24 @@ import (
"net/http"
"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/models"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/user"
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.
func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
type messageVisibilityUpdateRequest struct {
IDArray []string `json:"idArray"`
Visible bool `json:"visible"`
}
if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
return
@ -27,8 +35,7 @@ func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var request messageVisibilityUpdateRequest
err := decoder.Decode(&request)
if err != nil {
if err := decoder.Decode(&request); err != nil {
log.Errorln(err)
controllers.WriteSimpleResponse(w, false, "")
return
@ -42,103 +49,142 @@ func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "changed")
}
type messageVisibilityUpdateRequest struct {
IDArray []string `json:"idArray"`
Visible bool `json:"visible"`
func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) {
type blockUserRequest struct {
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.
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
messages := core.GetModerationChatMessages()
if err := json.NewEncoder(w).Encode(messages); err != nil {
log.Errorln(err)
}
messages := chat.GetChatModerationHistory()
controllers.WriteResponse(w, messages)
}
// 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")
var message models.ChatEvent
var message events.SystemMessageEvent
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
controllers.InternalErrorHandler(w, err)
return
}
message.MessageType = models.SystemMessageSent
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 {
if err := chat.SendSystemMessage(message.Body, false); err != nil {
controllers.BadRequestHandler(w, err)
return
}
controllers.WriteSimpleResponse(w, true, "sent")
}
// SendUserMessage will send a message to chat on behalf of a user.
func SendUserMessage(w http.ResponseWriter, r *http.Request) {
// SendUserMessage will send a message to chat on behalf of a user. *Depreciated*.
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")
var message models.ChatEvent
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
name := integration.DisplayName
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)
return
}
event.SetDefaults()
event.RenderBody()
event.Type = "CHAT"
if !message.Valid() {
controllers.BadRequestHandler(w, errors.New("invalid chat message; id, author, and body are required"))
if event.Empty() {
controllers.BadRequestHandler(w, errors.New("invalid message"))
return
}
message.MessageType = models.MessageSent
message.ClientID = "external-request"
message.ID = shortid.MustGenerate()
message.Visible = true
event.User = &user.User{
Id: integration.Id,
DisplayName: name,
DisplayColor: integration.DisplayColor,
CreatedAt: integration.CreatedAt,
}
message.SetDefaults()
message.RenderAndSanitizeMessageBody()
if err := core.SendMessageToChat(message); err != nil {
if err := chat.Broadcast(&event); err != nil {
controllers.BadRequestHandler(w, err)
return
}
chat.SaveUserMessage(event)
controllers.WriteSimpleResponse(w, true, "sent")
}
// 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")
var message models.ChatEvent
var message events.SystemActionEvent
if err := json.NewDecoder(r.Body).Decode(&message); err != nil {
controllers.InternalErrorHandler(w, err)
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.RenderAndSanitizeMessageBody()
message.RenderBody()
if err := core.SendMessageToChat(message); err != nil {
if err := chat.SendSystemAction(message.Body, false); err != nil {
controllers.BadRequestHandler(w, err)
return
}

View file

@ -12,8 +12,9 @@ import (
"strings"
"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/user"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
@ -71,17 +72,12 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) {
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) {
message := models.ChatEvent{}
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 {
if err := chat.SendSystemAction(messageText, ephemeral); err != nil {
log.Errorln(err)
}
}
@ -576,17 +572,24 @@ func SetCustomStyles(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "custom styles updated")
}
// SetUsernameBlocklist will set the list of usernames we do not allow to use.
func SetUsernameBlocklist(w http.ResponseWriter, r *http.Request) {
usernames, success := getValueFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to update chat username blocklist")
// SetForbiddenUsernameList will set the list of usernames we do not allow to use.
func SetForbiddenUsernameList(w http.ResponseWriter, r *http.Request) {
type forbiddenUsernameListRequest struct {
Value []string `json:"value"`
}
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
}
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 {

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

View file

@ -15,6 +15,7 @@ import (
// GetServerConfig gets the config details of the server.
func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ffmpeg := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
usernameBlocklist := data.GetForbiddenUsernameList()
var videoQualityVariants = make([]models.StreamOutputVariant, 0)
for _, variant := range data.GetStreamOutputVariants() {
@ -61,7 +62,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ExternalActions: data.GetExternalActions(),
SupportedCodecs: transcoder.GetCodecs(ffmpeg),
VideoCodec: data.GetVideoCodec(),
UsernameBlocklist: data.GetUsernameBlocklist(),
ForbiddenUsernames: usernameBlocklist,
}
w.Header().Set("Content-Type", "application/json")
@ -84,7 +85,7 @@ type serverConfigAdminResponse struct {
ExternalActions []models.ExternalAction `json:"externalActions"`
SupportedCodecs []string `json:"supportedCodecs"`
VideoCodec string `json:"videoCodec"`
UsernameBlocklist string `json:"usernameBlocklist"`
ForbiddenUsernames []string `json:"forbiddenUsernames"`
}
type videoSettings struct {

View file

@ -4,11 +4,17 @@ import (
"encoding/json"
"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"
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.
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(&w)
@ -16,7 +22,7 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
messages := core.GetAllChatMessages()
messages := chat.GetChatHistory()
if err := json.NewEncoder(w).Encode(messages); err != nil {
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

@ -24,6 +24,7 @@ type webConfigResponse struct {
ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"`
CustomStyles string `json:"customStyles"`
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
}
// GetWebConfig gets the status of the server.
@ -57,6 +58,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
ChatDisabled: data.GetChatDisabled(),
ExternalActions: data.GetExternalActions(),
CustomStyles: data.GetCustomStyles(),
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
}
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 (
"errors"
"time"
"net/http"
"sort"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
// Setup sets up the chat server.
func Setup(listener models.ChatListener) {
var getStatus func() models.Status
func Start(getStatusFunc func() models.Status) error {
setupPersistence()
clients := make(map[string]*Client)
addCh := make(chan *Client)
delCh := make(chan *Client)
sendAllCh := make(chan models.ChatEvent)
pingCh := make(chan models.PingMessage)
doneCh := make(chan bool)
errCh := make(chan error)
getStatus = getStatusFunc
_server = NewChat()
_server = &server{
clients,
"/entry", //hardcoded due to the UI requiring this and it is not configurable
listener,
addCh,
delCh,
sendAllCh,
pingCh,
doneCh,
errCh,
}
}
go _server.Run()
// Start starts the chat server.
func Start() error {
if _server == nil {
return errors.New("chat server is nil")
}
log.Traceln("Chat server started with max connection count of", _server.maxClientCount)
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
}
// 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 (
"bytes"
@ -12,38 +12,59 @@ import (
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
"mvdan.cc/xurls"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus"
)
// ChatEvent represents a single chat message.
type ChatEvent struct {
ClientID string `json:"-"`
// EventPayload is a generic key/value map for sending out to chat clients.
type EventPayload map[string]interface{}
Author string `json:"author,omitempty"`
Body string `json:"body,omitempty"`
type OutboundEvent interface {
GetBroadcastPayload() EventPayload
GetMessageType() EventType
}
// Event is any kind of event. A type is required to be specified.
type Event struct {
Type EventType `json:"type"`
Id string `json:"id"`
Timestamp time.Time `json:"timestamp"`
}
type UserEvent struct {
User *user.User `json:"user"`
HiddenAt *time.Time `json:"hiddenAt,omitempty"`
}
// MessageEvent is an event that has a message body.
type MessageEvent struct {
OutboundEvent `json:"-"`
Body string `json:"body"`
RawBody string `json:"-"`
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.
func (m ChatEvent) Valid() bool {
return m.Author != "" && m.Body != "" && m.ID != ""
type SystemActionEvent struct {
Event
MessageEvent
}
// SetDefaults will set default values on a chat event object.
func (m *ChatEvent) SetDefaults() {
id, _ := shortid.Generate()
m.ID = id
m.Timestamp = time.Now()
m.Visible = true
// 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
// the message into something safe and renderable for clients.
func (m *ChatEvent) RenderAndSanitizeMessageBody() {
func (m *MessageEvent) RenderAndSanitizeMessageBody() {
m.RawBody = m.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.
func (m *ChatEvent) Empty() bool {
func (m *MessageEvent) Empty() bool {
return m.Body == ""
}
// RenderBody will render markdown to html without any sanitization
func (m *ChatEvent) RenderBody() {
// RenderBody will render markdown to html without any sanitization.
func (m *MessageEvent) RenderBody() {
m.RawBody = m.Body
m.Body = RenderMarkdown(m.RawBody)
}
@ -92,7 +113,7 @@ func RenderMarkdown(raw string) string {
trimmed := strings.TrimSpace(raw)
var buf bytes.Buffer
if err := markdown.Convert([]byte(trimmed), &buf); err != nil {
panic(err)
log.Debugln(err)
}
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 (
"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
@ -25,7 +25,7 @@ blah blah blah
<p><a href="http://owncast.online" rel="nofollow noreferrer noopener" target="_blank">test link</a>
<img class="emoji" src="/img/emoji/bananadance.gif"></p>`
result := models.RenderAndSanitize(messageContent)
result := events.RenderAndSanitize(messageContent)
if 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) {
messageContent := `<img src="https://via.placeholder.com/350x150"> test ![](https://via.placeholder.com/350x150)`
expected := `<p> test </p>`
result := models.RenderAndSanitize(messageContent)
result := events.RenderAndSanitize(messageContent)
if 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) {
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>`
result := models.RenderAndSanitize(messageContent)
result := events.RenderAndSanitize(messageContent)
if 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) {
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"
result := models.RenderMarkdown(messageContent)
result := events.RenderMarkdown(messageContent)
if 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
import (
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
@ -22,8 +22,11 @@ func SetMessagesVisibility(messageIDs []string, visibility bool) error {
log.Errorln(err)
continue
}
message.MessageType = models.VisibiltyToggled
_server.sendAll(message)
payload := message.GetBroadcastPayload()
payload["type"] = events.VisibiltyToggled
if err := _server.Broadcast(payload); err != nil {
log.Debugln(err)
}
go webhooks.SendChatEvent(message)
}

View file

@ -1,171 +1,309 @@
package chat
import (
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
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() {
_db = data.GetDatabase()
createTable()
_datastore = data.GetDatastore()
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 (
"id" string NOT NULL PRIMARY KEY,
"author" TEXT,
"user_id" INTEGER,
"body" TEXT,
"messageType" TEXT,
"visible" INTEGER,
"timestamp" DATE
"eventType" TEXT,
"hidden_at" DATETIME,
"timestamp" DATETIME
);`
stmt, err := _db.Prepare(createTableSQL)
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
log.Fatal("error creating chat messages table", err)
}
defer stmt.Close()
if _, err := stmt.Exec(); err != nil {
log.Warnln(err)
log.Fatal("error creating chat messages table", err)
}
}
func addMessage(message models.ChatEvent) {
tx, err := _db.Begin()
if err != nil {
log.Fatal(err)
func SaveUserMessage(event events.UserMessageEvent) {
saveEvent(event.Id, event.User.Id, event.Body, event.Type, event.HiddenAt, event.Timestamp)
}
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 {
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()
if _, err := stmt.Exec(message.ID, message.Author, message.Body, message.MessageType, 1, message.Timestamp); err != nil {
log.Fatal(err)
if _, err = stmt.Exec(id, userId, body, eventType, hidden, timestamp); err != nil {
log.Errorln("error saving", eventType, err)
return
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
if err = tx.Commit(); err != nil {
log.Errorln("error saving", eventType, err)
return
}
}
func getChat(query string) []models.ChatEvent {
history := make([]models.ChatEvent, 0)
rows, err := _db.Query(query)
func getChat(query string) []events.UserMessageEvent {
history := make([]events.UserMessageEvent, 0)
rows, err := _datastore.DB.Query(query)
if err != nil {
log.Fatal(err)
log.Errorln("error fetching chat history", err)
return history
}
defer rows.Close()
for rows.Next() {
var id string
var author string
var userId string
var body string
var messageType models.EventType
var visible int
var hiddenAt *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 {
log.Debugln(err)
log.Error("There is a problem with the chat database. Restore a backup of owncast.db or remove it and start over.")
log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
break
}
message := models.ChatEvent{}
message.ID = id
message.Author = author
message.Body = body
message.MessageType = messageType
message.Visible = visible == 1
message.Timestamp = timestamp
history = append(history, message)
// System messages and chat actions are special and are not from real users
if messageType == events.SystemMessageSent || messageType == events.ChatActionSent {
name := "Owncast"
userDisplayName = &name
color := 200
userDisplayColor = &color
}
if err := rows.Err(); err != nil {
log.Fatal(err)
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)
}
return history
}
func getChatModerationHistory() []models.ChatEvent {
var query = "SELECT * FROM messages WHERE messageType == 'CHAT' AND datetime(timestamp) >=datetime('now', '-5 Hour')"
func GetChatModerationHistory() []events.UserMessageEvent {
// 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)
}
func getChatHistory() []models.ChatEvent {
// Get all messages sent within the past 5hrs, max 50
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"
func GetChatHistory() []events.UserMessageEvent {
// Get all visible messages
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)
}
// 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 {
tx, err := _db.Begin()
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
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 {
log.Fatal(err)
return err
}
defer stmt.Close()
var hiddenAt *time.Time
if !visible {
now := time.Now()
hiddenAt = &now
} else {
hiddenAt = nil
}
args := make([]interface{}, len(messageIDs)+1)
args[0] = visible
args[0] = hiddenAt
for i, id := range messageIDs {
args[i+1] = id
}
if _, err := stmt.Exec(args...); err != nil {
log.Fatal(err)
if _, err = stmt.Exec(args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
if err = tx.Commit(); err != nil {
return err
}
return nil
}
func getMessageById(messageID string) (models.ChatEvent, error) {
func getMessageById(messageID string) (*events.UserMessageEvent, error) {
var query = "SELECT * FROM messages WHERE id = ?"
row := _db.QueryRow(query, messageID)
row := _datastore.DB.QueryRow(query, messageID)
var id string
var author string
var userId string
var body string
var messageType models.EventType
var visible int
var eventType models.EventType
var hiddenAt *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 {
log.Errorln(err)
return models.ChatEvent{}, err
return nil, err
}
return models.ChatEvent{
ID: id,
Author: author,
Body: body,
MessageType: messageType,
Visible: visible == 1,
user := user.GetUserById(userId)
return &events.UserMessageEvent{
events.Event{
Type: eventType,
Id: id,
Timestamp: timestamp,
},
events.UserEvent{
User: user,
HiddenAt: hiddenAt,
},
events.MessageEvent{
Body: body,
},
}, 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
import (
"fmt"
"encoding/json"
"net/http"
"sync"
"time"
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/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
)
var (
_server *server
)
var _server *ChatServer
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.
type server struct {
Clients map[string]*Client
// send outbound message payload to all clients
outbound chan []byte
pattern string
listener models.ChatListener
// receive inbound message payload from all clients
inbound chan chatClientEvent
addCh chan *Client
delCh chan *Client
sendAllCh chan models.ChatEvent
pingCh chan models.PingMessage
doneCh chan bool
errCh chan error
// unregister requests from clients.
unregister chan *ChatClient
}
// Add adds a client to the server.
func (s *server) add(c *Client) {
s.addCh <- c
func NewChat() *ChatServer {
server := &ChatServer{
clients: map[uint]*ChatClient{},
outbound: make(chan []byte),
inbound: make(chan chatClientEvent),
unregister: make(chan *ChatClient),
maxClientCount: handleMaxConnectionCount(),
}
// Remove removes a client from the server.
func (s *server) remove(c *Client) {
s.delCh <- c
return server
}
// 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()
}
func (s *server) ping() {
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)
func (s *ChatServer) Run() {
for {
select {
// add new a client
case c := <-s.addCh:
l.Lock()
s.Clients[c.socketID] = c
if !c.Ignore {
s.listener.ClientAdded(c.GetViewerClientFromChatClient())
s.sendWelcomeMessageToClient(c)
case client := <-s.unregister:
if _, ok := s.clients[client.id]; ok {
s.mu.Lock()
delete(s.clients, client.id)
close(client.send)
s.mu.Unlock()
}
l.Unlock()
// remove a client
case c := <-s.delCh:
s.removeClient(c)
case msg := <-s.sendAllCh:
case message := <-s.inbound:
s.eventReceived(message)
}
}
}
// Addclient registers new connection as a User.
func (s *ChatServer) Addclient(conn *websocket.Conn, user *user.User, accessToken string, userAgent string) *ChatClient {
client := &ChatClient{
server: s,
conn: conn,
User: user,
ipAddress: conn.RemoteAddr().String(),
accessToken: accessToken,
send: make(chan []byte, 256),
UserAgent: userAgent,
ConnectedAt: time.Now(),
}
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 *ChatServer) sendUserJoinedMessage(c *ChatClient) {
userJoinedEvent := events.UserJoinedEvent{}
userJoinedEvent.SetDefaults()
userJoinedEvent.User = c.User
if err := s.Broadcast(userJoinedEvent.GetBroadcastPayload()); err != nil {
log.Errorln("error adding client to chat server", err)
}
// 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() {
break
}
if !msg.Empty() {
// set defaults before sending msg to anywhere
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:
_, _ = 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)
}
}
func (s *server) removeClient(c *Client) {
l.Lock()
if _, ok := s.Clients[c.socketID]; ok {
delete(s.Clients, c.socketID)
s.listener.ClientRemoved(c.socketID)
log.Tracef("The client was connected for %s and sent %d messages (%s)", time.Since(c.ConnectedAt), c.MessageCount, c.ClientID)
}
l.Unlock()
return nil
}
func (s *server) sendWelcomeMessageToClient(c *Client) {
go func() {
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 := data.GetServerWelcomeMessage()
welcomeMessage := utils.RenderSimpleMarkdown(data.GetServerWelcomeMessage())
if welcomeMessage != "" {
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)
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/rtmp"
"github.com/owncast/owncast/core/transcoder"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/owncast/owncast/yp"
@ -53,6 +54,8 @@ func Start() error {
log.Errorln("storage error", err)
}
user.SetupUsers()
fileWriter.SetupFileWriterReceiverService(&handler)
if err := createInitialOfflineState(); err != nil {
@ -62,7 +65,9 @@ func Start() error {
_yp = yp.NewYP(GetStatus)
chat.Setup(ChatListenerImpl{})
if err := chat.Start(GetStatus); err != nil {
log.Errorln(err)
}
// start the rtmp server
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 {
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
@ -577,19 +579,25 @@ func FindHighestVideoQualityIndex(qualities []models.StreamOutputVariant) int {
return indexedQualities[0].index
}
// GetUsernameBlocklist will return the blocked usernames as a comma separated string.
func GetUsernameBlocklist() string {
// GetForbiddenUsernameList will return the blocked usernames as a comma separated string.
func GetForbiddenUsernameList() []string {
usernameString, err := _datastore.GetString(blockedUsernamesKey)
if err != nil {
log.Traceln(blockedUsernamesKey, err)
return ""
return config.DefaultForbiddenUsernames
}
return usernameString
if usernameString == "" {
return config.DefaultForbiddenUsernames
}
// SetUsernameBlocklist set the username blocklist as a comma separated string.
func SetUsernameBlocklist(usernames string) error {
return _datastore.SetString(blockedUsernamesKey, usernames)
blocklist := strings.Split(usernameString, ",")
return blocklist
}
// SetForbiddenUsernameList set the username blocklist as a comma separated string.
func SetForbiddenUsernameList(usernames []string) error {
usernameListString := strings.Join(usernames, ",")
return _datastore.SetString(blockedUsernamesKey, usernameListString)
}

View file

@ -17,7 +17,7 @@ import (
)
const (
schemaVersion = 0
schemaVersion = 1
)
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 {
return err
}
@ -86,11 +92,6 @@ func SetupPersistence(file string) error {
}
}
_db = db
createWebhooksTable()
createAccessTokensTable()
_datastore = &Datastore{}
_datastore.Setup()
@ -106,13 +107,14 @@ func SetupPersistence(file string) 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))
utils.Backup(db, dbBackupFile)
for v := from; v < to; v++ {
switch v {
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:
panic("missing database migration step")
}

View file

@ -2,13 +2,18 @@ package data
import (
"fmt"
"io/ioutil"
"os"
"testing"
)
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()
}

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"
"database/sql"
"encoding/gob"
"sync"
// sqlite requires a blank import.
_ "github.com/mattn/go-sqlite3"
@ -12,14 +13,15 @@ import (
// Datastore is the global key/value store for configuration values.
type Datastore struct {
db *sql.DB
DB *sql.DB
cache map[string][]byte
DbLock *sync.Mutex
}
func (ds *Datastore) warmCache() {
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 {
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 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 {
return ConfigEntry{}, err
}
@ -63,36 +65,26 @@ func (ds *Datastore) Get(key string) (ConfigEntry, error) {
// Save will save the ConfigEntry to the database.
func (ds *Datastore) Save(e ConfigEntry) error {
ds.DbLock.Lock()
defer ds.DbLock.Unlock()
var dataGob bytes.Buffer
enc := gob.NewEncoder(&dataGob)
if err := enc.Encode(e.Value); err != nil {
return err
}
tx, err := ds.db.Begin()
tx, err := ds.DB.Begin()
if err != nil {
return err
}
var stmt *sql.Stmt
var count int
row := ds.db.QueryRow("SELECT COUNT(*) FROM datastore WHERE key = ? LIMIT 1", e.Key)
if err := row.Scan(&count); err != nil {
return err
}
if count == 0 {
stmt, err = tx.Prepare("INSERT INTO datastore(key, value) values(?, ?)")
stmt, err = tx.Prepare("INSERT INTO datastore (key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
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 {
return err
}
@ -110,7 +102,8 @@ func (ds *Datastore) Save(e ConfigEntry) error {
// Setup will create the datastore table and perform initial initialization.
func (ds *Datastore) Setup() {
ds.cache = make(map[string][]byte)
ds.db = GetDatabase()
ds.DB = GetDatabase()
ds.DbLock = &sync.Mutex{}
createTableSQL := `CREATE TABLE IF NOT EXISTS datastore (
"key" string NOT NULL PRIMARY KEY,
@ -118,7 +111,7 @@ func (ds *Datastore) Setup() {
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
);`
stmt, err := ds.db.Prepare(createTableSQL)
stmt, err := ds.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
@ -137,7 +130,7 @@ func (ds *Datastore) Setup() {
// Reset will delete all config entries in the datastore and start over.
func (ds *Datastore) Reset() {
sql := "DELETE FROM datastore"
stmt, err := ds.db.Prepare(sql)
stmt, err := ds.DB.Prepare(sql)
if err != nil {
log.Fatalln(err)
}
@ -150,3 +143,7 @@ func (ds *Datastore) Reset() {
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.
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, ",")
@ -67,7 +67,7 @@ func InsertWebhook(url string, events []models.EventType) (int, error) {
// DeleteWebhook will delete a webhook from the database.
func DeleteWebhook(id int) error {
log.Println("Deleting webhook:", id)
log.Traceln("Deleting webhook:", id)
tx, err := _db.Begin()
if err != nil {
@ -86,7 +86,7 @@ func DeleteWebhook(id int) error {
}
if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 {
tx.Rollback() //nolint
_ = tx.Rollback()
return errors.New(fmt.Sprint(id) + " not found")
}

View file

@ -7,7 +7,6 @@ import (
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/geoip"
"github.com/owncast/owncast/models"
@ -86,22 +85,6 @@ func RemoveChatClient(clientID string) {
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.
func SetViewerIdActive(id string) {
l.Lock()

View file

@ -11,6 +11,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/rtmp"
"github.com/owncast/owncast/core/transcoder"
@ -72,10 +73,15 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) {
go webhooks.SendStreamStatusEvent(models.StreamStarted)
transcoder.StartThumbnailGenerator(segmentPath, data.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings))
_ = chat.SendSystemAction("Stay tuned, the stream is starting!", true)
chat.SendAllWelcomeMessage()
}
// SetStreamAsDisconnected sets the stream as disconnected.
func SetStreamAsDisconnected() {
_ = chat.SendSystemAction("The stream is ending.", true)
_stats.StreamConnected = false
_stats.LastDisconnectTime = utils.NullTime{Time: time.Now(), Valid: true}
_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
import (
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models"
)
func SendChatEvent(chatEvent models.ChatEvent) {
func SendChatEvent(chatEvent *events.UserMessageEvent) {
webhookEvent := WebhookEvent{
Type: chatEvent.MessageType,
Type: chatEvent.GetMessageType(),
EventData: &WebhookChatMessage{
Author: chatEvent.Author,
User: chatEvent.User,
Body: chatEvent.Body,
RawBody: chatEvent.RawBody,
ID: chatEvent.ID,
Visible: chatEvent.Visible,
ID: chatEvent.Id,
Visible: chatEvent.HiddenAt == nil,
Timestamp: &chatEvent.Timestamp,
},
}
@ -20,7 +21,7 @@ func SendChatEvent(chatEvent models.ChatEvent) {
SendEventToWebhooks(webhookEvent)
}
func SendChatEventUsernameChanged(event models.NameChangeEvent) {
func SendChatEventUsernameChanged(event events.NameChangeEvent) {
webhookEvent := WebhookEvent{
Type: models.UserNameChanged,
EventData: event,
@ -29,7 +30,7 @@ func SendChatEventUsernameChanged(event models.NameChangeEvent) {
SendEventToWebhooks(webhookEvent)
}
func SendChatEventUserJoined(event models.UserJoinedEvent) {
func SendChatEventUserJoined(event events.UserJoinedEvent) {
webhookEvent := WebhookEvent{
Type: models.UserNameChanged,
EventData: event,

View file

@ -8,6 +8,8 @@ import (
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
)
@ -18,7 +20,7 @@ type WebhookEvent struct {
}
type WebhookChatMessage struct {
Author string `json:"author,omitempty"`
User *user.User `json:"user,omitempty"`
Body string `json:"body,omitempty"`
RawBody string `json:"rawBody,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/aws/aws-sdk-go v1.40.0
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/jonboulle/clockwork v0.2.2 // indirect
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/yuin/goldmark v1.4.0
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
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // 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/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.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/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
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-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-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-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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=

View file

@ -38,8 +38,6 @@ func main() {
config.LogDirectory = *logDirectory
}
log.Infoln(config.GetReleaseString())
if *backupDirectory != "" {
config.BackupDirectory = *backupDirectory
}
@ -52,6 +50,7 @@ func main() {
}
configureLogging(*enableDebugOptions, *enableVerboseLogging)
log.Infoln(config.GetReleaseString())
// Allows a user to restore a specific database backup
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
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.
type Webhook struct {
@ -25,7 +29,7 @@ var validEvents = []EventType{
// This is not a efficient method.
func HasValidEvents(events []EventType) bool {
for _, event := range events {
if !findItemInSlice(validEvents, event) {
if _, foundInSlice := utils.FindInSlice(validEvents, event); !foundInSlice {
return false
}
}

View file

@ -2,7 +2,7 @@ openapi: 3.0.1
info:
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.
version: '0.0.7'
version: '0.0.8-develop'
contact:
name: Gabe Kangas
email: gabek@real-ity.com
@ -27,6 +27,11 @@ components:
items:
$ref: "#/components/schemas/Client"
UserArray:
type: array
items:
$ref: "#/components/schemas/User"
LogEntryArray:
type: array
items:
@ -58,6 +63,7 @@ components:
userAgent:
description: The web client used to connect to this server
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:
description: The public IP address of this client
type: string
@ -77,6 +83,8 @@ components:
type: string
timeZone:
type: string
user:
$ref: "#/components/schemas/User"
x-last-modified: 1602052347511
BasicResponse:
@ -242,6 +250,30 @@ components:
format: date-time
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:
AdminBasicAuth:
type: http
@ -251,8 +283,20 @@ components:
type: http
scheme: bearer
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:
UsersResponse:
description: A collection of users.
content:
application/json:
schema:
$ref: "#/components/schemas/UserArray"
ClientsResponse:
description: Successful response of an array of clients
content:
@ -266,12 +310,16 @@ components:
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
ipAddress: "172.217.164.110"
username: coolperson42
clientID: 2ba20dd34f911c198df3218ddc64c740
geo:
countryCode: US
regionName: California
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:
description: Response of server log entries
@ -337,6 +385,15 @@ paths:
schema:
$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:
get:
summary: Current Status
@ -376,11 +433,48 @@ paths:
sessionMaxViewerCount: 12
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:
get:
summary: Historical Chat Messages
description: Used to get all chat messages prior to connecting to the websocket.
summary: Chat Messages Backlog
description: Used to get chat messages prior to connecting to the websocket.
tags: ["Chat"]
security:
- UserToken: []
responses:
"200":
description: ""
@ -586,6 +680,17 @@ paths:
"200":
$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:
get:
summary: Return recent log entries
@ -708,6 +813,32 @@ paths:
"200":
$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:
post:
summary: Set the stream key.
@ -1170,10 +1301,10 @@ paths:
value: Streaming my favorite game, Desert Bus.
/api/integrations/chat/user:
/api/integrations/chat/send:
post:
summary: Send a user chat message.
description: Send a chat message on behalf of a user. Could be a bot name or a real user.
summary: Send a chat message.
description: Send a chat message on behalf of a 3rd party integration, bot or service.
tags: ["Integrations"]
security:
- AccessToken: []
@ -1184,9 +1315,6 @@ paths:
schema:
type: object
properties:
user:
type: string
description: The user you want to send this message as.
body:
type: string
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"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
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
// the stream key as the password and and a hardcoded "admin" for username.
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) {
authHeader := strings.Split(r.Header.Get("Authorization"), "Bearer ")
token := strings.Join(authHeader, "")
if len(authHeader) == 0 || token == "" {
log.Warnln("invalid access token")
w.WriteHeader(http.StatusUnauthorized) //nolint
w.Write([]byte("invalid access token")) //nolint
accessDenied(w)
return
}
if accepted, err := data.DoesTokenSupportScope(token, scope); err != nil {
w.WriteHeader(http.StatusInternalServerError) //nolint
w.Write([]byte(err.Error())) //nolint
integration, err := user.GetExternalAPIUserForAccessTokenAndScope(token, scope)
if integration == nil || err != nil {
accessDenied(w)
return
} else if !accepted {
log.Warnln("invalid access token")
w.WriteHeader(http.StatusUnauthorized) //nolint
w.Write([]byte("invalid access token")) //nolint
}
handler(*integration, w, r)
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
}
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/admin"
"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/yp"
)
@ -30,15 +30,8 @@ func Start() error {
// custom emoji supported in the chat
http.HandleFunc("/api/emoji", controllers.GetCustomEmoji)
// websocket chat server
go func() {
if err := chat.Start(); err != nil {
log.Fatalln(err)
}
}()
// chat rest api
http.HandleFunc("/api/chat", controllers.GetChatMessages)
http.HandleFunc("/api/chat", middleware.RequireUserAccessToken(controllers.GetChatMessages))
// web config api
http.HandleFunc("/api/config", controllers.GetWebConfig)
@ -64,6 +57,9 @@ func Start() error {
// tell the backend you're an active viewer
http.HandleFunc("/api/ping", controllers.Ping)
// register a new chat user
http.HandleFunc("/api/chat/register", controllers.RegisterAnonymousChatUser)
// Authenticated admin requests
// Current inbound broadcaster
@ -82,7 +78,7 @@ func Start() error {
http.HandleFunc("/api/admin/hardwarestats", middleware.RequireAdminAuth(admin.GetHardwareStats))
// 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
http.HandleFunc("/api/admin/logs", middleware.RequireAdminAuth(admin.GetLogs))
@ -95,6 +91,13 @@ func Start() error {
// Update chat message visibility
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
// 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))
// 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
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))
// 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
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
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
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
http.HandleFunc("/api/integrations/chat/user", middleware.RequireAccessToken(models.ScopeCanSendUserMessages, admin.SendUserMessage))
// Send a user message to chat *NO LONGER SUPPORTED
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
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
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
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
http.HandleFunc("/api/integrations/chat", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetChatMessages))
http.HandleFunc("/api/integrations/chat", middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, controllers.ExternalGetChatMessages))
// 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
http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogo))
@ -211,6 +217,11 @@ func Start() error {
// set custom style css
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
ip := config.WebServerIP

View file

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

View file

@ -2,38 +2,26 @@ const { test } = require('@jest/globals');
var request = require('supertest');
request = request('http://127.0.0.1:8080');
const WebSocket = require('ws');
var ws;
const registerChat = require('./lib/chat').registerChat;
const sendChatMessage = require('./lib/chat').sendChatMessage;
const testVisibilityMessage = {
author: "username",
body: "message " + Math.floor(Math.random() * 100),
type: 'CHAT',
visible: true,
timestamp: new Date().toISOString()
};
test('can send a chat message', (done) => {
ws = new WebSocket('ws://127.0.0.1:8080/entry', {
origin: 'http://localhost',
});
test('can send a chat message', async (done) => {
const registration = await registerChat();
const accessToken = registration.accessToken;
function onOpen() {
ws.send(JSON.stringify(testVisibilityMessage), function () {
ws.close();
done();
sendChatMessage(testVisibilityMessage, accessToken, done);
});
}
ws.on('open', onOpen);
});
var messageId;
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 message = res.body[0];
messageId = message.id;
const messageId = message.id;
await request.post('/api/admin/chat/updatemessagevisibility')
.auth('admin', 'abc123')
.send({ "idArray": [messageId], "visible": false }).expect(200);
@ -46,9 +34,9 @@ test('verify message has become hidden', async (done) => {
.auth('admin', 'abc123')
const message = res.body.filter(obj => {
return obj.id === messageId;
return obj.body === `<p>${testVisibilityMessage.body}</p>`;
});
expect(message.length).toBe(1);
expect(message[0].visible).toBe(false);
expect(message[0].hiddenAt).toBeTruthy();
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(),
};
const forbiddenUsernames = [randomString(), randomString(), randomString()];
test('set server name', async (done) => {
const res = await sendConfigChangeRequest('name', serverName);
done();
@ -81,6 +83,11 @@ test('set s3 configuration', async (done) => {
done();
});
test('set forbidden usernames', async (done) => {
const res = await sendConfigChangeRequest('chat/forbiddenusernames', forbiddenUsernames);
done();
});
test('verify updated config values', async (done) => {
const res = await request.get('/api/config');
expect(res.body.name).toBe(serverName);
@ -122,6 +129,7 @@ test('admin configuration is correct', (done) => {
expect(res.body.instanceDetails.socialHandles).toStrictEqual(
socialHandles
);
expect(res.body.forbiddenUsernames).toStrictEqual(forbiddenUsernames);
expect(res.body.videoSettings.latencyLevel).toBe(latencyLevel);
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) => {
const name = 'test token';
const scopes = ['CAN_SEND_SYSTEM_MESSAGES'];
const name = 'Automated integration test';
const scopes = ['CAN_SEND_SYSTEM_MESSAGES', 'CAN_SEND_MESSAGES'];
const res = await sendIntegrationsChangePayload('accesstokens/create', {
name: name,
scopes: scopes,
});
expect(res.body.token).toBeTruthy();
expect(res.body.timestamp).toBeTruthy();
expect(res.body.name).toBe(name);
expect(res.body.accessToken).toBeTruthy();
expect(res.body.createdAt).toBeTruthy();
expect(res.body.displayName).toBe(name);
expect(res.body.scopes).toStrictEqual(scopes);
accessToken = res.body.token;
accessToken = res.body.accessToken;
done();
});
test('check access tokens', (done) => {
request.get('/api/admin/accesstokens')
test('check access tokens', async (done) => {
const res = await request.get('/api/admin/accesstokens')
.auth('admin', 'abc123').expect(200)
.then((res) => {
expect(res.body).toHaveLength(1);
expect(res.body[0].token).toBe(accessToken);
const tokenCheck = res.body.filter((token) => token.accessToken === accessToken)
expect(tokenCheck).toHaveLength(1);
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')
.set('Authorization', 'Bearer ' + accessToken)
.send(payload).expect(200);
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) => {
const res = await sendIntegrationsChangePayload('accesstokens/delete', {
token: accessToken,
@ -90,14 +104,13 @@ test('delete access token', async (done) => {
done();
});
test('check token delete was successful', (done) => {
request.get('/api/admin/accesstokens')
test('check token delete was successful', async (done) => {
const res = await request.get('/api/admin/accesstokens')
.auth('admin', 'abc123').expect(200)
.then((res) => {
expect(res.body).toHaveLength(0);
const tokenCheck = res.body.filter((token) => token.accessToken === accessToken)
expect(tokenCheck).toHaveLength(0);
done();
});
});
async function sendIntegrationsChangePayload(endpoint, payload) {
const url = '/api/admin/' + endpoint;

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 = [
'User ' + Math.floor(Math.random() * 100),
'User ' + Math.floor(Math.random() * 100),
'User ' + Math.floor(Math.random() * 100),
'User ' + Math.floor(Math.random() * 100),
];
const WebSocket = require('ws');
const fetch = require('node-fetch');
const messages = [
'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.',
'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();
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:8080/entry', {
origin: 'http://watch.owncast.online',
});
async function registerChat() {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}
ws.on('open', function open() {
setTimeout(sendMessage, 15000);
});
try {
const response = await fetch('http://localhost:8080/api/chat/register', options);
const result = await response.json();
return result;
} catch(e) {
console.error(e);
}
}
ws.on('error', function incoming(data) {
console.log(data);
});
async function sendMessage() {
const registration = await registerChat();
const accessToken = registration.accessToken;
function sendMessage() {
function send() {
if (availableMessages.length == 0) {
availableMessages = messages.slice();
}
const id = Math.random().toString(36).substring(7);
const username = usernames[Math.floor(Math.random() * usernames.length)];
const messageIndex = Math.floor(Math.random() * availableMessages.length);
const message = availableMessages[messageIndex];
availableMessages.splice(messageIndex, 1);
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;
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 ws = new WebSocket('ws://localhost:8080/entry', {
const ws = new WebSocket('ws://localhost:8080/ws', {
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
import (
"crypto/rand"
"encoding/base64"
"math/rand"
"time"
)
const tokenLength = 32
@ -17,7 +18,8 @@ func GenerateAccessToken() (string, error) {
// case the caller should not continue.
func generateRandomBytes(n int) ([]byte, error) {
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.
if err != nil {
return nil, err

View file

@ -32,7 +32,7 @@ func Restore(backupFile string, databaseFile string) error {
defer gz.Close()
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)
}
@ -59,12 +59,13 @@ func Restore(backupFile string, databaseFile string) error {
func Backup(db *sql.DB, backupFile string) {
log.Traceln("Backing up database to", backupFile)
BackupDirectory := filepath.Dir(backupFile)
backupDirectory := filepath.Dir(backupFile)
if !DoesFileExists(BackupDirectory) {
err := os.MkdirAll(BackupDirectory, 0700)
if !DoesFileExists(backupDirectory) {
err := os.MkdirAll(backupDirectory, 0700)
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"
"fmt"
"io/ioutil"
"math/rand"
"net/url"
"os"
"os/exec"
@ -237,3 +238,20 @@ func CleanupDirectory(path string) {
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>
<head>
<base target="_blank" />
<meta charset="UTF-8" />
<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" />

View file

@ -3,6 +3,7 @@
<head>
<title>Owncast</title>
<base target="_blank" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />

View file

@ -4,21 +4,32 @@ const html = htm.bind(h);
import Chat from './components/chat/chat.js';
import Websocket from './utils/websocket.js';
import { getLocalStorage, generateUsername } from './utils/helpers.js';
import { KEY_USERNAME } from './utils/constants.js';
import { getLocalStorage, setLocalStorage } from './utils/helpers.js';
import { KEY_EMBED_CHAT_ACCESS_TOKEN } from './utils/constants.js';
import { registerChat } from './chat/register.js';
export default class StandaloneChat extends Component {
constructor(props, context) {
super(props, context);
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
username: getLocalStorage(KEY_USERNAME) || generateUsername(),
username: null,
};
this.isRegistering = false;
this.hasConfiguredChat = false;
this.websocket = null;
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) {
@ -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) {
const { username, websocket } = state;
return (
html`
const { username, websocket, accessToken } = state;
return html`
<${Chat}
websocket=${websocket}
username=${username}
accessToken=${accessToken}
messagesOnly
ignoreClient
/>
`
);
`;
}
}

View file

@ -7,7 +7,9 @@ import SocialIconsList from './components/platform-logos-list.js';
import UsernameForm from './components/chat/username.js';
import VideoPoster from './components/video-poster.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, {
ExternalActionButton,
} from './components/external-action-modal.js';
@ -17,7 +19,6 @@ import {
classNames,
clearLocalStorage,
debounce,
generateUsername,
getLocalStorage,
getOrientation,
hasTouchScreen,
@ -27,7 +28,10 @@ import {
setLocalStorage,
} from './utils/helpers.js';
import {
CHAT_MAX_MESSAGE_LENGTH,
EST_SOCKET_PAYLOAD_BUFFER,
HEIGHT_SHORT_WIDE,
KEY_ACCESS_TOKEN,
KEY_CHAT_DISPLAYED,
KEY_USERNAME,
MESSAGE_OFFLINE,
@ -54,10 +58,13 @@ export default class App extends Component {
this.windowBlurred = false;
this.state = {
websocket: new Websocket(),
displayChat: chatStorage === null ? true : chatStorage,
websocket: null,
canChat: false, // all of chat functionality (panel + username)
displayChatPanel: chatStorage === null ? true : (chatStorage === 'true'), // just the chat panel
chatInputEnabled: false, // chat input box state
username: getLocalStorage(KEY_USERNAME) || generateUsername(),
accessToken: null,
username: getLocalStorage(KEY_USERNAME),
isRegistering: false,
touchKeyboardActive: false,
configData: {
@ -86,7 +93,7 @@ export default class App extends Component {
this.playerRestartTimer = null;
this.offlineTimer = null;
this.statusTimer = null;
this.disableChatTimer = null;
this.disableChatInputTimer = null;
this.streamDurationTimer = null;
// misc dom events
@ -116,6 +123,14 @@ export default class App extends Component {
// fetch events
this.getConfig = this.getConfig.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() {
@ -144,7 +159,7 @@ export default class App extends Component {
clearInterval(this.playerRestartTimer);
clearInterval(this.offlineTimer);
clearInterval(this.statusTimer);
clearTimeout(this.disableChatTimer);
clearTimeout(this.disableChatInputTimer);
clearInterval(this.streamDurationTimer);
window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('blur', this.handleWindowBlur);
@ -197,10 +212,20 @@ export default class App extends Component {
}
setConfigData(data = {}) {
const { name, summary } = data;
const { name, summary, chatDisabled } = data;
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({
canChat: !chatBlocked,
configData: {
...data,
summary: summary && addNewlines(summary),
@ -274,7 +299,7 @@ export default class App extends Component {
TIMER_DISABLE_CHAT_AFTER_OFFLINE -
(Date.now() - new Date(this.state.lastDisconnectTime));
const countdown = remainingChatTime < 0 ? 0 : remainingChatTime;
this.disableChatTimer = setTimeout(this.disableChatInput, countdown);
this.disableChatInputTimer = setTimeout(this.disableChatInput, countdown);
this.setState({
streamOnline: false,
streamStatusMessage: MESSAGE_OFFLINE,
@ -294,8 +319,8 @@ export default class App extends Component {
// play video!
handleOnlineMode() {
this.player.startPlayer();
clearTimeout(this.disableChatTimer);
this.disableChatTimer = null;
clearTimeout(this.disableChatInputTimer);
this.disableChatInputTimer = null;
this.streamDurationTimer = setInterval(
this.setCurrentStreamDuration,
@ -332,6 +357,8 @@ export default class App extends Component {
this.setState({
username: newName,
});
this.sendUsernameChange(newName);
}
handleFormFocus() {
@ -351,16 +378,12 @@ export default class App extends Component {
}
handleChatPanelToggle() {
const { displayChat: curDisplayed } = this.state;
const { displayChatPanel: curDisplayed } = this.state;
const displayChat = !curDisplayed;
if (displayChat) {
setLocalStorage(KEY_CHAT_DISPLAYED, displayChat);
} else {
clearLocalStorage(KEY_CHAT_DISPLAYED);
}
this.setState({
displayChat,
displayChatPanel: displayChat,
});
}
@ -371,7 +394,7 @@ export default class App extends Component {
}
handleNetworkingError(error) {
console.log(`>>> App Error: ${error}`);
console.error(`>>> App Error: ${error}`);
}
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) {
const {
chatInputEnabled,
configData,
displayChat,
displayChatPanel,
canChat,
isPlaying,
orientation,
playerActive,
@ -512,7 +619,6 @@ export default class App extends Component {
externalAction,
lastDisconnectTime,
} = state;
const {
version: appVersion,
logo = TEMP_IMAGE,
@ -524,6 +630,7 @@ export default class App extends Component {
chatDisabled,
externalActions,
customStyles,
maxSocketPayloadSize,
} = configData;
const bgUserLogo = { backgroundImage: `url(${logo})` };
@ -544,13 +651,13 @@ export default class App extends Component {
const shortHeight = windowHeight <= HEIGHT_SHORT_WIDE && !isPortrait;
const singleColMode = windowWidth <= WIDTH_SINGLE_COL && !shortHeight;
const shouldDisplayChat = displayChat && !chatDisabled;
const usernameStyle = chatDisabled ? 'none' : 'flex';
const shouldDisplayChat = displayChatPanel && canChat && !chatDisabled;
const extraAppClasses = classNames({
'config-loading': configData.loading,
chat: shouldDisplayChat,
'no-chat': !shouldDisplayChat,
'chat-hidden': !displayChatPanel && canChat && !chatDisabled, // hide panel
'chat-disabled': !canChat || chatDisabled,
'single-col': singleColMode,
'bg-gray-800': singleColMode && shouldDisplayChat,
'short-wide': shortHeight && windowWidth > WIDTH_SINGLE_COL,
@ -587,6 +694,19 @@ export default class App extends Component {
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`
<div
id="app-container"
@ -620,7 +740,6 @@ export default class App extends Component {
<div
id="user-options-container"
class="flex flex-row justify-end items-center flex-no-wrap"
style=${{ display: usernameStyle }}
>
<${UsernameForm}
username=${username}
@ -633,6 +752,7 @@ export default class App extends Component {
id="chat-toggle"
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"
style=${{ display: chatDisabled ? 'none' : 'block' }}
>
💬
</button>
@ -712,13 +832,7 @@ export default class App extends Component {
</span>
</footer>
<${Chat}
websocket=${websocket}
username=${username}
chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name}
/>
${externalActionModal}
${chat} ${externalActionModal}
</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,
convertToText,
convertOnPaste,
createEmojiMarkup,
trimNbsp,
emojify,
} from '../../utils/chat.js';
import {
getLocalStorage,
@ -19,7 +22,6 @@ import {
import {
URL_CUSTOM_EMOJIS,
KEY_CHAT_FIRST_MESSAGE_SENT,
CHAT_MAX_MESSAGE_LENGTH,
CHAT_CHAR_COUNT_BUFFER,
CHAT_OK_KEYCODES,
CHAT_KEY_MODIFIERS,
@ -38,10 +40,10 @@ export default class ChatInput extends Component {
this.state = {
inputHTML: '',
inputText: '', // for counting
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH,
inputCharsLeft: props.inputMaxBytes,
hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT),
emojiPicker: null,
emojiList: null,
};
this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this);
@ -71,6 +73,7 @@ export default class ChatInput extends Component {
return response.json();
})
.then((json) => {
const emojiList = json;
const emojiPicker = new EmojiButton({
zIndex: 100,
theme: 'owncast', // see chat.css
@ -91,7 +94,7 @@ export default class ChatInput extends Component {
this.formMessageInput.current.focus();
replaceCaret(this.formMessageInput.current);
});
this.setState({ emojiPicker });
this.setState({ emojiList, emojiPicker });
})
.catch((error) => {
// this.handleNetworkingError(`Emoji Fetch: ${error}`);
@ -106,18 +109,23 @@ export default class ChatInput extends Component {
}
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 = '';
if (emoji.url) {
const url = location.protocol + '//' + location.host + '/' + emoji.url;
const name = url.split('\\').pop().split('/').pop();
content = '<img class="emoji" alt="' + name + '" src="' + url + '"/>';
content = createEmojiMarkup(emoji, false);
} else {
content = emoji.emoji;
}
const newHTML = inputHTML + content;
const charsLeft = this.calculateCurrentBytesLeft(newHTML);
this.setState({
inputHTML: inputHTML + content,
inputCharsLeft: charsLeft,
});
// a hacky way add focus back into input field
setTimeout(() => {
@ -159,23 +167,33 @@ export default class ChatInput extends Component {
if (possibilities.length > 0) {
this.suggestion = possibilities[this.completionIndex];
const newHTML = inputHTML.substring(0, at + 1) + this.suggestion + ' ' + inputHTML.substring(position);
this.setState({
inputHTML:
inputHTML.substring(0, at + 1) +
this.suggestion +
' ' +
inputHTML.substring(position),
inputHTML: newHTML,
inputCharsLeft: this.calculateCurrentBytesLeft(newHTML),
});
}
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) {
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;
if (key === 'Enter') {
@ -196,37 +214,32 @@ export default class ChatInput extends Component {
if (key === 'Tab') {
if (this.autoCompleteNames()) {
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)) {
newStates.inputText = textValue;
this.setState(newStates);
// if new input pushes the potential chars over, don't do anything
const formField = this.formMessageInput.current;
const tempCharsLeft = this.calculateCurrentBytesLeft(formField.innerHTML);
if (tempCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) {
if (!this.modifierKeyPressed) {
event.preventDefault(); // prevent typing more
}
return;
}
newStates.inputText = textValue;
this.setState(newStates);
}
handleMessageInputKeyup(event) {
const formField = this.formMessageInput.current;
const textValue = formField.textContent; // get this only to count chars
const { key } = event;
if (key === 'Control' || key === 'Shift') {
this.prepNewLine = false;
}
if (CHAT_KEY_MODIFIERS.includes(key)) {
this.modifierKeyPressed = false;
}
if (key === ':' || key === ';') {
this.injectEmoji();
}
this.setState({
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH - textValue.length,
});
@ -239,11 +252,11 @@ export default class ChatInput extends Component {
handlePaste(event) {
// 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();
return;
}
convertOnPaste(event);
convertOnPaste(event, this.state.emojiList);
this.handleMessageInputKeydown(event);
}
@ -253,16 +266,15 @@ export default class ChatInput extends Component {
}
sendMessage() {
const { handleSendMessage } = this.props;
const { hasSentFirstChatMessage, inputHTML, inputText } = this.state;
if (CHAT_MAX_MESSAGE_LENGTH - inputText.length < 0) {
const { handleSendMessage, inputMaxBytes } = this.props;
const { hasSentFirstChatMessage, inputHTML, inputCharsLeft } = this.state;
if (inputCharsLeft < 0) {
return;
}
const message = convertToText(inputHTML);
const newStates = {
inputHTML: '',
inputText: '',
inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH,
inputCharsLeft: inputMaxBytes,
};
handleSendMessage(message);
@ -277,13 +289,23 @@ export default class ChatInput extends Component {
}
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) {
const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } =
state;
const { inputEnabled } = props;
const { inputEnabled, inputMaxBytes } = props;
const emojiButtonStyle = {
display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none',
};
@ -348,7 +370,7 @@ export default class ChatInput extends Component {
</span>
<span id="message-form-warning" class="text-red-600 text-xs"
>${inputCharsLeft}/${CHAT_MAX_MESSAGE_LENGTH}</span
>${inputCharsLeft} bytes</span
>
</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);
import {
messageBubbleColorForString,
textColorForString,
messageBubbleColorForHue,
textColorForHue,
} from '../../utils/user-colors.js';
import { convertToText } from '../../utils/chat.js';
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
@ -28,8 +28,9 @@ export default class ChatMessageView extends Component {
async componentDidMount() {
const { message, username } = this.props;
if (message && username) {
const { body } = message;
if (message && username) {
const formattedMessage = await formatMessageText(body, username);
this.setState({
formattedMessage,
@ -39,22 +40,24 @@ export default class ChatMessageView extends Component {
render() {
const { message } = this.props;
const { author, timestamp, visible } = message;
const { user, timestamp } = message;
const { displayName, displayColor, createdAt } = user;
const { formattedMessage } = this.state;
if (!formattedMessage) {
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 authorTextColor = isSystemMessage
? { color: '#fff' }
: { color: textColorForString(author) };
: { color: textColorForHue(displayColor) };
const backgroundStyle = isSystemMessage
? { backgroundColor: '#667eea' }
: { backgroundColor: messageBubbleColorForString(author) };
: { backgroundColor: messageBubbleColorForHue(displayColor) };
const messageClassString = isSystemMessage
? getSystemMessageClassString()
: getChatMessageClassString();
@ -66,8 +69,8 @@ export default class ChatMessageView extends Component {
title=${formattedTimestamp}
>
<div class="message-content break-words w-full">
<div style=${authorTextColor} class="message-author font-bold">
${author}
<div style=${authorTextColor} class="message-author font-bold" title=${userMetadata}>
${displayName}
</div>
<div
class="message-text text-gray-300 font-normal overflow-y-hidden pt-2"
@ -156,7 +159,7 @@ function formatTimestamp(sentAt) {
return '';
}
let diffInDays = getDiffInDaysFromNow(sentAt); //(new Date() - sentAt) / (24 * 3600 * 1000);
let diffInDays = getDiffInDaysFromNow(sentAt);
if (diffInDays >= 1) {
return (
`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 {
jumpToBottom,
debounce,
getLocalStorage,
setLocalStorage,
} from '../../utils/helpers.js';
import { extraUserNamesFromMessageHistory } from '../../utils/chat.js';
import {
URL_CHAT_HISTORY,
MESSAGE_JUMPTOBOTTOM_BUFFER,
KEY_CUSTOM_USERNAME_SET,
} from '../../utils/constants.js';
export default class Chat extends Component {
@ -33,6 +32,7 @@ export default class Chat extends Component {
this.websocket = null;
this.receivedFirstMessages = false;
this.receivedMessageUpdate = false;
this.hasFetchedHistory = false;
this.windowBlurred = false;
this.numMessagesSinceBlur = 0;
@ -52,7 +52,6 @@ export default class Chat extends Component {
componentDidMount() {
this.setupWebSocketCallbacks();
this.getChatHistory();
window.addEventListener('resize', this.handleWindowResize);
@ -93,23 +92,25 @@ export default class Chat extends Component {
componentDidUpdate(prevProps, prevState) {
const { username: prevName } = prevProps;
const { username } = this.props;
const { username, accessToken } = this.props;
const { messages: prevMessages } = prevState;
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
if (messages.length !== prevMessages.length) {
this.setState({
newMessagesReceived: true,
});
}
// Fetch chat history
if (!this.hasFetchedHistory && accessToken) {
this.hasFetchedHistory = true;
this.getChatHistory(accessToken);
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleWindowResize);
if (!this.props.messagesOnly) {
@ -138,8 +139,8 @@ export default class Chat extends Component {
}
// fetch chat history
getChatHistory() {
fetch(URL_CHAT_HISTORY)
getChatHistory(accessToken) {
fetch(URL_CHAT_HISTORY + `?accessToken=${accessToken}`)
.then((response) => {
if (!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),
chatUserNames,
});
this.scrollToBottom();
})
.catch((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) {
this.handleMessage(message);
}
handleNetworkingError(error) {
// todo: something more useful
console.log(error);
console.error('chat error', error);
}
// handle any incoming message
@ -247,13 +238,6 @@ export default class Chat extends Component {
this.setState({
webSocketConnected: true,
});
const hasPreviouslySetCustomUsername = getLocalStorage(
KEY_CUSTOM_USERNAME_SET
);
if (hasPreviouslySetCustomUsername && !this.props.ignoreClient) {
this.sendJoinedMessage();
}
}
websocketDisconnected() {
@ -269,38 +253,20 @@ export default class Chat extends Component {
const { username } = this.props;
const message = {
body: content,
author: username,
type: SOCKET_MESSAGE_TYPES.CHAT,
};
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) {
const { type } = message;
const nameList = this.state.chatUserNames;
if (
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) {
const { oldName, newName } = message;
const oldNameIndex = nameList.indexOf(oldName);
@ -373,7 +339,7 @@ export default class Chat extends Component {
}
render(props, state) {
const { username, messagesOnly, chatInputEnabled } = props;
const { username, messagesOnly, chatInputEnabled, inputMaxBytes } = props;
const { messages, chatUserNames, webSocketConnected } = state;
const messageList = messages
@ -416,6 +382,7 @@ export default class Chat extends Component {
chatUserNames=${chatUserNames}
inputEnabled=${webSocketConnected && chatInputEnabled}
handleSendMessage=${this.submitChat}
inputMaxBytes=${inputMaxBytes}
/>
</div>
</section>

View file

@ -12,33 +12,35 @@ export default function Message(props) {
if (type === SOCKET_MESSAGE_TYPES.CHAT || type === SOCKET_MESSAGE_TYPES.SYSTEM) {
return html`<${ChatMessageView} ...${props} />`;
} else if (type === SOCKET_MESSAGE_TYPES.NAME_CHANGE) {
const { oldName, newName } = message;
const { oldName, user } = message;
const { displayName } = user;
return (
html`
<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="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>
`
);
} else if (type === SOCKET_MESSAGE_TYPES.USER_JOINED) {
const { username } = message;
const { user } = message
const { displayName } = user;
return (
html`
<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="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>
`
);
} else if (type === SOCKET_MESSAGE_TYPES.CHAT_ACTION) {
const { author, body } = message;
const { body } = message;
const formattedMessage = `${body}`
return (
html`
@ -51,6 +53,8 @@ export default function Message(props) {
</div>
`
);
} else if (type === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
// noop for now
} else {
console.log("Unknown message type:", type);
}

View file

@ -24,7 +24,7 @@ export default class ExternalActionModal extends Component {
onClose: this.props.onClose,
});
} 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');
qualities = await response.json();
} catch (e) {
console.log(e);
console.error(e);
}
var MenuItem = videojs.getComponent('MenuItem');

View file

@ -56,8 +56,8 @@ export function extraUserNamesFromMessageHistory(messages) {
const list = [];
if (messages) {
messages.forEach(function (message) {
if (!list.includes(message.author)) {
list.push(message.author);
if (!list.includes(message.user.displayName)) {
list.push(message.user.displayName);
}
});
}
@ -86,6 +86,9 @@ export function convertToText(str = '') {
// Replace `<p>` (from IE).
value = value.replace(/<p>/gi, '\n');
// Cleanup the emoji titles.
value = value.replace(/\u200C{2}/gi, '');
// Trim each line.
value = value
.split('\n')
@ -109,7 +112,7 @@ export function convertToText(str = '') {
You would call this when a user pastes from
the clipboard into a `contenteditable` area.
*/
export function convertOnPaste( event = { preventDefault() {} }) {
export function convertOnPaste(event = { preventDefault() { } }, emojiList) {
// Prevent paste.
event.preventDefault();
@ -136,8 +139,47 @@ export function convertOnPaste( event = { preventDefault() {} }) {
// Clean up text.
value = convertToText(value);
const HTML = emojify(value, emojiList);
// Insert text.
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.
export const URL_STREAM = `/hls/stream.m3u8`;
export const URL_WEBSOCKET = `${
location.protocol === 'https:' ? 'wss' : 'ws'
}://${location.host}/entry`;
export const URL_WEBSOCKET = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
export const URL_CHAT_REGISTRATION = `/api/chat/register`;
export const TIMER_STATUS_UPDATE = 5000; // ms
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 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_CUSTOM_USERNAME_SET = 'owncast_custom_username_set';
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_OFFLINE = 'Chat is offline.';
export const CHAT_MAX_MESSAGE_LENGTH = 500;
export const EST_SOCKET_PAYLOAD_BUFFER = 512;
export const CHAT_CHAR_COUNT_BUFFER = 20;
export const CHAT_OK_KEYCODES = [
'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) {
return String(pad.repeat(size) + text).slice(-size);
}
@ -122,7 +118,6 @@ export function setVHvar() {
var vh = window.innerHeight * 0.01;
// Then we set the value in the --vh custom property to the root of the document
document.documentElement.style.setProperty('--vh', `${vh}px`);
console.log('== new vh', vh);
}
export function doesObjectSupportFunction(object, functionName) {

View file

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

View file

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

View file

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