446 lines
17 KiB
C#
446 lines
17 KiB
C#
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<int> MaxMessageLength;
|
|
private readonly CachedValue<int> MaxConnections;
|
|
private readonly CachedValue<int> FloodKickLength;
|
|
|
|
private List<IChatCommand> 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<string> 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();
|
|
}
|
|
}
|
|
}
|