Reintroduces separate contexts for users, channels, connections (now split into sessions and connections) and user-channel associations. It builds which is as much assurance as I can give about the stability of this commit, but its also the bare minimum of what i like to commit sooooo A lot of things still need to be broadcast through events throughout the application in order to keep states consistent but we'll cross that bridge when we get to it. I really need to stop using that phrase thingy, I'm overusing it.
361 lines
14 KiB
C#
361 lines
14 KiB
C#
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);
|
|
}
|
|
}
|