diff --git a/config/defaults.go b/config/defaults.go index 6b5bd1d90..964756af1 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -1,6 +1,10 @@ package config -import "github.com/owncast/owncast/models" +import ( + "time" + + "github.com/owncast/owncast/models" +) // Defaults will hold default configuration values. type Defaults struct { @@ -27,6 +31,8 @@ type Defaults struct { FederationUsername string FederationGoLiveMessage string + + ChatEstablishedUserModeTimeDuration time.Duration } // GetDefaults will return default configuration values. @@ -54,6 +60,8 @@ func GetDefaults() Defaults { RTMPServerPort: 1935, StreamKey: "abc123", + ChatEstablishedUserModeTimeDuration: time.Minute * 15, + StreamVariants: []models.StreamOutputVariant{ { IsAudioPassthrough: true, diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index 6f1c40ce6..4ce73d618 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -345,3 +345,24 @@ func SendChatAction(integration user.ExternalAPIUser, w http.ResponseWriter, r * controllers.WriteSimpleResponse(w, true, "sent") } + +// SetEnableEstablishedChatUserMode sets the requirement for a chat user +// to be "established" for some time before taking part in chat. +func SetEnableEstablishedChatUserMode(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + controllers.WriteSimpleResponse(w, false, "unable to update chat established user only mode") + return + } + + if err := data.SetChatEstablishedUsersOnlyMode(configValue.Value.(bool)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + controllers.WriteSimpleResponse(w, true, "chat established users only mode updated") +} diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index ad5595e53..d34162eb9 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -54,6 +54,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { ChatDisabled: data.GetChatDisabled(), ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(), SocketHostOverride: data.GetWebsocketOverrideHost(), + ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(), VideoSettings: videoSettings{ VideoQualityVariants: videoQualityVariants, LatencyLevel: data.GetStreamLatencyLevel().Level, @@ -98,6 +99,7 @@ type serverConfigAdminResponse struct { YP yp `json:"yp"` ChatDisabled bool `json:"chatDisabled"` ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"` + ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"` ExternalActions []models.ExternalAction `json:"externalActions"` SupportedCodecs []string `json:"supportedCodecs"` VideoCodec string `json:"videoCodec"` diff --git a/core/chat/server.go b/core/chat/server.go index c0ccc356a..70020c7b9 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/websocket" + "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/user" @@ -330,6 +331,16 @@ func SendActionToUser(userID string, text string) error { } func (s *Server) eventReceived(event chatClientEvent) { + c := event.client + u := c.User + + // If established chat user only mode is enabled and the user is not old + // enough then reject this event and send them an informative message. + if u != nil && data.GetChatEstbalishedUsersOnlyMode() && time.Since(event.client.User.CreatedAt) < config.GetDefaults().ChatEstablishedUserModeTimeDuration && !u.IsModerator() { + s.sendActionToClient(c, "You have not been an established chat participant long enough to take part in chat. Please enjoy the stream and try again later.") + return + } + var typecheck map[string]interface{} if err := json.Unmarshal(event.data, &typecheck); err != nil { log.Debugln(err) diff --git a/core/data/config.go b/core/data/config.go index ee031e3c8..a073b3e48 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -14,47 +14,48 @@ import ( ) const ( - extraContentKey = "extra_page_content" - streamTitleKey = "stream_title" - streamKeyKey = "stream_key" - logoPathKey = "logo_path" - serverSummaryKey = "server_summary" - serverWelcomeMessageKey = "server_welcome_message" - serverNameKey = "server_name" - serverURLKey = "server_url" - httpPortNumberKey = "http_port_number" - httpListenAddressKey = "http_listen_address" - websocketHostOverrideKey = "websocket_host_override" - rtmpPortNumberKey = "rtmp_port_number" - serverMetadataTagsKey = "server_metadata_tags" - directoryEnabledKey = "directory_enabled" - directoryRegistrationKeyKey = "directory_registration_key" - socialHandlesKey = "social_handles" - peakViewersSessionKey = "peak_viewers_session" - peakViewersOverallKey = "peak_viewers_overall" - lastDisconnectTimeKey = "last_disconnect_time" - ffmpegPathKey = "ffmpeg_path" - nsfwKey = "nsfw" - s3StorageEnabledKey = "s3_storage_enabled" - s3StorageConfigKey = "s3_storage_config" - videoLatencyLevel = "video_latency_level" - videoStreamOutputVariantsKey = "video_stream_output_variants" - chatDisabledKey = "chat_disabled" - externalActionsKey = "external_actions" - customStylesKey = "custom_styles" - videoCodecKey = "video_codec" - blockedUsernamesKey = "blocked_usernames" - publicKeyKey = "public_key" - privateKeyKey = "private_key" - serverInitDateKey = "server_init_date" - federationEnabledKey = "federation_enabled" - federationUsernameKey = "federation_username" - federationPrivateKey = "federation_private" - federationGoLiveMessageKey = "federation_go_live_message" - federationShowEngagementKey = "federation_show_engagement" - federationBlockedDomainsKey = "federation_blocked_domains" - suggestedUsernamesKey = "suggested_usernames" - chatJoinMessagesEnabledKey = "chat_join_messages_enabled" + extraContentKey = "extra_page_content" + streamTitleKey = "stream_title" + streamKeyKey = "stream_key" + logoPathKey = "logo_path" + serverSummaryKey = "server_summary" + serverWelcomeMessageKey = "server_welcome_message" + serverNameKey = "server_name" + serverURLKey = "server_url" + httpPortNumberKey = "http_port_number" + httpListenAddressKey = "http_listen_address" + websocketHostOverrideKey = "websocket_host_override" + rtmpPortNumberKey = "rtmp_port_number" + serverMetadataTagsKey = "server_metadata_tags" + directoryEnabledKey = "directory_enabled" + directoryRegistrationKeyKey = "directory_registration_key" + socialHandlesKey = "social_handles" + peakViewersSessionKey = "peak_viewers_session" + peakViewersOverallKey = "peak_viewers_overall" + lastDisconnectTimeKey = "last_disconnect_time" + ffmpegPathKey = "ffmpeg_path" + nsfwKey = "nsfw" + s3StorageEnabledKey = "s3_storage_enabled" + s3StorageConfigKey = "s3_storage_config" + videoLatencyLevel = "video_latency_level" + videoStreamOutputVariantsKey = "video_stream_output_variants" + chatDisabledKey = "chat_disabled" + externalActionsKey = "external_actions" + customStylesKey = "custom_styles" + videoCodecKey = "video_codec" + blockedUsernamesKey = "blocked_usernames" + publicKeyKey = "public_key" + privateKeyKey = "private_key" + serverInitDateKey = "server_init_date" + federationEnabledKey = "federation_enabled" + federationUsernameKey = "federation_username" + federationPrivateKey = "federation_private" + federationGoLiveMessageKey = "federation_go_live_message" + federationShowEngagementKey = "federation_show_engagement" + federationBlockedDomainsKey = "federation_blocked_domains" + suggestedUsernamesKey = "suggested_usernames" + chatJoinMessagesEnabledKey = "chat_join_messages_enabled" + chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode" ) // GetExtraPageBodyContent will return the user-supplied body content. @@ -501,6 +502,21 @@ func GetChatDisabled() bool { return false } +// SetChatEstablishedUsersOnlyMode sets the state of established user only mode. +func SetChatEstablishedUsersOnlyMode(enabled bool) error { + return _datastore.SetBool(chatEstablishedUsersOnlyModeKey, enabled) +} + +// GetChatEstbalishedUsersOnlyMode returns the state of established user only mode. +func GetChatEstbalishedUsersOnlyMode() bool { + enabled, err := _datastore.GetBool(chatEstablishedUsersOnlyModeKey) + if err == nil { + return enabled + } + + return false +} + // GetExternalActions will return the registered external actions. func GetExternalActions() []models.ExternalAction { configEntry, err := _datastore.Get(externalActionsKey) diff --git a/router/router.go b/router/router.go index 56fc8b420..3d03f423e 100644 --- a/router/router.go +++ b/router/router.go @@ -177,6 +177,9 @@ func Start() error { // Disable chat user join messages http.HandleFunc("/api/admin/config/chat/joinmessagesenabled", middleware.RequireAdminAuth(admin.SetChatJoinMessagesEnabled)) + // Enable/disable chat established user mode + http.HandleFunc("/api/admin/config/chat/establishedusermode", middleware.RequireAdminAuth(admin.SetEnableEstablishedChatUserMode)) + // Set chat usernames that are not allowed http.HandleFunc("/api/admin/config/chat/forbiddenusernames", middleware.RequireAdminAuth(admin.SetForbiddenUsernameList)) diff --git a/test/automated/api/chat.test.js b/test/automated/api/chat.test.js index 3484bed0d..9e2283d8f 100644 --- a/test/automated/api/chat.test.js +++ b/test/automated/api/chat.test.js @@ -27,10 +27,12 @@ test('can fetch chat messages', async (done) => { .auth('admin', 'abc123') .expect(200); - const expectedBody = `${testMessage.body}`; - const message = res.body.filter(function (msg) { - return msg.body === expectedBody; - })[0]; + const message = res.body.filter((m) => m.body === testMessage.body)[0]; + if (!message) { + throw new Error('Message not found'); + } + + const expectedBody = testMessage.body; expect(message.body).toBe(expectedBody); expect(message.user.displayName).toBe(userDisplayName); @@ -52,7 +54,7 @@ test('can derive display name from user header', async (done) => { test('can overwrite user header derived display name with body', async (done) => { const res = await request .post('/api/chat/register') - .send({displayName: 'TestUserChat'}) + .send({ displayName: 'TestUserChat' }) .set('X-Forwarded-User', 'test-user') .expect(200); diff --git a/test/automated/api/chatmoderation.test.js b/test/automated/api/chatmoderation.test.js index 71b60cd85..aa23ec18d 100644 --- a/test/automated/api/chatmoderation.test.js +++ b/test/automated/api/chatmoderation.test.js @@ -5,13 +5,19 @@ const WebSocket = require('ws'); const registerChat = require('./lib/chat').registerChat; const sendChatMessage = require('./lib/chat').sendChatMessage; -const listenForEvent = require('./lib/chat').listenForEvent; const testVisibilityMessage = { body: 'message ' + Math.floor(Math.random() * 100), type: 'CHAT', }; +var messageId; + +const establishedUserFailedChatMessage = { + body: 'this message should fail to send ' + Math.floor(Math.random() * 100), + type: 'CHAT', +}; + test('can send a chat message', async (done) => { const registration = await registerChat(); const accessToken = registration.accessToken; @@ -30,14 +36,14 @@ test('verify we can make API call to mark message as hidden', async (done) => { ); // Verify the visibility change comes through the websocket - ws.on('message', function incoming(message) { + ws.on('message', async function incoming(message) { const messages = message.split('\n'); - messages.forEach(function (message) { + messages.forEach(async function (message) { const event = JSON.parse(message); if (event.type === 'VISIBILITY-UPDATE') { - done(); ws.close(); + done(); } }); }); @@ -48,7 +54,7 @@ test('verify we can make API call to mark message as hidden', async (done) => { .expect(200); const message = res.body[0]; - const messageId = message.id; + messageId = message.id; await request .post('/api/admin/chat/updatemessagevisibility') .auth('admin', 'abc123') @@ -57,15 +63,56 @@ test('verify we can make API call to mark message as hidden', async (done) => { }); test('verify message has become hidden', async (done) => { + await new Promise((r) => setTimeout(r, 2000)); + const res = await request .get('/api/admin/chat/messages') .expect(200) .auth('admin', 'abc123'); const message = res.body.filter((obj) => { - return obj.body === `${testVisibilityMessage.body}`; + return obj.id === messageId; }); expect(message.length).toBe(1); // expect(message[0].hiddenAt).toBeTruthy(); done(); }); + +test('can enable established chat user mode', async (done) => { + await request + .post('/api/admin/config/chat/establishedusermode') + .auth('admin', 'abc123') + .send({ value: true }) + .expect(200); + done(); +}); + +test('can send a message after established user mode is enabled', async (done) => { + const registration = await registerChat(); + const accessToken = registration.accessToken; + + sendChatMessage(establishedUserFailedChatMessage, accessToken, done); +}); + +test('verify rejected message is not in the chat feed', async (done) => { + const res = await request + .get('/api/admin/chat/messages') + .expect(200) + .auth('admin', 'abc123'); + + const message = res.body.filter((obj) => { + return obj.body === establishedUserFailedChatMessage.body; + }); + + expect(message.length).toBe(0); + done(); +}); + +test('can disable established chat user mode', async (done) => { + await request + .post('/api/admin/config/chat/establishedusermode') + .auth('admin', 'abc123') + .send({ value: false }) + .expect(200); + done(); +}); diff --git a/test/automated/api/lib/chat.js b/test/automated/api/lib/chat.js index e5c47e121..dacdb5520 100644 --- a/test/automated/api/lib/chat.js +++ b/test/automated/api/lib/chat.js @@ -11,16 +11,11 @@ async function registerChat() { } } -function sendChatMessage(message, accessToken, done) { - const ws = new WebSocket( - `ws://localhost:8080/ws?accessToken=${accessToken}`, - { - origin: 'http://localhost:8080', - } - ); +async function sendChatMessage(message, accessToken, done) { + const ws = new WebSocket(`ws://localhost:8080/ws?accessToken=${accessToken}`); - function onOpen() { - ws.send(JSON.stringify(message), function () { + async function onOpen() { + ws.send(JSON.stringify(message), async function () { ws.close(); done(); }); @@ -30,12 +25,7 @@ function sendChatMessage(message, accessToken, done) { } async function listenForEvent(name, accessToken, done) { - const ws = new WebSocket( - `ws://localhost:8080/ws?accessToken=${accessToken}`, - { - origin: 'http://localhost:8080', - } - ); + const ws = new WebSocket(`ws://localhost:8080/ws?accessToken=${accessToken}`); ws.on('message', function incoming(message) { const messages = message.split('\n'); diff --git a/test/automated/api/package-lock.json b/test/automated/api/package-lock.json index 719f9fc13..b57527dcf 100644 --- a/test/automated/api/package-lock.json +++ b/test/automated/api/package-lock.json @@ -600,6 +600,15 @@ "node": ">= 10.14.2" } }, + "node_modules/@jest/core/node_modules/ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/core/node_modules/strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", @@ -984,15 +993,6 @@ "node": ">=8" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3373,6 +3373,15 @@ "node": ">= 10.14.2" } }, + "node_modules/jest-runtime/node_modules/ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-runtime/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -3638,6 +3647,15 @@ "node": ">= 10.13.0" } }, + "node_modules/jest/node_modules/ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/jest/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -4613,6 +4631,15 @@ "node": ">= 10" } }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/prompts": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", @@ -5634,6 +5661,15 @@ "node": ">=10" } }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/string-length/node_modules/strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", @@ -6791,6 +6827,12 @@ "strip-ansi": "^6.0.0" }, "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, "strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", @@ -7132,12 +7174,6 @@ } } }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -8688,6 +8724,12 @@ "jest-cli": "^26.6.3" }, "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -9211,6 +9253,12 @@ "yargs": "^15.4.1" }, "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -10062,6 +10110,14 @@ "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + } } }, "prompts": { @@ -10907,6 +10963,12 @@ "strip-ansi": "^6.0.0" }, "dependencies": { + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, "strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",