using Fleck; using SharpChat.Commands; using SharpChat.Config; using SharpChat.Events; using SharpChat.EventStorage; using SharpChat.Misuzu; using SharpChat.Packet; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace SharpChat { public class SockChatServer : IDisposable { public const ushort DEFAULT_PORT = 6770; public const int DEFAULT_MSG_LENGTH_MAX = 5000; public const int DEFAULT_MAX_CONNECTIONS = 5; public const int DEFAULT_FLOOD_KICK_LENGTH = 30; public bool IsDisposed { get; private set; } public static ChatUser Bot { get; } = new ChatUser { UserId = -1, Username = "ChatBot", Rank = 0, Colour = new ChatColour(), }; public IWebSocketServer Server { get; } public ChatContext Context { get; } private readonly HttpClient HttpClient; private readonly MisuzuClient Misuzu; private readonly CachedValue MaxMessageLength; private readonly CachedValue MaxConnections; private readonly CachedValue FloodKickLength; private List Commands { get; } = new(); private bool IsShuttingDown = false; private ChatChannel DefaultChannel { get; set; } public SockChatServer(HttpClient httpClient, MisuzuClient msz, IEventStorage evtStore, IConfig config) { Logger.Write("Initialising Sock Chat server..."); HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); Misuzu = msz ?? throw new ArgumentNullException(nameof(msz)); MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX); MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS); FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH); Context = new ChatContext(evtStore); string[] channelNames = config.ReadValue("channels", new[] { "lounge" }); foreach(string channelName in channelNames) { ChatChannel channelInfo = new(channelName); IConfig channelCfg = config.ScopeTo($"channels:{channelName}"); string tmp; tmp = channelCfg.SafeReadValue("name", string.Empty); if(!string.IsNullOrWhiteSpace(tmp)) channelInfo.Name = tmp; channelInfo.Password = channelCfg.SafeReadValue("password", string.Empty); channelInfo.Rank = channelCfg.SafeReadValue("minRank", 0); Context.Channels.Add(channelInfo); DefaultChannel ??= channelInfo; } Commands.AddRange(new IChatCommand[] { new AFKCommand(), new NickCommand(), new WhisperCommand(), new ActionCommand(), new WhoCommand(), new JoinChannelCommand(), new CreateChannelCommand(), new DeleteChannelCommand(), new PasswordChannelCommand(), new RankChannelCommand(), new BroadcastCommand(), new DeleteMessageCommand(), new KickBanCommand(msz), new PardonUserCommand(msz), new PardonAddressCommand(msz), new BanListCommand(msz), new SilenceApplyCommand(), new SilenceRevokeCommand(), new RemoteAddressCommand(), }); ushort port = config.SafeReadValue("port", DEFAULT_PORT); Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}"); } public void Listen(ManualResetEvent waitHandle) { if(waitHandle != null) Commands.Add(new ShutdownRestartCommand(waitHandle, () => !IsShuttingDown && (IsShuttingDown = true))); Server.Start(sock => { if(IsShuttingDown || IsDisposed) { sock.Close(1013); return; } sock.OnOpen = () => OnOpen(sock); sock.OnClose = () => OnClose(sock); sock.OnError = err => OnError(sock, err); sock.OnMessage = msg => OnMessage(sock, msg); }); Logger.Write("Listening..."); } private void OnOpen(IWebSocketConnection conn) { Logger.Write($"Connection opened from {conn.ConnectionInfo.ClientIpAddress}:{conn.ConnectionInfo.ClientPort}"); lock(Context.SessionsAccess) { if(!Context.Sessions.Any(x => x.Connection == conn)) Context.Sessions.Add(new ChatUserSession(conn)); } Context.Update(); } private void OnClose(IWebSocketConnection conn) { Logger.Write($"Connection closed from {conn.ConnectionInfo.ClientIpAddress}:{conn.ConnectionInfo.ClientPort}"); ChatUserSession sess; lock(Context.SessionsAccess) sess = Context.GetSession(conn); // Remove connection from user if(sess?.User != null) { // RemoveConnection sets conn.User to null so we must grab a local copy. ChatUser user = sess.User; user.RemoveSession(sess); if(!user.HasSessions) Context.UserLeave(null, user); } // Update context Context.Update(); // Remove connection from server lock(Context.SessionsAccess) Context.Sessions.Remove(sess); sess?.Dispose(); } private void OnError(IWebSocketConnection conn, Exception ex) { string sessId; lock(Context.SessionsAccess) { ChatUserSession sess = Context.GetSession(conn); sessId = sess?.Id ?? new string('0', ChatUserSession.ID_LENGTH); } Logger.Write($"[{sessId} {conn.ConnectionInfo.ClientIpAddress}] {ex}"); Context.Update(); } private readonly object BumpAccess = new(); private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1); private DateTimeOffset LastBump = DateTimeOffset.MinValue; private void OnMessage(IWebSocketConnection conn, string msg) { Context.Update(); ChatUserSession sess; lock(Context.SessionsAccess) sess = Context.GetSession(conn); if(sess == null) { conn.Close(); return; } if(sess.User is not null && sess.User.HasFloodProtection) { sess.User.RateLimiter.AddTimePoint(); if(sess.User.RateLimiter.State == ChatRateLimitState.Kick) { Task.Run(async () => { TimeSpan duration = TimeSpan.FromSeconds(FloodKickLength); await Misuzu.CreateBanAsync( sess.User.UserId.ToString(), sess.RemoteAddress.ToString(), string.Empty, "::1", duration, "Kicked from chat for flood protection." ); Context.BanUser(sess.User, duration, UserDisconnectReason.Flood); }).Wait(); return; } else if(sess.User.RateLimiter.State == ChatRateLimitState.Warning) sess.User.Send(new FloodWarningPacket()); } string[] args = msg.Split('\t'); if(args.Length < 1) return; switch(args[0]) { case "0": if(!int.TryParse(args[1], out int pTime)) break; sess.BumpPing(); sess.Send(new PongPacket(sess.LastPing)); lock(BumpAccess) { if(LastBump < DateTimeOffset.UtcNow - BumpInterval) { (string, string)[] bumpList; lock(Context.UsersAccess) bumpList = Context.Users .Where(u => u.HasSessions && u.Status == ChatUserStatus.Online) .Select(u => (u.UserId.ToString(), u.RemoteAddresses.FirstOrDefault()?.ToString() ?? string.Empty)) .ToArray(); if(bumpList.Any()) Task.Run(async () => { await Misuzu.BumpUsersOnlineAsync(bumpList); }).Wait(); LastBump = DateTimeOffset.UtcNow; } } break; case "1": if(sess.User != null) break; string authMethod = args.ElementAtOrDefault(1); if(string.IsNullOrWhiteSpace(authMethod)) { sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); sess.Dispose(); break; } string authToken = args.ElementAtOrDefault(2); if(string.IsNullOrWhiteSpace(authToken)) { sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); sess.Dispose(); break; } if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) { string[] tokenParts = authToken.Split(':', 2); authMethod = tokenParts[0]; authToken = tokenParts[1]; } Task.Run(async () => { MisuzuAuthInfo fai; string ipAddr = sess.RemoteAddress.ToString(); try { fai = await Misuzu.AuthVerifyAsync(authMethod, authToken, ipAddr); } catch(Exception ex) { Logger.Write($"<{sess.Id}> Failed to authenticate: {ex}"); sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); sess.Dispose(); #if DEBUG throw; #else return; #endif } if(!fai.Success) { Logger.Debug($"<{sess.Id}> Auth fail: {fai.Reason}"); sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); sess.Dispose(); return; } MisuzuBanInfo fbi; try { fbi = await Misuzu.CheckBanAsync(fai.UserId.ToString(), ipAddr); } catch(Exception ex) { Logger.Write($"<{sess.Id}> Failed auth ban check: {ex}"); sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); sess.Dispose(); #if DEBUG throw; #else return; #endif } if(fbi.IsBanned && !fbi.HasExpired) { Logger.Write($"<{sess.Id}> User is banned."); sess.Send(new AuthFailPacket(AuthFailReason.Banned, fbi)); sess.Dispose(); return; } lock(Context.UsersAccess) { ChatUser aUser = Context.Users.FirstOrDefault(u => u.UserId == fai.UserId); if(aUser == null) aUser = new ChatUser(fai); else { aUser.ApplyAuth(fai); aUser.Channel?.Send(new UserUpdatePacket(aUser)); } // Enforce a maximum amount of connections per user if(aUser.SessionCount >= MaxConnections) { sess.Send(new AuthFailPacket(AuthFailReason.MaxSessions)); sess.Dispose(); return; } // Bumping the ping to prevent upgrading sess.BumpPing(); aUser.AddSession(sess); sess.Send(new LegacyCommandResponse(LCR.WELCOME, false, $"Welcome to Flashii Chat, {aUser.Username}!")); if(File.Exists("welcome.txt")) { IEnumerable lines = File.ReadAllLines("welcome.txt").Where(x => !string.IsNullOrWhiteSpace(x)); string line = lines.ElementAtOrDefault(RNG.Next(lines.Count())); if(!string.IsNullOrWhiteSpace(line)) sess.Send(new LegacyCommandResponse(LCR.WELCOME, false, line)); } Context.HandleJoin(aUser, DefaultChannel, sess, MaxMessageLength); } }).Wait(); break; case "2": if(args.Length < 3) break; ChatUser mUser = sess.User; // No longer concats everything after index 1 with \t, no previous implementation did that either string messageText = args.ElementAtOrDefault(2); if(mUser == null || !mUser.Can(ChatUserPermissions.SendMessage) || string.IsNullOrWhiteSpace(messageText)) break; // Extra validation step, not necessary at all but enforces proper formatting in SCv1. if(!long.TryParse(args[1], out long mUserId) || mUser.UserId != mUserId) break; ChatChannel mChannel = mUser.CurrentChannel; if(mChannel == null || !mUser.InChannel(mChannel) || (mUser.IsSilenced && !mUser.Can(ChatUserPermissions.SilenceUser))) break; if(mUser.Status != ChatUserStatus.Online) { mUser.Status = ChatUserStatus.Online; mChannel.Send(new UserUpdatePacket(mUser)); } int maxMsgLength = MaxMessageLength; if(messageText.Length > maxMsgLength) messageText = messageText[..maxMsgLength]; messageText = messageText.Trim(); #if DEBUG Logger.Write($"<{sess.Id} {mUser.Username}> {messageText}"); #endif IChatMessage message = null; if(messageText.StartsWith("/")) { ChatCommandContext context = new(messageText, Context, mUser, sess, mChannel); IChatCommand command = null; foreach(IChatCommand cmd in Commands) if(cmd.IsMatch(context)) { command = cmd; break; } if(command != null) { if(command is ActionCommand actionCommand) message = actionCommand.ActionDispatch(context); else { command.Dispatch(context); break; } } } message ??= new ChatMessage { Target = mChannel, TargetName = mChannel.TargetName, DateTime = DateTimeOffset.UtcNow, Sender = mUser, Text = messageText, }; lock(Context.EventsAccess) { Context.Events.AddEvent(message); mChannel.Send(new ChatMessageAddPacket(message)); } break; } } ~SockChatServer() { DoDispose(); } public void Dispose() { DoDispose(); GC.SuppressFinalize(this); } private void DoDispose() { if(IsDisposed) return; IsDisposed = true; lock(Context.SessionsAccess) foreach(ChatUserSession sess in Context.Sessions) sess.Dispose(); Server?.Dispose(); HttpClient?.Dispose(); } } }