From 974ee25b483982176e7405849e1479da668100fd Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 4 Aug 2023 20:31:21 -0700 Subject: [PATCH] Commands: /ban and /op --- README.md | 12 ++--- pkg/banned_users.go | 79 +++++++++++++++++++++------ pkg/commands.go | 128 ++++++++++++++++++++++++++++++++++++++++---- pkg/handlers.go | 13 +++++ 4 files changed, 198 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 560fc71..b1bd96d 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,14 @@ It is very much in the style of the old-school Flash based webcam chat rooms of * WebRTC means peer-to-peer video streaming so cheap on hosting costs! * Simple integration with your existing userbase via signed JWT tokens. * User configurable sound effects to be notified of DMs or users entering/exiting the room. - -Some important features still lacking: - * Operator commands * [x] /kick users - * [x] /nsfw to mark someone's camera - * [ ] /ban users - * [ ] /op users (give temporary mod control) + * [x] /ban users (and /unban, /bans to list) + * [x] /nsfw to tag a user's camera as explicit + * [x] /shutdown to gracefully reboot the server + * [x] /kickall to kick EVERYBODY off the server (e.g., for mandatory front-end reload for new features) + * [x] /op and /deop users (give temporary mod control) + * [x] /help to get in-chat help for moderator commands # Configuration diff --git a/pkg/banned_users.go b/pkg/banned_users.go index 662c4bf..2855f4f 100644 --- a/pkg/banned_users.go +++ b/pkg/banned_users.go @@ -1,28 +1,73 @@ package barertc -import "time" +import ( + "fmt" + "strings" + "sync" + "time" +) /* Functions to handle banned users */ -/* -BanList holds (in memory) knowledge of currently banned users. - -All bans are reset if the chat server is rebooted. Otherwise each ban -comes with a duration - default is 24 hours by the operator can specify -a duration with a ban. If the server is not rebooted, bans will be lifted -after they expire. - -Bans are against usernames and will also block a JWT token from -authenticating if they are currently banned. -*/ -type BanList struct { - Active []Ban -} - // Ban is an entry on the ban list. type Ban struct { Username string ExpiresAt time.Time } -// +// Global storage for banned users in memory. +var ( + banList = map[string]Ban{} + banListMu sync.RWMutex +) + +// BanUser adds a user to the ban list. +func BanUser(username string, duration time.Duration) { + banListMu.Lock() + defer banListMu.Unlock() + banList[username] = Ban{ + Username: username, + ExpiresAt: time.Now().Add(duration), + } +} + +// UnbanUser lifts the ban of a user early. +func UnbanUser(username string) bool { + banListMu.RLock() + defer banListMu.RUnlock() + _, ok := banList[username] + if ok { + delete(banList, username) + } + return ok +} + +// StringifyBannedUsers returns a stringified list of all the current banned users. +func StringifyBannedUsers() string { + var lines = []string{} + banListMu.RLock() + defer banListMu.RUnlock() + for username, ban := range banList { + lines = append(lines, fmt.Sprintf( + "* `%s` banned until %s", + username, + ban.ExpiresAt.Format(time.RFC3339), + )) + } + return strings.Join(lines, "\n") +} + +// IsBanned returns whether the username is currently banned. +func IsBanned(username string) bool { + banListMu.Lock() + defer banListMu.Unlock() + ban, ok := banList[username] + if ok { + // Has the ban expired? + if time.Now().After(ban.ExpiresAt) { + delete(banList, username) + return false + } + } + return ok +} diff --git a/pkg/commands.go b/pkg/commands.go index 2a3262a..59e55ef 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -7,7 +7,9 @@ import ( "time" "git.kirsle.net/apps/barertc/pkg/config" + ourjwt "git.kirsle.net/apps/barertc/pkg/jwt" "git.kirsle.net/apps/barertc/pkg/log" + "github.com/golang-jwt/jwt/v4" "github.com/mattn/go-shellwords" ) @@ -35,6 +37,12 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { case "/ban": s.BanCommand(words, sub) return true + case "/unban": + s.UnbanCommand(words, sub) + return true + case "/bans": + s.BansCommand(words, sub) + return true case "/nsfw": if len(words) == 1 { sub.ChatServer("Usage: `/nsfw username` to add the NSFW flag to their camera.") @@ -58,7 +66,12 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { case "/help": sub.ChatServer(RenderMarkdown("Moderator commands are:\n\n" + "* `/kick ` to kick from chat\n" + + "* `/ban ` to ban from chat (default duration is 24 (hours))\n" + + "* `/unban ` to list the ban on a user\n" + + "* `/bans` to list current banned users and their expiration date\n" + "* `/nsfw ` to mark their camera NSFW\n" + + "* `/op ` to grant operator rights to a user\n" + + "* `/deop ` to remove operator rights from a user\n" + "* `/shutdown` to gracefully shut down (reboot) the chat server\n" + "* `/kickall` to kick EVERYBODY off and force them to log back in\n" + "* `/help` to show this message\n\n" + @@ -72,8 +85,16 @@ func (s *Server) ProcessCommand(sub *Subscriber, msg Message) bool { Message: "The chat server is going down for a reboot NOW!", }) os.Exit(1) + return true case "/kickall": s.KickAllCommand() + return true + case "/op": + s.OpCommand(words, sub) + return true + case "/deop": + s.DeopCommand(words, sub) + return true } } @@ -151,7 +172,7 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) { if len(words) == 1 { sub.ChatServer(RenderMarkdown( "Usage: `/ban username` to remove the user from the chat room for 24 hours (default).\n\n" + - "Set another duration (in hours, fractions supported) like: `/ban username 0.5` for a 30-minute ban.", + "Set another duration (in hours) like: `/ban username 2` for a 2-hour ban.", )) return } @@ -162,27 +183,112 @@ func (s *Server) BanCommand(words []string, sub *Subscriber) { duration = 24 * time.Hour ) if len(words) >= 3 { - if dur, err := strconv.ParseFloat(words[2], 64); err == nil { - if dur < 1 { - duration = time.Duration(dur*60) * time.Second - } else { - duration = time.Duration(dur) * time.Hour - } + if dur, err := strconv.Atoi(words[2]); err == nil { + duration = time.Duration(dur) * time.Hour } } - // TODO: banning, for now it just kicks. - _ = duration + log.Info("Operator %s bans %s for %d hours", sub.Username, username, duration/time.Hour) other, err := s.GetSubscriber(username) if err != nil { sub.ChatServer("/ban: username not found: %s", username) } else { - other.ChatServer("You have been kicked from the chat room by %s", sub.Username) + // Ban them. + BanUser(username, duration) + + other.ChatServer("You have been banned from the chat room by %s. You may come back after %d hours.", sub.Username, duration/time.Hour) other.SendJSON(Message{ Action: ActionKick, }) s.DeleteSubscriber(other) - sub.ChatServer("%s has been kicked from the room", username) + sub.ChatServer("%s has been banned from the room for %d hours.", username, duration/time.Hour) + } +} + +// UnbanCommand handles the `/unban` operator command. +func (s *Server) UnbanCommand(words []string, sub *Subscriber) { + if len(words) == 1 { + sub.ChatServer(RenderMarkdown( + "Usage: `/unban username` to lift the ban on a user and allow them back into the chat room.", + )) + return + } + + // Parse the command. + var username = words[1] + + if UnbanUser(username) { + sub.ChatServer("The ban on %s has been lifted.", username) + } else { + sub.ChatServer("/unban: user %s was not found to be banned. Try `/bans` to see current banned users.", username) + } +} + +// BansCommand handles the `/bans` operator command. +func (s *Server) BansCommand(words []string, sub *Subscriber) { + result := StringifyBannedUsers() + sub.ChatServer( + RenderMarkdown("The listing of banned users currently includes:\n\n" + result), + ) +} + +// OpCommand handles the `/op` operator command. +func (s *Server) OpCommand(words []string, sub *Subscriber) { + if len(words) == 1 { + sub.ChatServer(RenderMarkdown( + "Usage: `/op username` to grant temporary operator rights to a user.", + )) + return + } + + // Parse the command. + var username = words[1] + if other, err := s.GetSubscriber(username); err != nil { + sub.ChatServer("/op: user %s was not found.", username) + } else { + if other.JWTClaims == nil { + other.JWTClaims = &ourjwt.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: username, + }, + } + } + other.JWTClaims.IsAdmin = true + + // Send everyone the Who List. + s.SendWhoList() + + sub.ChatServer("Operator rights have been granted to %s", username) + } +} + +// DeopCommand handles the `/deop` operator command. +func (s *Server) DeopCommand(words []string, sub *Subscriber) { + if len(words) == 1 { + sub.ChatServer(RenderMarkdown( + "Usage: `/deop username` to remove operator rights from a user.", + )) + return + } + + // Parse the command. + var username = words[1] + if other, err := s.GetSubscriber(username); err != nil { + sub.ChatServer("/deop: user %s was not found.", username) + } else { + if other.JWTClaims == nil { + other.JWTClaims = &ourjwt.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: username, + }, + } + } + other.JWTClaims.IsAdmin = false + + // Send everyone the Who List. + s.SendWhoList() + + sub.ChatServer("Operator rights have been taken from %s", username) } } diff --git a/pkg/handlers.go b/pkg/handlers.go index 5c19796..ff28f4e 100644 --- a/pkg/handlers.go +++ b/pkg/handlers.go @@ -71,6 +71,19 @@ func (s *Server) OnLogin(sub *Subscriber, msg Message) { } msg.Username = username + // Is the username currently banned? + if IsBanned(msg.Username) { + sub.ChatServer( + "You are currently banned from entering the chat room. Chat room bans are temporarily and usually last for " + + "24 hours. Please try coming back later.", + ) + sub.SendJSON(Message{ + Action: ActionKick, + }) + s.DeleteSubscriber(sub) + return + } + // Use their username. sub.Username = msg.Username sub.authenticated = true