From a450e62397c74babd96a414db8cb2bc95e764b7a Mon Sep 17 00:00:00 2001
From: Gabe Kangas <gabek@real-ity.com>
Date: Tue, 9 Apr 2024 22:25:41 -0700
Subject: [PATCH] Optionally disable chat rate limiter and add optional chat
 slur/language filter (#3681)

* feat(chat): basic profanity filter. For #3139

* feat(chat): add setting for disabling chat spam protection. Closes #3523

* feat(chat): wire up the new chat slur filter to admin and chat. Closes #3139
---
 controllers/admin/config.go         |  36 +++++++
 controllers/admin/serverConfig.go   |  82 ++++++++--------
 controllers/config.go               |  82 ++++++++--------
 core/chat/chatclient.go             |  31 ++++--
 core/chat/messageFilter.go          |  18 ++++
 core/chat/messageFilter_test.go     |  39 ++++++++
 core/data/config.go                 |  32 +++++++
 go.mod                              |   1 +
 go.sum                              |   2 +
 router/router.go                    |   5 +
 web/pages/admin/config-chat.tsx     | 144 +++++++++++++++++-----------
 web/types/config-section.ts         |   2 +
 web/utils/config-constants.tsx      |  18 ++++
 web/utils/server-status-context.tsx |   2 +
 14 files changed, 352 insertions(+), 142 deletions(-)
 create mode 100644 core/chat/messageFilter.go
 create mode 100644 core/chat/messageFilter_test.go

diff --git a/controllers/admin/config.go b/controllers/admin/config.go
index 5fd78e7df..0648803ed 100644
--- a/controllers/admin/config.go
+++ b/controllers/admin/config.go
@@ -802,6 +802,42 @@ func SetVideoServingEndpoint(w http.ResponseWriter, r *http.Request) {
 	controllers.WriteSimpleResponse(w, true, "custom video serving endpoint updated")
 }
 
+// SetChatSpamProtectionEnabled will enable or disable the chat spam protection.
+func SetChatSpamProtectionEnabled(w http.ResponseWriter, r *http.Request) {
+	if !requirePOST(w, r) {
+		return
+	}
+
+	configValue, success := getValueFromRequest(w, r)
+	if !success {
+		return
+	}
+
+	if err := data.SetChatSpamProtectionEnabled(configValue.Value.(bool)); err != nil {
+		controllers.WriteSimpleResponse(w, false, err.Error())
+		return
+	}
+	controllers.WriteSimpleResponse(w, true, "chat spam protection changed")
+}
+
+// SetChatSlurFilterEnabled will enable or disable the chat slur filter.
+func SetChatSlurFilterEnabled(w http.ResponseWriter, r *http.Request) {
+	if !requirePOST(w, r) {
+		return
+	}
+
+	configValue, success := getValueFromRequest(w, r)
+	if !success {
+		return
+	}
+
+	if err := data.SetChatSlurFilterEnabled(configValue.Value.(bool)); err != nil {
+		controllers.WriteSimpleResponse(w, false, err.Error())
+		return
+	}
+	controllers.WriteSimpleResponse(w, true, "chat message slur filter changed")
+}
+
 func requirePOST(w http.ResponseWriter, r *http.Request) bool {
 	if r.Method != controllers.POST {
 		controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go
index 2429fdfa1..43e2720df 100644
--- a/controllers/admin/serverConfig.go
+++ b/controllers/admin/serverConfig.go
@@ -49,20 +49,22 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
 			CustomJavascript:    data.GetCustomJavascript(),
 			AppearanceVariables: data.GetCustomColorVariableValues(),
 		},
-		FFmpegPath:              ffmpeg,
-		AdminPassword:           data.GetAdminPassword(),
-		StreamKeys:              data.GetStreamKeys(),
-		StreamKeyOverridden:     config.TemporaryStreamKey != "",
-		WebServerPort:           config.WebServerPort,
-		WebServerIP:             config.WebServerIP,
-		RTMPServerPort:          data.GetRTMPPortNumber(),
-		ChatDisabled:            data.GetChatDisabled(),
-		ChatJoinMessagesEnabled: data.GetChatJoinPartMessagesEnabled(),
-		SocketHostOverride:      data.GetWebsocketOverrideHost(),
-		VideoServingEndpoint:    data.GetVideoServingEndpoint(),
-		ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(),
-		HideViewerCount:         data.GetHideViewerCount(),
-		DisableSearchIndexing:   data.GetDisableSearchIndexing(),
+		FFmpegPath:                ffmpeg,
+		AdminPassword:             data.GetAdminPassword(),
+		StreamKeys:                data.GetStreamKeys(),
+		StreamKeyOverridden:       config.TemporaryStreamKey != "",
+		WebServerPort:             config.WebServerPort,
+		WebServerIP:               config.WebServerIP,
+		RTMPServerPort:            data.GetRTMPPortNumber(),
+		ChatDisabled:              data.GetChatDisabled(),
+		ChatJoinMessagesEnabled:   data.GetChatJoinPartMessagesEnabled(),
+		SocketHostOverride:        data.GetWebsocketOverrideHost(),
+		VideoServingEndpoint:      data.GetVideoServingEndpoint(),
+		ChatEstablishedUserMode:   data.GetChatEstbalishedUsersOnlyMode(),
+		ChatSpamProtectionEnabled: data.GetChatSpamProtectionEnabled(),
+		ChatSlurFilterEnabled:     data.GetChatSlurFilterEnabled(),
+		HideViewerCount:           data.GetHideViewerCount(),
+		DisableSearchIndexing:     data.GetDisableSearchIndexing(),
 		VideoSettings: videoSettings{
 			VideoQualityVariants: videoQualityVariants,
 			LatencyLevel:         data.GetStreamLatencyLevel().Level,
@@ -100,31 +102,33 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
 }
 
 type serverConfigAdminResponse struct {
-	InstanceDetails         webConfigResponse           `json:"instanceDetails"`
-	Notifications           notificationsConfigResponse `json:"notifications"`
-	YP                      yp                          `json:"yp"`
-	FFmpegPath              string                      `json:"ffmpegPath"`
-	AdminPassword           string                      `json:"adminPassword"`
-	SocketHostOverride      string                      `json:"socketHostOverride,omitempty"`
-	WebServerIP             string                      `json:"webServerIP"`
-	VideoCodec              string                      `json:"videoCodec"`
-	VideoServingEndpoint    string                      `json:"videoServingEndpoint"`
-	S3                      models.S3                   `json:"s3"`
-	Federation              federationConfigResponse    `json:"federation"`
-	SupportedCodecs         []string                    `json:"supportedCodecs"`
-	ExternalActions         []models.ExternalAction     `json:"externalActions"`
-	ForbiddenUsernames      []string                    `json:"forbiddenUsernames"`
-	SuggestedUsernames      []string                    `json:"suggestedUsernames"`
-	StreamKeys              []models.StreamKey          `json:"streamKeys"`
-	VideoSettings           videoSettings               `json:"videoSettings"`
-	RTMPServerPort          int                         `json:"rtmpServerPort"`
-	WebServerPort           int                         `json:"webServerPort"`
-	ChatDisabled            bool                        `json:"chatDisabled"`
-	ChatJoinMessagesEnabled bool                        `json:"chatJoinMessagesEnabled"`
-	ChatEstablishedUserMode bool                        `json:"chatEstablishedUserMode"`
-	DisableSearchIndexing   bool                        `json:"disableSearchIndexing"`
-	StreamKeyOverridden     bool                        `json:"streamKeyOverridden"`
-	HideViewerCount         bool                        `json:"hideViewerCount"`
+	InstanceDetails           webConfigResponse           `json:"instanceDetails"`
+	Notifications             notificationsConfigResponse `json:"notifications"`
+	YP                        yp                          `json:"yp"`
+	FFmpegPath                string                      `json:"ffmpegPath"`
+	AdminPassword             string                      `json:"adminPassword"`
+	SocketHostOverride        string                      `json:"socketHostOverride,omitempty"`
+	WebServerIP               string                      `json:"webServerIP"`
+	VideoCodec                string                      `json:"videoCodec"`
+	VideoServingEndpoint      string                      `json:"videoServingEndpoint"`
+	S3                        models.S3                   `json:"s3"`
+	Federation                federationConfigResponse    `json:"federation"`
+	SupportedCodecs           []string                    `json:"supportedCodecs"`
+	ExternalActions           []models.ExternalAction     `json:"externalActions"`
+	ForbiddenUsernames        []string                    `json:"forbiddenUsernames"`
+	SuggestedUsernames        []string                    `json:"suggestedUsernames"`
+	StreamKeys                []models.StreamKey          `json:"streamKeys"`
+	VideoSettings             videoSettings               `json:"videoSettings"`
+	RTMPServerPort            int                         `json:"rtmpServerPort"`
+	WebServerPort             int                         `json:"webServerPort"`
+	ChatDisabled              bool                        `json:"chatDisabled"`
+	ChatJoinMessagesEnabled   bool                        `json:"chatJoinMessagesEnabled"`
+	ChatEstablishedUserMode   bool                        `json:"chatEstablishedUserMode"`
+	ChatSpamProtectionEnabled bool                        `json:"chatSpamProtectionEnabled"`
+	ChatSlurFilterEnabled     bool                        `json:"chatSlurFilterEnabled"`
+	DisableSearchIndexing     bool                        `json:"disableSearchIndexing"`
+	StreamKeyOverridden       bool                        `json:"streamKeyOverridden"`
+	HideViewerCount           bool                        `json:"hideViewerCount"`
 }
 
 type videoSettings struct {
diff --git a/controllers/config.go b/controllers/config.go
index ceec87692..a8ea9f599 100644
--- a/controllers/config.go
+++ b/controllers/config.go
@@ -16,26 +16,27 @@ import (
 )
 
 type webConfigResponse struct {
-	AppearanceVariables  map[string]string            `json:"appearanceVariables"`
-	Name                 string                       `json:"name"`
-	CustomStyles         string                       `json:"customStyles"`
-	StreamTitle          string                       `json:"streamTitle,omitempty"` // What's going on with the current stream
-	OfflineMessage       string                       `json:"offlineMessage"`
-	Logo                 string                       `json:"logo"`
-	Version              string                       `json:"version"`
-	SocketHostOverride   string                       `json:"socketHostOverride,omitempty"`
-	ExtraPageContent     string                       `json:"extraPageContent"`
-	Summary              string                       `json:"summary"`
-	Tags                 []string                     `json:"tags"`
-	SocialHandles        []models.SocialHandle        `json:"socialHandles"`
-	ExternalActions      []models.ExternalAction      `json:"externalActions"`
-	Notifications        notificationsConfigResponse  `json:"notifications"`
-	Federation           federationConfigResponse     `json:"federation"`
-	MaxSocketPayloadSize int                          `json:"maxSocketPayloadSize"`
-	HideViewerCount      bool                         `json:"hideViewerCount"`
-	ChatDisabled         bool                         `json:"chatDisabled"`
-	NSFW                 bool                         `json:"nsfw"`
-	Authentication       authenticationConfigResponse `json:"authentication"`
+	AppearanceVariables        map[string]string            `json:"appearanceVariables"`
+	Name                       string                       `json:"name"`
+	CustomStyles               string                       `json:"customStyles"`
+	StreamTitle                string                       `json:"streamTitle,omitempty"` // What's going on with the current stream
+	OfflineMessage             string                       `json:"offlineMessage"`
+	Logo                       string                       `json:"logo"`
+	Version                    string                       `json:"version"`
+	SocketHostOverride         string                       `json:"socketHostOverride,omitempty"`
+	ExtraPageContent           string                       `json:"extraPageContent"`
+	Summary                    string                       `json:"summary"`
+	Tags                       []string                     `json:"tags"`
+	SocialHandles              []models.SocialHandle        `json:"socialHandles"`
+	ExternalActions            []models.ExternalAction      `json:"externalActions"`
+	Notifications              notificationsConfigResponse  `json:"notifications"`
+	Federation                 federationConfigResponse     `json:"federation"`
+	MaxSocketPayloadSize       int                          `json:"maxSocketPayloadSize"`
+	HideViewerCount            bool                         `json:"hideViewerCount"`
+	ChatDisabled               bool                         `json:"chatDisabled"`
+	ChatSpamProtectionDisabled bool                         `json:"chatSpamProtectionDisabled"`
+	NSFW                       bool                         `json:"nsfw"`
+	Authentication             authenticationConfigResponse `json:"authentication"`
 }
 
 type federationConfigResponse struct {
@@ -118,26 +119,27 @@ func getConfigResponse() webConfigResponse {
 	}
 
 	return webConfigResponse{
-		Name:                 data.GetServerName(),
-		Summary:              serverSummary,
-		OfflineMessage:       offlineMessage,
-		Logo:                 "/logo",
-		Tags:                 data.GetServerMetadataTags(),
-		Version:              config.GetReleaseString(),
-		NSFW:                 data.GetNSFW(),
-		SocketHostOverride:   data.GetWebsocketOverrideHost(),
-		ExtraPageContent:     pageContent,
-		StreamTitle:          data.GetStreamTitle(),
-		SocialHandles:        socialHandles,
-		ChatDisabled:         data.GetChatDisabled(),
-		ExternalActions:      data.GetExternalActions(),
-		CustomStyles:         data.GetCustomStyles(),
-		MaxSocketPayloadSize: config.MaxSocketPayloadSize,
-		Federation:           federationResponse,
-		Notifications:        notificationsResponse,
-		Authentication:       authenticationResponse,
-		AppearanceVariables:  data.GetCustomColorVariableValues(),
-		HideViewerCount:      data.GetHideViewerCount(),
+		Name:                       data.GetServerName(),
+		Summary:                    serverSummary,
+		OfflineMessage:             offlineMessage,
+		Logo:                       "/logo",
+		Tags:                       data.GetServerMetadataTags(),
+		Version:                    config.GetReleaseString(),
+		NSFW:                       data.GetNSFW(),
+		SocketHostOverride:         data.GetWebsocketOverrideHost(),
+		ExtraPageContent:           pageContent,
+		StreamTitle:                data.GetStreamTitle(),
+		SocialHandles:              socialHandles,
+		ChatDisabled:               data.GetChatDisabled(),
+		ChatSpamProtectionDisabled: data.GetChatSpamProtectionEnabled(),
+		ExternalActions:            data.GetExternalActions(),
+		CustomStyles:               data.GetCustomStyles(),
+		MaxSocketPayloadSize:       config.MaxSocketPayloadSize,
+		Federation:                 federationResponse,
+		Notifications:              notificationsResponse,
+		Authentication:             authenticationResponse,
+		AppearanceVariables:        data.GetCustomColorVariableValues(),
+		HideViewerCount:            data.GetHideViewerCount(),
 	}
 }
 
diff --git a/core/chat/chatclient.go b/core/chat/chatclient.go
index fac4ed491..df344f17b 100644
--- a/core/chat/chatclient.go
+++ b/core/chat/chatclient.go
@@ -13,19 +13,21 @@ 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"
 	"github.com/owncast/owncast/geoip"
 )
 
 // Client represents a single chat client.
 type Client struct {
-	ConnectedAt  time.Time `json:"connectedAt"`
-	timeoutTimer *time.Timer
-	rateLimiter  *rate.Limiter
-	conn         *websocket.Conn
-	User         *user.User `json:"user"`
-	server       *Server
-	Geo          *geoip.GeoDetails `json:"geo"`
+	ConnectedAt   time.Time `json:"connectedAt"`
+	timeoutTimer  *time.Timer
+	rateLimiter   *rate.Limiter
+	messageFilter *ChatMessageFilter
+	conn          *websocket.Conn
+	User          *user.User `json:"user"`
+	server        *Server
+	Geo           *geoip.GeoDetails `json:"geo"`
 	// Buffered channel of outbound messages.
 	send         chan []byte
 	accessToken  string
@@ -90,6 +92,7 @@ func (c *Client) readPump() {
 	// Allow 3 messages every two seconds.
 	limit := rate.Every(2 * time.Second / 3)
 	c.rateLimiter = rate.NewLimiter(limit, 1)
+	c.messageFilter = NewMessageFilter()
 
 	defer func() {
 		c.close()
@@ -129,6 +132,12 @@ func (c *Client) readPump() {
 			continue
 		}
 
+		// Check if this message passes the optional language filter
+		if data.GetChatSlurFilterEnabled() && !c.messageFilter.Allow(string(message)) {
+			c.sendAction("Sorry, that message contained language that is not allowed in this chat.")
+			continue
+		}
+
 		message = bytes.TrimSpace(bytes.ReplaceAll(message, newline, space))
 		c.handleEvent(message)
 	}
@@ -200,7 +209,13 @@ func (c *Client) close() {
 }
 
 func (c *Client) passesRateLimit() bool {
-	return c.User.IsModerator() || (c.rateLimiter.Allow() && !c.inTimeout)
+	// If spam rate limiting is disabled, or the user is a moderator, always
+	// allow the message.
+	if !data.GetChatSpamProtectionEnabled() || c.User.IsModerator() {
+		return true
+	}
+
+	return (c.rateLimiter.Allow() && !c.inTimeout)
 }
 
 func (c *Client) startChatRejectionTimeout() {
diff --git a/core/chat/messageFilter.go b/core/chat/messageFilter.go
new file mode 100644
index 000000000..86d11ec02
--- /dev/null
+++ b/core/chat/messageFilter.go
@@ -0,0 +1,18 @@
+package chat
+
+import (
+	goaway "github.com/TwiN/go-away"
+)
+
+// ChatMessageFilter is a allow/deny chat message filter.
+type ChatMessageFilter struct{}
+
+// NewMessageFilter will return an instance of the chat message filter.
+func NewMessageFilter() *ChatMessageFilter {
+	return &ChatMessageFilter{}
+}
+
+// Allow will test if this message should be allowed to be sent.
+func (*ChatMessageFilter) Allow(message string) bool {
+	return !goaway.IsProfane(message)
+}
diff --git a/core/chat/messageFilter_test.go b/core/chat/messageFilter_test.go
new file mode 100644
index 000000000..37ca31823
--- /dev/null
+++ b/core/chat/messageFilter_test.go
@@ -0,0 +1,39 @@
+package chat
+
+import (
+	"testing"
+)
+
+func TestFiltering(t *testing.T) {
+	filter := NewMessageFilter()
+
+	filteredTestMessages := []string{
+		"Hello, fucking world!",
+		"Suck my dick",
+		"Eat my ass",
+		"fuck this shit",
+		"@$$h073",
+		"F   u   C  k th1$ $h!t",
+		"u r fag",
+		"fucking sucks",
+	}
+
+	unfilteredTestMessages := []string{
+		"bass fish",
+		"assumptions",
+	}
+
+	for _, m := range filteredTestMessages {
+		result := filter.Allow(m)
+		if result {
+			t.Errorf("%s should be seen as a filtered profane message", m)
+		}
+	}
+
+	for _, m := range unfilteredTestMessages {
+		result := filter.Allow(m)
+		if !result {
+			t.Errorf("%s should not be filtered", m)
+		}
+	}
+}
diff --git a/core/data/config.go b/core/data/config.go
index 47cc860af..ca440dc56 100644
--- a/core/data/config.go
+++ b/core/data/config.go
@@ -59,6 +59,8 @@ const (
 	suggestedUsernamesKey           = "suggested_usernames"
 	chatJoinMessagesEnabledKey      = "chat_join_messages_enabled"
 	chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode"
+	chatSpamProtectionEnabledKey    = "chat_spam_protection_enabled"
+	chatSlurFilterEnabledKey        = "chat_slur_filter_enabled"
 	notificationsEnabledKey         = "notifications_enabled"
 	discordConfigurationKey         = "discord_configuration"
 	browserPushConfigurationKey     = "browser_push_configuration"
@@ -528,6 +530,36 @@ func GetChatEstbalishedUsersOnlyMode() bool {
 	return false
 }
 
+// SetChatSpamProtectionEnabled will enable chat spam protection if set to true.
+func SetChatSpamProtectionEnabled(enabled bool) error {
+	return _datastore.SetBool(chatSpamProtectionEnabledKey, enabled)
+}
+
+// GetChatSpamProtectionEnabled will return if chat spam protection is enabled.
+func GetChatSpamProtectionEnabled() bool {
+	enabled, err := _datastore.GetBool(chatSpamProtectionEnabledKey)
+	if err == nil {
+		return enabled
+	}
+
+	return true
+}
+
+// SetChatSlurFilterEnabled will enable the chat slur filter.
+func SetChatSlurFilterEnabled(enabled bool) error {
+	return _datastore.SetBool(chatSlurFilterEnabledKey, enabled)
+}
+
+// GetChatSlurFilterEnabled will return if the chat slur filter is enabled.
+func GetChatSlurFilterEnabled() bool {
+	enabled, err := _datastore.GetBool(chatSlurFilterEnabledKey)
+	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/go.mod b/go.mod
index cce223231..354bd2c08 100644
--- a/go.mod
+++ b/go.mod
@@ -59,6 +59,7 @@ require (
 require github.com/SherClockHolmes/webpush-go v1.3.0
 
 require (
+	github.com/TwiN/go-away v1.6.13 // indirect
 	github.com/andybalholm/brotli v1.0.5 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
diff --git a/go.sum b/go.sum
index 72b79ab71..f858ad57e 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWG
 github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
 github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
 github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw=
+github.com/TwiN/go-away v1.6.13 h1:aB6l/FPXmA5ds+V7I9zdhxzpsLLUvVtEuS++iU/ZmgE=
+github.com/TwiN/go-away v1.6.13/go.mod h1:MpvIC9Li3minq+CGgbgUDvQ9tDaeW35k5IXZrF9MVas=
 github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
 github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
diff --git a/router/router.go b/router/router.go
index c1866eb5f..0c6dfc53b 100644
--- a/router/router.go
+++ b/router/router.go
@@ -210,6 +210,11 @@ func Start() error {
 	// Set the suggested chat usernames that will be assigned automatically
 	http.HandleFunc("/api/admin/config/chat/suggestedusernames", middleware.RequireAdminAuth(admin.SetSuggestedUsernameList))
 
+	// Enable or disable chat spam protection
+	http.HandleFunc("/api/admin/config/chat/spamprotectionenabled", middleware.RequireAdminAuth(admin.SetChatSpamProtectionEnabled))
+
+	http.HandleFunc("/api/admin/config/chat/slurfilterenabled", middleware.RequireAdminAuth(admin.SetChatSlurFilterEnabled))
+
 	// Set video codec
 	http.HandleFunc("/api/admin/config/video/codec", middleware.RequireAdminAuth(admin.SetVideoCodec))
 
diff --git a/web/pages/admin/config-chat.tsx b/web/pages/admin/config-chat.tsx
index 3593b85ea..c3fb6a2d5 100644
--- a/web/pages/admin/config-chat.tsx
+++ b/web/pages/admin/config-chat.tsx
@@ -1,4 +1,4 @@
-import { Typography } from 'antd';
+import { Col, Row, Typography } from 'antd';
 import React, { ReactElement, useContext, useEffect, useState } from 'react';
 import { TEXTFIELD_TYPE_TEXTAREA } from '../../components/admin/TextField';
 import { TextFieldWithSubmit } from '../../components/admin/TextFieldWithSubmit';
@@ -16,6 +16,7 @@ import {
   API_CHAT_FORBIDDEN_USERNAMES,
   API_CHAT_SUGGESTED_USERNAMES,
   FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED,
+  FIELD_PROPS_ENABLE_CHAT_SLUR_FILTER,
   CHAT_ESTABLISHED_USER_MODE,
   FIELD_PROPS_DISABLE_CHAT,
   postConfigUpdateToAPI,
@@ -23,6 +24,7 @@ import {
   TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES,
   TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES,
   TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE,
+  FIELD_PROPS_ENABLE_SPAM_PROTECTION,
 } from '../../utils/config-constants';
 import { ServerStatusContext } from '../../utils/server-status-context';
 
@@ -43,6 +45,8 @@ export default function ConfigChat() {
     instanceDetails,
     suggestedUsernames,
     chatEstablishedUserMode,
+    chatSpamProtectionEnabled,
+    chatSlurFilterEnabled,
   } = serverConfig;
   const { welcomeMessage } = instanceDetails;
 
@@ -65,6 +69,14 @@ export default function ConfigChat() {
     handleFieldChange({ fieldName: 'chatEstablishedUserMode', value: enabled });
   }
 
+  function handleChatSpamProtectionChange(enabled: boolean) {
+    handleFieldChange({ fieldName: 'chatSpamProtectionEnabled', value: enabled });
+  }
+
+  function handleChatSlurFilterChange(enabled: boolean) {
+    handleFieldChange({ fieldName: 'chatSlurFilterEnabled', value: enabled });
+  }
+
   function resetForbiddenUsernameState() {
     setForbiddenUsernameSaveState(null);
   }
@@ -155,6 +167,8 @@ export default function ConfigChat() {
       suggestedUsernames,
       welcomeMessage,
       chatEstablishedUserMode,
+      chatSpamProtectionEnabled,
+      chatSlurFilterEnabled,
     });
   }, [serverConfig]);
 
@@ -165,60 +179,80 @@ export default function ConfigChat() {
   return (
     <div className="config-server-details-form">
       <Title>Chat Settings</Title>
-      <div className="form-module config-server-details-container">
-        <ToggleSwitch
-          fieldName="chatDisabled"
-          {...FIELD_PROPS_DISABLE_CHAT}
-          checked={!formDataValues.chatDisabled}
-          reversed
-          onChange={handleChatDisableChange}
-        />
-        <ToggleSwitch
-          fieldName="chatJoinMessagesEnabled"
-          {...FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED}
-          checked={formDataValues.chatJoinMessagesEnabled}
-          onChange={handleChatJoinMessagesEnabledChange}
-        />
-        <ToggleSwitch
-          fieldName="chatEstablishedUserMode"
-          {...CHAT_ESTABLISHED_USER_MODE}
-          checked={formDataValues.chatEstablishedUserMode}
-          onChange={handleEstablishedUserModeChange}
-        />
-        <TextFieldWithSubmit
-          fieldName="welcomeMessage"
-          {...TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE}
-          type={TEXTFIELD_TYPE_TEXTAREA}
-          value={formDataValues.welcomeMessage}
-          initialValue={welcomeMessage}
-          onChange={handleFieldChange}
-        />
-        <br />
-        <br />
-        <EditValueArray
-          title={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.label}
-          placeholder={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.placeholder}
-          description={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.tip}
-          values={formDataValues.forbiddenUsernames}
-          handleDeleteIndex={handleDeleteForbiddenUsernameIndex}
-          handleCreateString={handleCreateForbiddenUsername}
-          submitStatus={forbiddenUsernameSaveState}
-        />
-        <br />
-        <br />
-        <EditValueArray
-          title={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.label}
-          placeholder={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.placeholder}
-          description={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.tip}
-          values={formDataValues.suggestedUsernames}
-          handleDeleteIndex={handleDeleteSuggestedUsernameIndex}
-          handleCreateString={handleCreateSuggestedUsername}
-          submitStatus={suggestedUsernameSaveState}
-          continuousStatusMessage={getSuggestedUsernamesLimitWarning(
-            formDataValues.suggestedUsernames.length,
-          )}
-        />
-      </div>
+      <Row gutter={[45, 16]}>
+        <Col md={24} lg={12}>
+          <div className="form-module">
+            <ToggleSwitch
+              fieldName="chatDisabled"
+              {...FIELD_PROPS_DISABLE_CHAT}
+              checked={!formDataValues.chatDisabled}
+              reversed
+              onChange={handleChatDisableChange}
+            />
+            <ToggleSwitch
+              fieldName="chatJoinMessagesEnabled"
+              {...FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED}
+              checked={formDataValues.chatJoinMessagesEnabled}
+              onChange={handleChatJoinMessagesEnabledChange}
+            />
+            <TextFieldWithSubmit
+              fieldName="welcomeMessage"
+              {...TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE}
+              type={TEXTFIELD_TYPE_TEXTAREA}
+              value={formDataValues.welcomeMessage}
+              initialValue={welcomeMessage}
+              onChange={handleFieldChange}
+            />
+            <br />
+            <br />
+            <EditValueArray
+              title={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.label}
+              placeholder={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.placeholder}
+              description={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.tip}
+              values={formDataValues.forbiddenUsernames}
+              handleDeleteIndex={handleDeleteForbiddenUsernameIndex}
+              handleCreateString={handleCreateForbiddenUsername}
+              submitStatus={forbiddenUsernameSaveState}
+            />
+            <br />
+            <br />
+            <EditValueArray
+              title={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.label}
+              placeholder={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.placeholder}
+              description={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.tip}
+              values={formDataValues.suggestedUsernames}
+              handleDeleteIndex={handleDeleteSuggestedUsernameIndex}
+              handleCreateString={handleCreateSuggestedUsername}
+              submitStatus={suggestedUsernameSaveState}
+              continuousStatusMessage={getSuggestedUsernamesLimitWarning(
+                formDataValues.suggestedUsernames.length,
+              )}
+            />
+          </div>
+        </Col>
+        <Col md={24} lg={12}>
+          <div className="form-module">
+            <ToggleSwitch
+              fieldName="chatSpamProtectionEnabled"
+              {...FIELD_PROPS_ENABLE_SPAM_PROTECTION}
+              checked={formDataValues.chatSpamProtectionEnabled}
+              onChange={handleChatSpamProtectionChange}
+            />
+            <ToggleSwitch
+              fieldName="chatEstablishedUserMode"
+              {...CHAT_ESTABLISHED_USER_MODE}
+              checked={formDataValues.chatEstablishedUserMode}
+              onChange={handleEstablishedUserModeChange}
+            />
+            <ToggleSwitch
+              fieldName="chatSlurFilterEnabled"
+              {...FIELD_PROPS_ENABLE_CHAT_SLUR_FILTER}
+              checked={formDataValues.chatSlurFilterEnabled}
+              onChange={handleChatSlurFilterChange}
+            />
+          </div>
+        </Col>
+      </Row>
     </div>
   );
 }
diff --git a/web/types/config-section.ts b/web/types/config-section.ts
index 948a60375..1fec049dd 100644
--- a/web/types/config-section.ts
+++ b/web/types/config-section.ts
@@ -152,6 +152,8 @@ export interface ConfigDetails {
   forbiddenUsernames: string[];
   suggestedUsernames: string[];
   chatDisabled: boolean;
+  chatSpamProtectionEnabled: boolean;
+  chatSlurFilterEnabled: boolean;
   federation: Federation;
   notifications: NotificationsConfig;
   chatJoinMessagesEnabled: boolean;
diff --git a/web/utils/config-constants.tsx b/web/utils/config-constants.tsx
index 4d5f2e22f..d20e6bb19 100644
--- a/web/utils/config-constants.tsx
+++ b/web/utils/config-constants.tsx
@@ -38,6 +38,8 @@ const API_HIDE_VIEWER_COUNT = '/hideviewercount';
 const API_CHAT_DISABLE = '/chat/disable';
 const API_CHAT_JOIN_MESSAGES_ENABLED = '/chat/joinmessagesenabled';
 const API_CHAT_ESTABLISHED_MODE = '/chat/establishedusermode';
+const API_CHAT_SPAM_PROTECTION_ENABLED = '/chat/spamprotectionenabled';
+const API_CHAT_SLUR_FILTER_ENABLED = '/chat/slurfilterenabled';
 const API_DISABLE_SEARCH_INDEXING = '/disablesearchindexing';
 const API_SOCKET_HOST_OVERRIDE = '/sockethostoverride';
 const API_VIDEO_SERVING_ENDPOINT = '/videoservingendpoint';
@@ -258,6 +260,14 @@ export const FIELD_PROPS_DISABLE_CHAT = {
   useSubmit: true,
 };
 
+export const FIELD_PROPS_ENABLE_SPAM_PROTECTION = {
+  apiPath: API_CHAT_SPAM_PROTECTION_ENABLED,
+  configPath: '',
+  label: 'Spam Protection',
+  tip: 'Limits how quickly messages can be sent to prevent spamming.',
+  useSubmit: true,
+};
+
 export const FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED = {
   apiPath: API_CHAT_JOIN_MESSAGES_ENABLED,
   configPath: '',
@@ -266,6 +276,14 @@ export const FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED = {
   useSubmit: true,
 };
 
+export const FIELD_PROPS_ENABLE_CHAT_SLUR_FILTER = {
+  apiPath: API_CHAT_SLUR_FILTER_ENABLED,
+  configPath: '',
+  label: 'Chat language filter',
+  tip: 'Filters out messages that contain offensive language.',
+  useSubmit: true,
+};
+
 export const CHAT_ESTABLISHED_USER_MODE = {
   apiPath: API_CHAT_ESTABLISHED_MODE,
   configPath: '',
diff --git a/web/utils/server-status-context.tsx b/web/utils/server-status-context.tsx
index 2db00eeeb..991cdc625 100644
--- a/web/utils/server-status-context.tsx
+++ b/web/utils/server-status-context.tsx
@@ -69,6 +69,8 @@ const initialServerConfigState: ConfigDetails = {
   forbiddenUsernames: [],
   suggestedUsernames: [],
   chatDisabled: false,
+  chatSpamProtectionEnabled: true,
+  chatSlurFilterEnabled: false,
   chatJoinMessagesEnabled: true,
   chatEstablishedUserMode: false,
   hideViewerCount: false,