using Microsoft.Extensions.Logging; using SharpChat.Auth; using SharpChat.Bans; using SharpChat.Channels; using SharpChat.Configuration; using SharpChat.Connections; using SharpChat.Events; using SharpChat.Messages; using SharpChat.Sessions; using SharpChat.Snowflake; using SharpChat.SockChat; using SharpChat.SockChat.S2CPackets; using SharpChat.Storage; using SharpChat.Users; using System.Dynamic; using System.Net; using ZLogger; namespace SharpChat; public class Context { public const int DEFAULT_MSG_LENGTH_MAX = 5000; public const int DEFAULT_MAX_CONNECTIONS = 5; public const int DEFAULT_FLOOD_KICK_LENGTH = 30; public const int DEFAULT_FLOOD_KICK_EXEMPT_RANK = 9; public readonly SemaphoreSlim ContextAccess = new(1, 1); public ILoggerFactory LoggerFactory { get; } public Config Config { get; } public MessageStorage Messages { get; } public AuthClient Auth { get; } public BansClient Bans { get; } public CachedValue<int> MaxMessageLength { get; } public CachedValue<int> MaxConnections { get; } public CachedValue<int> FloodKickLength { get; } public CachedValue<int> FloodKickExemptRank { get; } private readonly ILogger Logger; public SnowflakeGenerator SnowflakeGenerator { get; } = new(); public RandomSnowflake RandomSnowflake { get; } public UsersContext Users { get; } = new(); public SessionsContext Sessions { get; } public ChannelsContext Channels { get; } public ChannelsUsersContext ChannelsUsers { get; } public Dictionary<string, RateLimiter> UserRateLimiters { get; } = []; public Context( ILoggerFactory loggerFactory, Config config, StorageBackend storage, AuthClient authClient, BansClient bansClient ) { LoggerFactory = loggerFactory; Logger = loggerFactory.CreateLogger("ctx"); Config = config; Messages = storage.CreateMessageStorage(); Auth = authClient; Bans = bansClient; RandomSnowflake = new(SnowflakeGenerator); Sessions = new(loggerFactory, RandomSnowflake); Channels = new(RandomSnowflake); ChannelsUsers = new(Channels, Users); Logger.ZLogDebug($"Reading cached config values..."); MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX); MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS); FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH); FloodKickExemptRank = config.ReadCached("floodKickExemptRank", DEFAULT_FLOOD_KICK_EXEMPT_RANK); Logger.ZLogDebug($"Creating channel list..."); string[] channelNames = config.ReadValue<string[]>("channels") ?? ["lounge"]; if(channelNames is not null) foreach(string channelName in channelNames) { Config channelCfg = config.ScopeTo($"channels:{channelName}"); string name = channelCfg.SafeReadValue("name", string.Empty)!; if(string.IsNullOrWhiteSpace(name)) name = channelName; Channels.CreateChannel( name, channelCfg.SafeReadValue("password", string.Empty)!, rank: channelCfg.SafeReadValue("minRank", 0) ); } } public async Task DispatchEvent(ChatEvent eventInfo) { if(eventInfo is MessageCreateEvent mce) { if(mce.IsBroadcast) { await Send(new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.BROADCAST, false, mce.MessageText)); } else if(mce.IsPrivate) { // The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed // e.g. nook sees @Arysil and Arysil sees @nook // this entire routine is garbage, channels should probably in the db if(!mce.ChannelName.StartsWith('@')) return; IEnumerable<string> uids = mce.ChannelName[1..].Split('-', 3).Select(u => (long.TryParse(u, out long up) ? up : -1).ToString()); if(uids.Count() != 2) return; IEnumerable<User> users = Users.GetUsers(uids); User? target = users.FirstOrDefault(u => mce.SenderId.Equals(u.UserId, StringComparison.Ordinal)); if(target == null) return; foreach(User user in users) await SendTo(user, new ChatMessageAddS2CPacket( mce.MessageId, DateTimeOffset.Now, mce.SenderId, mce.SenderId == user.UserId ? $"{target.GetLegacyName()} {mce.MessageText}" : mce.MessageText, mce.IsAction, true )); } else { Channel? channel = Channels.GetChannel(mce.ChannelName); if(channel is not null) await SendTo(channel, new ChatMessageAddS2CPacket( mce.MessageId, DateTimeOffset.Now, mce.SenderId, mce.MessageText, mce.IsAction, false )); } dynamic data = new ExpandoObject(); data.text = mce.MessageText; if(mce.IsAction) data.act = true; await Messages.LogMessage(mce.MessageId, "msg:add", mce.ChannelName, mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms, data); return; } } public async Task Update() { foreach(Session session in Sessions.GetTimedOutSessions()) { session.Logger.ZLogInformation($"Nuking connection associated with user #{session.UserId}"); session.Connection.Close(ConnectionCloseReason.TimeOut); Sessions.DestroySession(session); } foreach(User user in Users.GetUsers()) if(Sessions.CountActiveSessions(user) < 1) { Logger.ZLogInformation($"Timing out user {user.UserId} (no more connections)."); await HandleDisconnect(user, UserDisconnectS2CPacket.Reason.TimeOut); } } public async Task SafeUpdate() { ContextAccess.Wait(); try { await Update(); } finally { ContextAccess.Release(); } } public async Task UpdateUser( User user, string? userName = null, string? nickName = null, ColourInheritable? colour = null, UserStatus? status = null, string? statusText = null, int? rank = null, UserPermissions? perms = null, bool silent = false ) { string previousName = user.GetLegacyName(); UserDiff diff = Users.UpdateUser( user, userName, colour, rank, perms, nickName, status, statusText ); if(diff.Changed) { string currentName = user.GetLegacyNameWithStatus(); if(!silent && diff.Nick.Changed) await SendToUserChannels(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.NICKNAME_CHANGE, false, previousName, currentName)); await SendToUserChannels(user, new UserUpdateS2CPacket(diff.Id, currentName, diff.Colour.After, diff.Rank.After, diff.Permissions.After)); } } public async Task BanUser(User user, TimeSpan duration, UserDisconnectS2CPacket.Reason reason = UserDisconnectS2CPacket.Reason.Kicked) { if(duration > TimeSpan.Zero) { DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration; await SendTo(user, new ForceDisconnectS2CPacket(expires)); } else await SendTo(user, new ForceDisconnectS2CPacket()); foreach(SockChatConnection conn in Sessions.GetConnections<SockChatConnection>(user)) { conn.Close(ConnectionCloseReason.Unauthorized); Sessions.DestroySession(conn); } await Update(); await HandleDisconnect(user, reason); } public async Task HandleDisconnect(User user, UserDisconnectS2CPacket.Reason reason = UserDisconnectS2CPacket.Reason.Leave) { await UpdateUser(user, status: UserStatus.Offline); Users.RemoveUser(user); foreach(Channel chan in ChannelsUsers.GetUserChannels(user)) { ChannelsUsers.RemoveChannelUser(chan, user); long msgId = RandomSnowflake.Next(); await SendTo(chan, new UserDisconnectS2CPacket(msgId, DateTimeOffset.Now, user.UserId, user.GetLegacyNameWithStatus(), reason)); await Messages.LogMessage(msgId, "user:disconnect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, new { reason = (int)reason }); if(chan.IsTemporary && chan.IsOwner(user.UserId)) await RemoveChannel(chan); } ChannelsUsers.RemoveUser(user); } public async Task SwitchChannel(User user, Channel chan, string password) { Channel? oldChan = ChannelsUsers.GetUserLastChannel(user); if(oldChan?.Id == chan.Id) { await ForceChannel(user); return; } if(!user.Permissions.HasFlag(UserPermissions.JoinAnyChannel) && chan.IsOwner(user.UserId)) { if(chan.Rank > user.Rank) { await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name)); await ForceChannel(user); return; } if(!string.IsNullOrEmpty(chan.Password) && chan.Password.SlowUtf8Equals(password)) { await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name)); await ForceChannel(user); return; } } if(oldChan is not null) { long leaveId = RandomSnowflake.Next(); await SendTo(oldChan, new UserChannelLeaveS2CPacket(leaveId, user.UserId)); await Messages.LogMessage(leaveId, "chan:leave", oldChan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions); ChannelsUsers.RemoveChannelUser(oldChan, user); if(oldChan.IsTemporary && oldChan.IsOwner(user.UserId)) await RemoveChannel(oldChan); } long joinId = RandomSnowflake.Next(); await SendTo(chan, new UserChannelJoinS2CPacket(joinId, user.UserId, user.GetLegacyNameWithStatus(), user.Colour, user.Rank, user.Permissions)); await Messages.LogMessage(joinId, "chan:join", chan.Name, user.UserId, user.GetLegacyName(), user.Colour, user.Rank, user.NickName, user.Permissions); await SendTo(user, new ContextClearS2CPacket(ContextClearS2CPacket.Mode.MessagesUsers)); await SendTo(user, new ContextUsersS2CPacket( ChannelsUsers.GetChannelUsers(chan).Except([user]).OrderByDescending(u => u.Rank) .Select(u => new ContextUsersS2CPacket.Entry( u.UserId, u.GetLegacyNameWithStatus(), u.Colour, u.Rank, u.Permissions, true )) )); IEnumerable<Message> msgs = await Messages.GetMessages(chan.Name); foreach(Message msg in msgs) await SendTo(user, new ContextMessageS2CPacket(msg)); await ForceChannel(user, chan); ChannelsUsers.AddChannelUser(chan, user); } public async Task Send(S2CPacket packet) { foreach(SockChatConnection conn in Sessions.GetConnections<SockChatConnection>()) await conn.Send(packet); } public async Task SendTo(User user, S2CPacket packet) { foreach(SockChatConnection conn in Sessions.GetConnections<SockChatConnection>(user)) await conn.Send(packet); } public async Task SendTo(Channel channel, S2CPacket packet) { // might be faster to grab the users first and then cascade into that SendTo IEnumerable<SockChatConnection> conns = Sessions.GetConnections<SockChatConnection>( s => ChannelsUsers.HasChannelUser(channel, s.UserId) ); foreach(SockChatConnection conn in conns) await conn.Send(packet); } public async Task SendToUserChannels(User user, S2CPacket packet) { IEnumerable<Channel> chans = ChannelsUsers.GetUserChannels(user); IEnumerable<SockChatConnection> conns = Sessions.GetConnections<SockChatConnection>( s => chans.Any(c => ChannelsUsers.HasChannelUser(c.Id, s.UserId)) ); foreach(SockChatConnection conn in conns) await conn.Send(packet); } public async Task ForceChannel(User user, Channel? chan = null) { chan ??= ChannelsUsers.GetUserLastChannel(user) ?? throw new ArgumentException("no channel???"); await SendTo(user, new UserChannelForceJoinS2CPacket(chan.Name)); } public async Task UpdateChannel( Channel channel, bool? temporary = null, int? rank = null, string? password = null ) { ChannelDiff diff = Channels.UpdateChannel( channel, temporary: temporary, rank: rank, password: password ); // TODO: Users that no longer have access to the channel/gained access to the channel by the hierarchy change should receive delete and create packets respectively if(diff.Changed) foreach(User user in Users.GetUsersOfMinimumRank(channel.Rank)) await SendTo(user, new ChannelUpdateS2CPacket(channel.Name, channel.Name, channel.HasPassword, channel.IsTemporary)); } public async Task RemoveChannel(Channel channel) { // Remove channel from the listing Channels.RemoveChannel(channel); // Move all users back to the main channel // TODO: Replace this with a kick. SCv2 supports being in 0 channels, SCv1 should force the user back to DefaultChannel. foreach(User user in ChannelsUsers.GetChannelUsers(channel)) await SwitchChannel(user, Channels.GetDefaultChannel(), string.Empty); // Broadcast deletion of channel foreach(User user in Users.GetUsersOfMinimumRank(channel.Rank)) await SendTo(user, new ChannelDeleteS2CPacket(channel.Name)); ChannelsUsers.RemoveChannel(channel); } }