diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index ee282c7e0..8a66c5a9f 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -103,6 +103,48 @@ func GetDisabledUsers(w http.ResponseWriter, r *http.Request) { controllers.WriteResponse(w, users) } +// UpdateUserModerator will set the moderator status for a user ID. +func UpdateUserModerator(w http.ResponseWriter, r *http.Request) { + type request struct { + UserID string `json:"userId"` + IsModerator bool `json:"isModerator"` + } + + if r.Method != controllers.POST { + controllers.WriteSimpleResponse(w, false, r.Method+" not supported") + return + } + + decoder := json.NewDecoder(r.Body) + var req request + + if err := decoder.Decode(&req); err != nil { + controllers.WriteSimpleResponse(w, false, "") + return + } + + // Update the user object with new moderation access. + if err := user.SetModerator(req.UserID, req.IsModerator); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + + // Update the clients for this user to know about the moderator access change. + if err := chat.SendConnectedClientInfoToUser(req.UserID); err != nil { + log.Debugln(err) + } + + controllers.WriteSimpleResponse(w, true, fmt.Sprintf("%s is moderator: %t", req.UserID, req.IsModerator)) +} + +// GetModerators will return a list of moderator users. +func GetModerators(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + users := user.GetModeratorUsers() + 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") diff --git a/core/chat/chatclient.go b/core/chat/chatclient.go index d371d078c..80315652c 100644 --- a/core/chat/chatclient.go +++ b/core/chat/chatclient.go @@ -66,11 +66,14 @@ var ( ) func (c *Client) sendConnectedClientInfo() { - payload := events.EventPayload{ - "type": events.ConnectedUserInfo, - "user": c.User, + payload := events.ConnectedClientInfo{ + Event: events.Event{ + Type: events.ConnectedUserInfo, + }, + User: c.User, } + payload.SetDefaults() c.sendPayload(payload) } @@ -204,7 +207,7 @@ func (c *Client) startChatRejectionTimeout() { c.sendAction("You are temporarily blocked from sending chat messages due to perceived flooding.") } -func (c *Client) sendPayload(payload events.EventPayload) { +func (c *Client) sendPayload(payload interface{}) { var data []byte data, err := json.Marshal(payload) if err != nil { diff --git a/core/chat/events/connectedClientInfo.go b/core/chat/events/connectedClientInfo.go new file mode 100644 index 000000000..3b04a5423 --- /dev/null +++ b/core/chat/events/connectedClientInfo.go @@ -0,0 +1,9 @@ +package events + +import "github.com/owncast/owncast/core/user" + +// ConnectedClientInfo represents the information about a connected client. +type ConnectedClientInfo struct { + Event + User *user.User `json:"user"` +} diff --git a/core/chat/server.go b/core/chat/server.go index 37c7d9fd9..08c0317c9 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -2,6 +2,7 @@ package chat import ( "encoding/json" + "fmt" "net/http" "sync" "time" @@ -279,6 +280,49 @@ func (s *Server) DisconnectUser(userID string) { } } +// SendConnectedClientInfoToUser will find all the connected clients assigned to a user +// and re-send each the connected client info. +func SendConnectedClientInfoToUser(userID string) error { + clients, err := GetClientsForUser(userID) + if err != nil { + return err + } + + // Get an updated reference to the user. + user := user.GetUserByID(userID) + if user == nil { + return fmt.Errorf("user not found") + } + + if err != nil { + return err + } + + for _, client := range clients { + // Update the client's reference to its user. + client.User = user + // Send the update to the client. + client.sendConnectedClientInfo() + } + + return nil +} + +// SendActionToUser will send system action text to all connected clients +// assigned to a user ID. +func SendActionToUser(userID string, text string) error { + clients, err := GetClientsForUser(userID) + if err != nil { + return err + } + + for _, client := range clients { + _server.sendActionToClient(client, text) + } + + return nil +} + func (s *Server) eventReceived(event chatClientEvent) { var typecheck map[string]interface{} if err := json.Unmarshal(event.data, &typecheck); err != nil { @@ -342,6 +386,9 @@ func (s *Server) sendActionToClient(c *Client, message string) { MessageEvent: events.MessageEvent{ Body: message, }, + Event: events.Event{ + Type: events.ChatActionSent, + }, } clientMessage.SetDefaults() clientMessage.RenderBody() diff --git a/core/user/user.go b/core/user/user.go index 62f92740a..3216828ce 100644 --- a/core/user/user.go +++ b/core/user/user.go @@ -16,6 +16,8 @@ import ( var _datastore *data.Datastore +const moderatorScopeKey = "MODERATOR" + // User represents a single chat user. type User struct { ID string `json:"id"` @@ -26,6 +28,7 @@ type User struct { DisabledAt *time.Time `json:"disabledAt,omitempty"` PreviousNames []string `json:"previousNames"` NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` + Scopes []string `json:"scopes"` } // IsEnabled will return if this single user is enabled. @@ -33,6 +36,12 @@ func (u *User) IsEnabled() bool { return u.DisabledAt == nil } +// IsModerator will return if the user has moderation privileges. +func (u *User) IsModerator() bool { + _, hasModerationScope := utils.FindInSlice(u.Scopes, moderatorScopeKey) + return hasModerationScope +} + // SetupUsers will perform the initial initialization of the user package. func SetupUsers() { _datastore = data.GetDatastore() @@ -47,7 +56,7 @@ func CreateAnonymousUser(username string) (*User, error) { return nil, err } - var displayName = username + displayName := username if displayName == "" { displayName = utils.GeneratePhrase() } @@ -75,7 +84,6 @@ func ChangeUsername(userID string, username string) { defer _datastore.DbLock.Unlock() tx, err := _datastore.DB.Begin() - if err != nil { log.Debugln(err) } @@ -86,7 +94,6 @@ func ChangeUsername(userID string, username string) { }() stmt, err := tx.Prepare("UPDATE users SET display_name = ?, previous_names = previous_names || ?, namechanged_at = ? WHERE id = ?") - if err != nil { log.Debugln(err) } @@ -115,7 +122,6 @@ func create(user *User) error { }() 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) } @@ -129,7 +135,7 @@ func create(user *User) error { return tx.Commit() } -// SetEnabled will will set the enabled flag on a single user assigned to userID. +// SetEnabled will set the enabled status of a single user by ID. func SetEnabled(userID string, enabled bool) error { _datastore.DbLock.Lock() defer _datastore.DbLock.Unlock() @@ -166,18 +172,82 @@ 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 = ?" + query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE access_token = ?" row := _datastore.DB.QueryRow(query, token) return getUserFromRow(row) } +// SetModerator will add or remove moderator status for a single user by ID. +func SetModerator(userID string, isModerator bool) error { + if isModerator { + return addScopeToUser(userID, moderatorScopeKey) + } + + return removeScopeFromUser(userID, moderatorScopeKey) +} + +func addScopeToUser(userID string, scope string) error { + u := GetUserByID(userID) + scopesString := u.Scopes + scopes := utils.StringSliceToMap(scopesString) + scopes[scope] = true + + scopesSlice := utils.StringMapKeys(scopes) + + return setScopesOnUser(userID, scopesSlice) +} + +func removeScopeFromUser(userID string, scope string) error { + u := GetUserByID(userID) + scopesString := u.Scopes + scopes := utils.StringSliceToMap(scopesString) + delete(scopes, scope) + + scopesSlice := utils.StringMapKeys(scopes) + + return setScopesOnUser(userID, scopesSlice) +} + +func setScopesOnUser(userID string, scopes []string) error { + _datastore.DbLock.Lock() + defer _datastore.DbLock.Unlock() + + tx, err := _datastore.DB.Begin() + if err != nil { + return err + } + + defer tx.Rollback() //nolint + + scopesSliceString := strings.TrimSpace(strings.Join(scopes, ",")) + stmt, err := tx.Prepare("UPDATE users SET scopes=? WHERE id IS ?") + if err != nil { + return err + } + + defer stmt.Close() + + var val *string + if scopesSliceString == "" { + val = nil + } else { + val = &scopesSliceString + } + + if _, err := stmt.Exec(val, userID); err != nil { + return err + } + + return tx.Commit() +} + // 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 = ?" + query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE id = ?" row := _datastore.DB.QueryRow(query, id) if row == nil { log.Errorln(row) @@ -188,7 +258,7 @@ func GetUserByID(id string) *User { // 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'" + query := "SELECT id, display_name, scopes, 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 { @@ -206,6 +276,35 @@ func GetDisabledUsers() []*User { return users } +// GetModeratorUsers will return a list of users with moderator access. +func GetModeratorUsers() []*User { + query := `SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM ( + WITH RECURSIVE split(id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope, rest) AS ( + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, '', scopes || ',' FROM users + UNION ALL + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, + substr(rest, 0, instr(rest, ',')), + substr(rest, instr(rest, ',')+1) + FROM split + WHERE rest <> '') + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope + FROM split + WHERE scope <> '' + ORDER BY created_at + ) AS token WHERE token.scope = ?` + + rows, err := _datastore.DB.Query(query, moderatorScopeKey) + if err != nil { + log.Errorln(err) + return nil + } + defer rows.Close() + + users := getUsersFromRows(rows) + + return users +} + func getUsersFromRows(rows *sql.Rows) []*User { users := make([]*User, 0) @@ -217,12 +316,18 @@ func getUsersFromRows(rows *sql.Rows) []*User { var disabledAt *time.Time var previousUsernames string var userNameChangedAt *time.Time + var scopesString *string - if err := rows.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { + if err := rows.Scan(&id, &displayName, &scopesString, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { log.Errorln("error creating collection of users from results", err) return nil } + var scopes []string + if scopesString != nil { + scopes = strings.Split(*scopesString, ",") + } + user := &User{ ID: id, DisplayName: displayName, @@ -231,6 +336,7 @@ func getUsersFromRows(rows *sql.Rows) []*User { DisabledAt: disabledAt, PreviousNames: strings.Split(previousUsernames, ","), NameChangedAt: userNameChangedAt, + Scopes: scopes, } users = append(users, user) } @@ -250,11 +356,17 @@ func getUserFromRow(row *sql.Row) *User { var disabledAt *time.Time var previousUsernames string var userNameChangedAt *time.Time + var scopesString *string - if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { + if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt, &scopesString); err != nil { return nil } + var scopes []string + if scopesString != nil { + scopes = strings.Split(*scopesString, ",") + } + return &User{ ID: id, DisplayName: displayName, @@ -263,5 +375,6 @@ func getUserFromRow(row *sql.Row) *User { DisabledAt: disabledAt, PreviousNames: strings.Split(previousUsernames, ","), NameChangedAt: userNameChangedAt, + Scopes: scopes, } } diff --git a/router/middleware/auth.go b/router/middleware/auth.go index 2b0db88c9..7f1a10832 100644 --- a/router/middleware/auth.go +++ b/router/middleware/auth.go @@ -111,3 +111,24 @@ func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc { handler(w, r) }) } + +// RequireUserModerationScopeAccesstoken will validate a provided user's access token and make sure the associated user is enabled +// and has "MODERATOR" scope assigned to the user. +func RequireUserModerationScopeAccesstoken(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() || !user.IsModerator() { + accessDenied(w) + return + } + + handler(w, r) + }) +} diff --git a/router/router.go b/router/router.go index 2a71f97e8..4612c728b 100644 --- a/router/router.go +++ b/router/router.go @@ -110,6 +110,12 @@ func Start() error { // Get a list of disabled users http.HandleFunc("/api/admin/chat/users/disabled", middleware.RequireAdminAuth(admin.GetDisabledUsers)) + // Set moderator status for a user + http.HandleFunc("/api/admin/chat/users/setmoderator", middleware.RequireAdminAuth(admin.UpdateUserModerator)) + + // Get a list of moderator users + http.HandleFunc("/api/admin/chat/users/moderators", middleware.RequireAdminAuth(admin.GetModerators)) + // Update config values // Change the current streaming key in memory @@ -232,6 +238,14 @@ func Start() error { // set custom style css http.HandleFunc("/api/admin/config/customstyles", middleware.RequireAdminAuth(admin.SetCustomStyles)) + // Inline chat moderation actions + + // Update chat message visibility + http.HandleFunc("/api/chat/updatemessagevisibility", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateMessageVisibility)) + + // Enable/disable a user + http.HandleFunc("/api/chat/users/setenabled", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateUserEnabled)) + // websocket http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { chat.HandleClientConnection(w, r) diff --git a/test/automated/api/chatusers.test.js b/test/automated/api/chatusers.test.js index 6f935c54b..42ac9a818 100644 --- a/test/automated/api/chatusers.test.js +++ b/test/automated/api/chatusers.test.js @@ -80,6 +80,26 @@ test('verify user is enabled', async (done) => { done(); }); +test('can set the user as moderator', async (done) => { + await request + .post('/api/admin/chat/users/setmoderator') + .send({ userId: userId, isModerator: true }) + .auth('admin', 'abc123') + .expect(200); + done(); +}); + +test('verify user is a moderator', async (done) => { + const response = await request + .get('/api/admin/chat/users/moderators') + .auth('admin', 'abc123') + .expect(200); + const tokenCheck = response.body.filter((user) => user.id === userId); + expect(tokenCheck).toHaveLength(1); + + done(); +}); + test('verify user list is populated', async (done) => { const ws = new WebSocket( `ws://localhost:8080/ws?accessToken=${accessToken}`, diff --git a/test/automated/api/run.sh b/test/automated/api/run.sh index 1f19c79d7..0e733be61 100755 --- a/test/automated/api/run.sh +++ b/test/automated/api/run.sh @@ -37,7 +37,7 @@ function finish { trap finish EXIT echo "Waiting..." -sleep 13 +sleep 15 # Run the tests against the instance. npm test \ No newline at end of file diff --git a/test/automated/browser/tests/chat.js b/test/automated/browser/tests/chat.js index 23e9ac725..c9dfc58d2 100644 --- a/test/automated/browser/tests/chat.js +++ b/test/automated/browser/tests/chat.js @@ -1,4 +1,10 @@ -async function interactiveChatTest(browser, page, newName, chatMessage, device) { +async function interactiveChatTest( + browser, + page, + newName, + chatMessage, + device +) { it('should have the chat input', async () => { await page.waitForSelector('#message-input'); }); @@ -16,25 +22,29 @@ async function interactiveChatTest(browser, page, newName, chatMessage, device) it('should allow changing the username on ' + device, async () => { await page.waitForSelector('#username-display'); - await page.evaluate(()=>document.querySelector('#username-display').click()) - + await page.evaluate(() => + document.querySelector('#username-display').click() + ); await page.waitForSelector('#username-change-input'); - await page.evaluate(()=>document.querySelector('#username-change-input').click()) - await page.type('#username-change-input', 'a new name'); - - await page.evaluate(()=>document.querySelector('#username-change-input').click()) - await page.type('#username-change-input', newName); - + await page.evaluate(() => + document.querySelector('#username-change-input').click() + ); + await page.evaluate(() => + document.querySelector('#username-change-input').click() + ); await page.waitForSelector('#button-update-username'); - await page.evaluate(()=>document.querySelector('#button-update-username').click()) + + await page.evaluate(() => + document.querySelector('#button-update-username').click() + ); }); it('should allow typing a chat message', async () => { await page.waitForSelector('#message-input'); - await page.evaluate(()=>document.querySelector('#message-input').click()) + await page.evaluate(() => document.querySelector('#message-input').click()); await page.waitForTimeout(1000); - await page.focus('#message-input') - await page.keyboard.type(chatMessage) + await page.focus('#message-input'); + await page.keyboard.type(chatMessage); page.keyboard.press('Enter'); }); } diff --git a/utils/utils.go b/utils/utils.go index c9dba2888..3d2636c6a 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -251,7 +251,7 @@ func VerifyFFMpegPath(path string) error { } mode := stat.Mode() - //source: https://stackoverflow.com/a/60128480 + // source: https://stackoverflow.com/a/60128480 if mode&0111 == 0 { return errors.New("ffmpeg path is not executable") } @@ -270,7 +270,7 @@ func CleanupDirectory(path string) { } } -// FindInSlice will return the index if a string is located in a slice of strings. +// FindInSlice will return if a string is in a slice, and the index of that string. func FindInSlice(slice []string, val string) (int, bool) { for i, item := range slice { if item == val { @@ -280,6 +280,27 @@ func FindInSlice(slice []string, val string) (int, bool) { return -1, false } +// StringSliceToMap is a convinience function to convert a slice of strings into +// a map using the string as the key. +func StringSliceToMap(stringSlice []string) map[string]interface{} { + stringMap := map[string]interface{}{} + + for _, str := range stringSlice { + stringMap[str] = true + } + + return stringMap +} + +// StringMapKeys returns a slice of string keys from a map. +func StringMapKeys(stringMap map[string]interface{}) []string { + stringSlice := []string{} + for k := range stringMap { + stringSlice = append(stringSlice, k) + } + return stringSlice +} + // 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 { diff --git a/webroot/img/menu-filled.svg b/webroot/img/menu-filled.svg new file mode 100644 index 000000000..3a997b411 --- /dev/null +++ b/webroot/img/menu-filled.svg @@ -0,0 +1,41 @@ + + diff --git a/webroot/img/menu-vert.svg b/webroot/img/menu-vert.svg new file mode 100644 index 000000000..ab273376a --- /dev/null +++ b/webroot/img/menu-vert.svg @@ -0,0 +1 @@ + diff --git a/webroot/img/menu.svg b/webroot/img/menu.svg new file mode 100644 index 000000000..38bcba04d --- /dev/null +++ b/webroot/img/menu.svg @@ -0,0 +1 @@ + diff --git a/webroot/js/app.js b/webroot/js/app.js index 2ed3d158e..73d2a79eb 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -51,6 +51,7 @@ import { URL_VIEWER_PING, WIDTH_SINGLE_COL, } from './utils/constants.js'; +import { checkIsModerator } from './utils/chat.js'; export default class App extends Component { constructor(props, context) { @@ -67,6 +68,8 @@ export default class App extends Component { chatInputEnabled: false, // chat input box state accessToken: null, username: getLocalStorage(KEY_USERNAME), + isModerator: false, + isRegistering: false, touchKeyboardActive: false, @@ -558,7 +561,10 @@ export default class App extends Component { const { user } = e; const { displayName } = user; - this.setState({ username: displayName }); + this.setState({ + username: displayName, + isModerator: checkIsModerator(e), + }); } } @@ -627,6 +633,7 @@ export default class App extends Component { configData, displayChatPanel, canChat, + isModerator, isPlaying, orientation, @@ -769,6 +776,7 @@ export default class App extends Component { > <${UsernameForm} username=${username} + isModerator=${isModerator} onUsernameChange=${this.handleUsernameChange} onFocus=${this.handleFormFocus} onBlur=${this.handleFormBlur} diff --git a/webroot/js/components/chat/chat-message-view.js b/webroot/js/components/chat/chat-message-view.js index 4eff92ab8..b9941b8ff 100644 --- a/webroot/js/components/chat/chat-message-view.js +++ b/webroot/js/components/chat/chat-message-view.js @@ -10,12 +10,14 @@ import { import { convertToText } from '../../utils/chat.js'; import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js'; import { getDiffInDaysFromNow } from '../../utils/helpers.js'; +import ModeratorActions from './moderator-actions.js'; export default class ChatMessageView extends Component { constructor(props) { super(props); this.state = { formattedMessage: '', + moderatorMenuOpen: false, }; } @@ -37,11 +39,14 @@ export default class ChatMessageView extends Component { }); } } - render() { - const { message } = this.props; + const { message, isModerator, accessToken } = this.props; const { user, timestamp } = message; - const { displayName, displayColor, createdAt } = user; + const { displayName, displayColor, createdAt, + isModerator: isAuthorModerator, + } = user; + + const isMessageModeratable = isModerator && message.type === SOCKET_MESSAGE_TYPES.CHAT; const { formattedMessage } = this.state; if (!formattedMessage) { @@ -61,8 +66,8 @@ export default class ChatMessageView extends Component { ? { backgroundColor: '#667eea' } : { backgroundColor: messageBubbleColorForHue(displayColor) }; const messageClassString = isSystemMessage - ? getSystemMessageClassString() - : getChatMessageClassString(); + ? 'message flex flex-row items-start p-4 m-2 rounded-lg shadow-l border-solid border-indigo-700 border-2 border-opacity-60 text-l' + : `message relative flex flex-row items-start p-3 m-3 rounded-lg shadow-s text-sm ${isMessageModeratable ? 'moderatable' : ''}`; return html`