sharp-chat/SharpChat/SockChatServer.cs

234 lines
9 KiB
C#

using Fleck;
using SharpChat.ClientCommands;
using SharpChat.Config;
using SharpChat.EventStorage;
using SharpChat.Misuzu;
using SharpChat.S2CPackets;
using SharpChat.C2SPacketHandlers;
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 const int DEFAULT_FLOOD_KICK_EXEMPT_RANK = 9;
public IWebSocketServer Server { get; }
public Context 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 readonly CachedValue<int> FloodKickExemptRank;
private readonly List<C2SPacketHandler> GuestHandlers = [];
private readonly List<C2SPacketHandler> AuthedHandlers = [];
private readonly SendMessageC2SPacketHandler SendMessageHandler;
private bool IsShuttingDown = false;
private static readonly string[] DEFAULT_CHANNELS = ["lounge"];
private Channel DefaultChannel { get; set; }
public SockChatServer(HttpClient httpClient, MisuzuClient msz, EventStorage.EventStorage evtStore, Config.Config 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);
FloodKickExemptRank = config.ReadCached("floodKickExemptRank", DEFAULT_FLOOD_KICK_EXEMPT_RANK);
Context = new Context(evtStore);
string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS);
if(channelNames is not null)
foreach(string channelName in channelNames) {
Config.Config channelCfg = config.ScopeTo($"channels:{channelName}");
string name = channelCfg.SafeReadValue("name", string.Empty)!;
if(string.IsNullOrWhiteSpace(name))
name = channelName;
Channel channelInfo = new(
name,
channelCfg.SafeReadValue("password", string.Empty)!,
rank: channelCfg.SafeReadValue("minRank", 0)
);
Context.Channels.Add(channelInfo);
DefaultChannel ??= channelInfo;
}
if(DefaultChannel is null)
throw new Exception("The default channel could not be determined.");
GuestHandlers.Add(new AuthC2SPacketHandler(Misuzu, DefaultChannel, MaxMessageLength, MaxConnections));
AuthedHandlers.AddRange([
new PingC2SPacketHandler(Misuzu),
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
]);
SendMessageHandler.AddCommands([
new AFKClientCommand(),
new NickClientCommand(),
new WhisperClientCommand(),
new ActionClientCommand(),
new WhoClientCommand(),
new JoinChannelClientCommand(),
new CreateChannelClientCommand(),
new DeleteChannelClientCommand(),
new PasswordChannelClientCommand(),
new RankChannelClientCommand(),
new BroadcastClientCommand(),
new DeleteMessageClientCommand(),
new KickBanClientCommand(msz),
new PardonUserClientCommand(msz),
new PardonAddressClientCommand(msz),
new BanListClientCommand(msz),
new RemoteAddressClientCommand(),
]);
ushort port = config.SafeReadValue("port", DEFAULT_PORT);
Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}");
}
public void Listen(ManualResetEvent waitHandle) {
if(waitHandle != null)
SendMessageHandler.AddCommand(new ShutdownRestartClientCommand(waitHandle, () => !IsShuttingDown && (IsShuttingDown = true)));
Server.Start(sock => {
if(IsShuttingDown) {
sock.Close(1013);
return;
}
Connection conn = new(sock);
Context.Connections.Add(conn);
sock.OnOpen = () => OnOpen(conn);
sock.OnClose = () => OnClose(conn);
sock.OnError = err => OnError(conn, err);
sock.OnMessage = msg => OnMessage(conn, msg);
});
Logger.Write("Listening...");
}
private void OnOpen(Connection conn) {
Logger.Write($"Connection opened from {conn.RemoteAddress}:{conn.RemotePort}");
Context.SafeUpdate();
}
private void OnError(Connection conn, Exception ex) {
Logger.Write($"[{conn.Id} {conn.RemoteAddress}] {ex}");
Context.SafeUpdate();
}
private void OnClose(Connection conn) {
Logger.Write($"Connection closed from {conn.RemoteAddress}:{conn.RemotePort}");
Context.ContextAccess.Wait();
try {
Context.Connections.Remove(conn);
if(conn.User != null && !Context.Connections.Any(c => c.User == conn.User))
Context.HandleDisconnect(conn.User);
Context.Update();
} finally {
Context.ContextAccess.Release();
}
}
private void OnMessage(Connection conn, string msg) {
Context.SafeUpdate();
// this doesn't affect non-authed connections?????
if(conn.User is not null && conn.User.Rank < FloodKickExemptRank) {
User? banUser = null;
string banAddr = string.Empty;
TimeSpan banDuration = TimeSpan.MinValue;
Context.ContextAccess.Wait();
try {
if(!Context.UserRateLimiters.TryGetValue(conn.User.UserId, out RateLimiter? rateLimiter))
Context.UserRateLimiters.Add(conn.User.UserId, rateLimiter = new RateLimiter(
User.DEFAULT_SIZE,
User.DEFAULT_MINIMUM_DELAY,
User.DEFAULT_RISKY_OFFSET
));
rateLimiter.Update();
if(rateLimiter.IsExceeded) {
banDuration = TimeSpan.FromSeconds(FloodKickLength);
banUser = conn.User;
banAddr = conn.RemoteAddress.ToString();
} else if(rateLimiter.IsRisky) {
banUser = conn.User;
}
if(banUser is not null) {
if(banDuration == TimeSpan.MinValue) {
Context.SendTo(conn.User, new CommandResponseS2CPacket(Context.RandomSnowflake.Next(), LCR.FLOOD_WARN, false));
} else {
Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood);
if(banDuration > TimeSpan.Zero)
Misuzu.CreateBanAsync(
conn.User.UserId.ToString(), conn.RemoteAddress.ToString(),
string.Empty, "::1",
banDuration,
"Kicked from chat for flood protection."
).Wait();
return;
}
}
} finally {
Context.ContextAccess.Release();
}
}
C2SPacketHandlerContext context = new(msg, Context, conn);
C2SPacketHandler? handler = conn.User is null
? GuestHandlers.FirstOrDefault(h => h.IsMatch(context))
: AuthedHandlers.FirstOrDefault(h => h.IsMatch(context));
handler?.Handle(context);
}
private bool IsDisposed;
~SockChatServer() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
IsShuttingDown = true;
foreach(Connection conn in Context.Connections)
conn.Dispose();
Server?.Dispose();
HttpClient?.Dispose();
}
}
}