commit 4ceffeb48d7e6588d46ab53223c2c83260675b45 Author: flashwave Date: Tue Aug 30 17:00:58 2022 +0200 Imported stable branch. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40daee9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,215 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 cache/options directory +.vs/ + +# JetBrains Rider cache/options directory +.idea/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +## TODO: Comment the next line if you want to checkin your +## web deploy settings but do note that will include unencrypted +## passwords +#*.pubxml + +*.publishproj + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +[Ss]tyle[Cc]op.* +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# LightSwitch generated files +GeneratedArtifacts/ +_Pvt_Extensions/ +ModelManifest.xml diff --git a/Hamakaze/Hamakaze.csproj b/Hamakaze/Hamakaze.csproj new file mode 100644 index 0000000..f208d30 --- /dev/null +++ b/Hamakaze/Hamakaze.csproj @@ -0,0 +1,7 @@ + + + + net5.0 + + + diff --git a/Hamakaze/Headers/HttpAcceptEncodingHeader.cs b/Hamakaze/Headers/HttpAcceptEncodingHeader.cs new file mode 100644 index 0000000..47b60a7 --- /dev/null +++ b/Hamakaze/Headers/HttpAcceptEncodingHeader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Hamakaze.Headers { + public class HttpAcceptEncodingHeader : HttpHeader { + public const string NAME = @"Accept-Encoding"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public HttpEncoding[] Encodings { get; } + + public HttpAcceptEncodingHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) { } + + public HttpAcceptEncodingHeader(string[] encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse) + ) {} + + public HttpAcceptEncodingHeader(IEnumerable encodings) { + Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray(); + } + } +} diff --git a/Hamakaze/Headers/HttpConnectionHeader.cs b/Hamakaze/Headers/HttpConnectionHeader.cs new file mode 100644 index 0000000..50b1318 --- /dev/null +++ b/Hamakaze/Headers/HttpConnectionHeader.cs @@ -0,0 +1,17 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpConnectionHeader : HttpHeader { + public const string NAME = @"Connection"; + + public override string Name => NAME; + public override object Value { get; } + + public const string CLOSE = @"close"; + public const string KEEP_ALIVE = @"keep-alive"; + + public HttpConnectionHeader(string mode) { + Value = mode ?? throw new ArgumentNullException(nameof(mode)); + } + } +} diff --git a/Hamakaze/Headers/HttpContentEncodingHeader.cs b/Hamakaze/Headers/HttpContentEncodingHeader.cs new file mode 100644 index 0000000..9972819 --- /dev/null +++ b/Hamakaze/Headers/HttpContentEncodingHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpContentEncodingHeader : HttpHeader { + public const string NAME = @"Content-Encoding"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public string[] Encodings { get; } + + public HttpContentEncodingHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) { } + + public HttpContentEncodingHeader(string[] encodings) { + Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings)); + } + } +} diff --git a/Hamakaze/Headers/HttpContentLengthHeader.cs b/Hamakaze/Headers/HttpContentLengthHeader.cs new file mode 100644 index 0000000..53f2d31 --- /dev/null +++ b/Hamakaze/Headers/HttpContentLengthHeader.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +namespace Hamakaze.Headers { + public class HttpContentLengthHeader : HttpHeader { + public const string NAME = @"Content-Length"; + + public override string Name => NAME; + public override object Value => Stream?.Length ?? Length; + + private Stream Stream { get; } + private long Length { get; } + + public HttpContentLengthHeader(Stream stream) { + Stream = stream ?? throw new ArgumentNullException(nameof(stream)); + if(!stream.CanRead || !stream.CanSeek) + throw new ArgumentException(@"Body must readable and seekable.", nameof(stream)); + } + + public HttpContentLengthHeader(long length) { + Length = length; + } + + public HttpContentLengthHeader(string length) { + if(!long.TryParse(length, out long ll)) + throw new ArgumentException(@"Invalid length value.", nameof(length)); + Length = ll; + } + } +} diff --git a/Hamakaze/Headers/HttpContentTypeHeader.cs b/Hamakaze/Headers/HttpContentTypeHeader.cs new file mode 100644 index 0000000..8ef6846 --- /dev/null +++ b/Hamakaze/Headers/HttpContentTypeHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpContentTypeHeader : HttpHeader { + public const string NAME = @"Content-Type"; + + public override string Name => NAME; + public override object Value => MediaType.ToString(); + + public HttpMediaType MediaType { get; } + + public HttpContentTypeHeader(string mediaType) { + MediaType = HttpMediaType.Parse(mediaType ?? throw new ArgumentNullException(nameof(mediaType))); + } + + public HttpContentTypeHeader(HttpMediaType mediaType) { + MediaType = mediaType; + } + } +} diff --git a/Hamakaze/Headers/HttpCustomHeader.cs b/Hamakaze/Headers/HttpCustomHeader.cs new file mode 100644 index 0000000..bfd7196 --- /dev/null +++ b/Hamakaze/Headers/HttpCustomHeader.cs @@ -0,0 +1,13 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpCustomHeader : HttpHeader { + public override string Name { get; } + public override object Value { get; } + + public HttpCustomHeader(string name, object value) { + Name = NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + Value = value; + } + } +} diff --git a/Hamakaze/Headers/HttpDateHeader.cs b/Hamakaze/Headers/HttpDateHeader.cs new file mode 100644 index 0000000..91c36fe --- /dev/null +++ b/Hamakaze/Headers/HttpDateHeader.cs @@ -0,0 +1,18 @@ +using System; +using System.Globalization; + +namespace Hamakaze.Headers { + public class HttpDateHeader : HttpHeader { + public const string NAME = @"Date"; + + public override string Name => NAME; + public override object Value { get; } + + public DateTimeOffset DateTime { get; } + + public HttpDateHeader(string dateString) { + Value = dateString ?? throw new ArgumentNullException(nameof(dateString)); + DateTime = DateTimeOffset.ParseExact(dateString, @"r", CultureInfo.InvariantCulture); + } + } +} diff --git a/Hamakaze/Headers/HttpHeader.cs b/Hamakaze/Headers/HttpHeader.cs new file mode 100644 index 0000000..b9f75fa --- /dev/null +++ b/Hamakaze/Headers/HttpHeader.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; + +namespace Hamakaze.Headers { + public abstract class HttpHeader { + public abstract string Name { get; } + public abstract object Value { get; } + + public override string ToString() { + return string.Format(@"{0}: {1}", Name, Value); + } + + public static string NormaliseName(string name) { + if(string.IsNullOrWhiteSpace(name)) + return string.Empty; + + string[] parts = name.ToLowerInvariant().Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for(int i = 0; i < parts.Length; ++i) + parts[i] = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(parts[i]); + return string.Join('-', parts); + } + + public static HttpHeader Create(string name, object value) { + return name switch { + HttpTeHeader.NAME => new HttpTeHeader(value.ToString()), + HttpDateHeader.NAME => new HttpDateHeader(value.ToString()), + HttpHostHeader.NAME => new HttpHostHeader(value.ToString()), + HttpServerHeader.NAME => new HttpServerHeader(value.ToString()), + HttpUserAgentHeader.NAME => new HttpUserAgentHeader(value.ToString()), + HttpKeepAliveHeader.NAME => new HttpKeepAliveHeader(value.ToString()), + HttpConnectionHeader.NAME => new HttpConnectionHeader(value.ToString()), + HttpContentTypeHeader.NAME => new HttpContentTypeHeader(value.ToString()), + HttpContentLengthHeader.NAME => new HttpContentLengthHeader(value.ToString()), + HttpAcceptEncodingHeader.NAME => new HttpAcceptEncodingHeader(value.ToString()), + HttpContentEncodingHeader.NAME => new HttpContentEncodingHeader(value.ToString()), + HttpTransferEncodingHeader.NAME => new HttpTransferEncodingHeader(value.ToString()), + _ => new HttpCustomHeader(name, value), + }; + } + } +} diff --git a/Hamakaze/Headers/HttpHostHeader.cs b/Hamakaze/Headers/HttpHostHeader.cs new file mode 100644 index 0000000..c263ff9 --- /dev/null +++ b/Hamakaze/Headers/HttpHostHeader.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using System.Text; + +namespace Hamakaze.Headers { + public class HttpHostHeader : HttpHeader { + public const string NAME = @"Host"; + + public override string Name => NAME; + public override object Value { + get { + StringBuilder sb = new(); + sb.Append(Host); + if(Port != -1) + sb.AppendFormat(@":{0}", Port); + return sb.ToString(); + } + } + + public string Host { get; } + public int Port { get; } + public bool IsSecure { get; } + + public HttpHostHeader(string host, int port) { + Host = host; + Port = port; + } + + public HttpHostHeader(string hostAndPort) { + string[] parts = hostAndPort.Split(':', 2, StringSplitOptions.TrimEntries); + Host = parts.ElementAtOrDefault(0) ?? throw new ArgumentNullException(nameof(hostAndPort)); + if(!ushort.TryParse(parts.ElementAtOrDefault(1), out ushort port)) + throw new FormatException(@"Host is not in valid format."); + Port = port; + } + } +} diff --git a/Hamakaze/Headers/HttpKeepAliveHeader.cs b/Hamakaze/Headers/HttpKeepAliveHeader.cs new file mode 100644 index 0000000..d8ab2c3 --- /dev/null +++ b/Hamakaze/Headers/HttpKeepAliveHeader.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace Hamakaze.Headers { + public class HttpKeepAliveHeader : HttpHeader { + public const string NAME = @"Keep-Alive"; + + public override string Name => NAME; + public override object Value { + get { + List parts = new(); + if(MaxIdle != TimeSpan.MaxValue) + parts.Add(string.Format(@"timeout={0}", MaxIdle.TotalSeconds)); + if(MaxRequests >= 0) + parts.Add(string.Format(@"max={0}", MaxRequests)); + return string.Join(@", ", parts); + } + } + + public TimeSpan MaxIdle { get; } = TimeSpan.MaxValue; + public int MaxRequests { get; } = -1; + + public HttpKeepAliveHeader(string value) { + IEnumerable kvps = (value ?? throw new ArgumentNullException(nameof(value))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach(string kvp in kvps) { + string[] parts = kvp.Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if(parts[0] == @"timeout" && int.TryParse(parts[1], out int timeout)) + MaxIdle = TimeSpan.FromSeconds(timeout); + else if(parts[0] == @"max" && int.TryParse(parts[1], out int max)) + MaxRequests = max; + } + } + } +} diff --git a/Hamakaze/Headers/HttpServerHeader.cs b/Hamakaze/Headers/HttpServerHeader.cs new file mode 100644 index 0000000..c2d665f --- /dev/null +++ b/Hamakaze/Headers/HttpServerHeader.cs @@ -0,0 +1,14 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpServerHeader : HttpHeader { + public const string NAME = @"Server"; + + public override string Name => NAME; + public override object Value { get; } + + public HttpServerHeader(string server) { + Value = server ?? throw new ArgumentNullException(nameof(server)); + } + } +} diff --git a/Hamakaze/Headers/HttpTeHeader.cs b/Hamakaze/Headers/HttpTeHeader.cs new file mode 100644 index 0000000..0ccc4c3 --- /dev/null +++ b/Hamakaze/Headers/HttpTeHeader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Hamakaze.Headers { + public class HttpTeHeader : HttpHeader { + public const string NAME = @"TE"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public HttpEncoding[] Encodings { get; } + + public HttpTeHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) { } + + public HttpTeHeader(string[] encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse) + ) { } + + public HttpTeHeader(IEnumerable encodings) { + Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray(); + } + } +} diff --git a/Hamakaze/Headers/HttpTransferEncodingHeader.cs b/Hamakaze/Headers/HttpTransferEncodingHeader.cs new file mode 100644 index 0000000..a62939a --- /dev/null +++ b/Hamakaze/Headers/HttpTransferEncodingHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpTransferEncodingHeader : HttpHeader { + public const string NAME = @"Transfer-Encoding"; + + public override string Name => NAME; + public override object Value => string.Join(@", ", Encodings); + + public string[] Encodings { get; } + + public HttpTransferEncodingHeader(string encodings) : this( + (encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + ) {} + + public HttpTransferEncodingHeader(string[] encodings) { + Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings)); + } + } +} diff --git a/Hamakaze/Headers/HttpUserAgentHeader.cs b/Hamakaze/Headers/HttpUserAgentHeader.cs new file mode 100644 index 0000000..b8aa5ec --- /dev/null +++ b/Hamakaze/Headers/HttpUserAgentHeader.cs @@ -0,0 +1,20 @@ +using System; + +namespace Hamakaze.Headers { + public class HttpUserAgentHeader : HttpHeader { + public const string NAME = @"User-Agent"; + + public override string Name => NAME; + public override object Value { get; } + + public HttpUserAgentHeader(string userAgent) { + if(userAgent == null) + throw new ArgumentNullException(nameof(userAgent)); + + if(string.IsNullOrWhiteSpace(userAgent) || userAgent.Equals(HttpClient.USER_AGENT)) + Value = HttpClient.USER_AGENT; + else + Value = string.Format(@"{0} {1}", userAgent, HttpClient.USER_AGENT); + } + } +} diff --git a/Hamakaze/HttpClient.cs b/Hamakaze/HttpClient.cs new file mode 100644 index 0000000..be009b5 --- /dev/null +++ b/Hamakaze/HttpClient.cs @@ -0,0 +1,118 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; + +namespace Hamakaze { + public class HttpClient : IDisposable { + public const string PRODUCT_STRING = @"HMKZ"; + public const string VERSION_MAJOR = @"1"; + public const string VERSION_MINOR = @"0"; + public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR; + + private static HttpClient InstanceValue { get; set; } + public static HttpClient Instance { + get { + if(InstanceValue == null) + InstanceValue = new HttpClient(); + return InstanceValue; + } + } + + private HttpConnectionManager Connections { get; } + private HttpTaskManager Tasks { get; } + + public string DefaultUserAgent { get; set; } = USER_AGENT; + public bool ReuseConnections { get; set; } = true; + public IEnumerable AcceptedEncodings { get; set; } = new[] { HttpEncoding.GZip, HttpEncoding.Deflate, HttpEncoding.Brotli }; + + public HttpClient() { + Connections = new HttpConnectionManager(); + Tasks = new HttpTaskManager(); + } + + public HttpTask CreateTask( + HttpRequestMessage request, + Action onComplete = null, + Action onError = null, + Action onCancel = null, + Action onDownloadProgress = null, + Action onUploadProgress = null, + Action onStateChange = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + if(request == null) + throw new ArgumentNullException(nameof(request)); + if(string.IsNullOrWhiteSpace(request.UserAgent)) + request.UserAgent = DefaultUserAgent; + if(!request.HasHeader(HttpAcceptEncodingHeader.NAME)) + request.AcceptedEncodings = AcceptedEncodings; + request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE; + + HttpTask task = new(Connections, request, disposeRequest, disposeResponse); + + if(onComplete != null) + task.OnComplete += onComplete; + if(onError != null) + task.OnError += onError; + if(onCancel != null) + task.OnCancel += onCancel; + if(onDownloadProgress != null) + task.OnDownloadProgress += onDownloadProgress; + if(onUploadProgress != null) + task.OnUploadProgress += onUploadProgress; + if(onStateChange != null) + task.OnStateChange += onStateChange; + + return task; + } + + public void RunTask(HttpTask task) { + Tasks.RunTask(task); + } + + public void SendRequest( + HttpRequestMessage request, + Action onComplete = null, + Action onError = null, + Action onCancel = null, + Action onDownloadProgress = null, + Action onUploadProgress = null, + Action onStateChange = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse)); + } + + public static void Send( + HttpRequestMessage request, + Action onComplete = null, + Action onError = null, + Action onCancel = null, + Action onDownloadProgress = null, + Action onUploadProgress = null, + Action onStateChange = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + Instance.SendRequest(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse); + } + + private bool IsDisposed; + ~HttpClient() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Tasks.Dispose(); + Connections.Dispose(); + } + } +} diff --git a/Hamakaze/HttpConnection.cs b/Hamakaze/HttpConnection.cs new file mode 100644 index 0000000..8bb90d0 --- /dev/null +++ b/Hamakaze/HttpConnection.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Threading; + +namespace Hamakaze { + public class HttpConnection : IDisposable { + public IPEndPoint EndPoint { get; } + public Stream Stream { get; } + + public Socket Socket { get; } + public NetworkStream NetworkStream { get; } + public SslStream SslStream { get; } + + public string Host { get; } + public bool IsSecure { get; } + + public bool HasTimedOut => MaxRequests == 0 || (DateTimeOffset.Now - LastOperation) > MaxIdle; + + public int MaxRequests { get; set; } = -1; + public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue; + public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now; + + public bool InUse { get; private set; } + + public HttpConnection(string host, IPEndPoint endPoint, bool secure) { + Host = host ?? throw new ArgumentNullException(nameof(host)); + EndPoint = endPoint ?? throw new ArgumentNullException(nameof(endPoint)); + IsSecure = secure; + + if(endPoint.AddressFamily is not AddressFamily.InterNetwork and not AddressFamily.InterNetworkV6) + throw new ArgumentException(@"Address must be an IPv4 or IPv6 address.", nameof(endPoint)); + + Socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { + NoDelay = true, + Blocking = true, + }; + Socket.Connect(endPoint); + + NetworkStream = new NetworkStream(Socket, true); + + if(IsSecure) { + SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null); + Stream = SslStream; + SslStream.AuthenticateAsClient(Host, null, SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, true); + } else + Stream = NetworkStream; + } + + public void MarkUsed() { + LastOperation = DateTimeOffset.Now; + if(MaxRequests > 0) + --MaxRequests; + } + + public bool Acquire() { + return !InUse && (InUse = true); + } + + public void Release() { + InUse = false; + } + + private bool IsDisposed; + ~HttpConnection() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Stream.Dispose(); + } + } +} diff --git a/Hamakaze/HttpConnectionManager.cs b/Hamakaze/HttpConnectionManager.cs new file mode 100644 index 0000000..a0c5853 --- /dev/null +++ b/Hamakaze/HttpConnectionManager.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; + +namespace Hamakaze { + public class HttpConnectionManager : IDisposable { + private List Connections { get; } = new(); + private Mutex Lock { get; } = new(); + + public HttpConnectionManager() { + } + + private void AcquireLock() { + if(!Lock.WaitOne(10000)) + throw new HttpConnectionManagerLockException(); + } + + private void ReleaseLock() { + Lock.ReleaseMutex(); + } + + public HttpConnection CreateConnection(string host, IPEndPoint endPoint, bool secure) { + if(host == null) + throw new ArgumentNullException(nameof(host)); + if(endPoint == null) + throw new ArgumentNullException(nameof(endPoint)); + HttpConnection conn = null; + AcquireLock(); + try { + conn = CreateConnectionInternal(host, endPoint, secure); + } finally { + ReleaseLock(); + } + return conn; + } + + private HttpConnection CreateConnectionInternal(string host, IPEndPoint endPoint, bool secure) { + HttpConnection conn = new(host, endPoint, secure); + Connections.Add(conn); + return conn; + } + + public HttpConnection GetConnection(string host, IPEndPoint endPoint, bool secure) { + if(host == null) + throw new ArgumentNullException(nameof(host)); + if(endPoint == null) + throw new ArgumentNullException(nameof(endPoint)); + HttpConnection conn = null; + AcquireLock(); + try { + conn = GetConnectionInternal(host, endPoint, secure); + } finally { + ReleaseLock(); + } + return conn; + } + + private HttpConnection GetConnectionInternal(string host, IPEndPoint endPoint, bool secure) { + CleanConnectionsInternal(); + HttpConnection conn = Connections.FirstOrDefault(c => host.Equals(c.Host) && endPoint.Equals(c.EndPoint) && c.IsSecure == secure && c.Acquire()); + if(conn == null) { + conn = CreateConnectionInternal(host, endPoint, secure); + conn.Acquire(); + } + return conn; + } + + public void EndConnection(HttpConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + AcquireLock(); + try { + EndConnectionInternal(conn); + } finally { + ReleaseLock(); + } + } + + private void EndConnectionInternal(HttpConnection conn) { + Connections.Remove(conn); + conn.Dispose(); + } + + public void CleanConnection() { + AcquireLock(); + try { + CleanConnectionsInternal(); + } finally { + ReleaseLock(); + } + } + + private void CleanConnectionsInternal() { + IEnumerable conns = Connections.Where(x => x.HasTimedOut).ToArray(); + foreach(HttpConnection conn in conns) { + Connections.Remove(conn); + conn.Dispose(); + } + } + + private bool IsDisposed; + ~HttpConnectionManager() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Lock.Dispose(); + + foreach(HttpConnection conn in Connections) + conn.Dispose(); + Connections.Clear(); + } + } +} diff --git a/Hamakaze/HttpEncoding.cs b/Hamakaze/HttpEncoding.cs new file mode 100644 index 0000000..5b6e3a5 --- /dev/null +++ b/Hamakaze/HttpEncoding.cs @@ -0,0 +1,69 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Hamakaze { + public readonly struct HttpEncoding : IComparable, IEquatable { + public const string DEFLATE = @"deflate"; + public const string GZIP = @"gzip"; + public const string XGZIP = @"x-gzip"; + public const string BROTLI = @"br"; + public const string IDENTITY = @"identity"; + public const string CHUNKED = @"chunked"; + public const string ANY = @"*"; + + public static readonly HttpEncoding Any = new(ANY); + public static readonly HttpEncoding None = new(ANY, 0f); + public static readonly HttpEncoding Deflate = new(DEFLATE); + public static readonly HttpEncoding GZip = new(GZIP); + public static readonly HttpEncoding Brotli = new(BROTLI); + public static readonly HttpEncoding Identity = new(IDENTITY); + + public string Name { get; } + public float Quality { get; } + + public HttpEncoding(string name, float quality = 1f) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Quality = quality; + } + + public HttpEncoding WithQuality(float quality) { + return new HttpEncoding(Name, quality); + } + + public static HttpEncoding Parse(string encoding) { + string[] parts = encoding.Split(';', StringSplitOptions.TrimEntries); + float quality = 1f; + encoding = parts[0]; + + for(int i = 1; i < parts.Length; ++i) + if(parts[i].StartsWith(@"q=")) { + if(!float.TryParse(parts[i], out quality)) + quality = 1f; + break; + } + + return new HttpEncoding(encoding, quality); + } + + public override string ToString() { + StringBuilder sb = new(); + sb.Append(Name); + if(Quality is >= 0f and < 1f) + sb.AppendFormat(CultureInfo.InvariantCulture, @";q={0:0.0}", Quality); + return sb.ToString(); + } + + public int CompareTo(HttpEncoding? other) { + if(!other.HasValue || other.Value.Quality < Quality) + return -1; + if(other.Value.Quality > Quality) + return 1; + return 0; + } + + public bool Equals(HttpEncoding? other) { + return other.HasValue && Name.Equals(other.Value.Name) && Quality.Equals(other.Value.Quality); + } + } +} diff --git a/Hamakaze/HttpException.cs b/Hamakaze/HttpException.cs new file mode 100644 index 0000000..d6e0bce --- /dev/null +++ b/Hamakaze/HttpException.cs @@ -0,0 +1,40 @@ +using System; + +namespace Hamakaze { + public class HttpException : Exception { + public HttpException(string message) : base(message) { } + } + + public class HttpConnectionManagerException : HttpException { + public HttpConnectionManagerException(string message) : base(message) { } + } + public class HttpConnectionManagerLockException : HttpConnectionManagerException { + public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { } + } + + public class HttpTaskException : HttpException { + public HttpTaskException(string message) : base(message) { } + } + public class HttpTaskAlreadyStartedException : HttpTaskException { + public HttpTaskAlreadyStartedException() : base(@"Task has already started.") { } + } + public class HttpTaskInvalidStateException : HttpTaskException { + public HttpTaskInvalidStateException() : base(@"Task has ended up in an invalid state.") { } + } + public class HttpTaskNoAddressesException : HttpTaskException { + public HttpTaskNoAddressesException() : base(@"Could not find any addresses for this host.") { } + } + public class HttpTaskNoConnectionException : HttpTaskException { + public HttpTaskNoConnectionException() : base(@"Was unable to create a connection with this host.") { } + } + public class HttpTaskRequestFailedException : HttpTaskException { + public HttpTaskRequestFailedException() : base(@"Request failed for unknown reasons.") { } + } + + public class HttpTaskManagerException : HttpException { + public HttpTaskManagerException(string message) : base(message) { } + } + public class HttpTaskManagerLockException : HttpTaskManagerException { + public HttpTaskManagerLockException() : base(@"Failed to reserve a thread.") { } + } +} diff --git a/Hamakaze/HttpMediaType.cs b/Hamakaze/HttpMediaType.cs new file mode 100644 index 0000000..e3ca1e6 --- /dev/null +++ b/Hamakaze/HttpMediaType.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Hamakaze { + public readonly struct HttpMediaType : IComparable, IEquatable { + public const string TYPE_APPLICATION = @"application"; + public const string TYPE_AUDIO = @"audio"; + public const string TYPE_IMAGE = @"image"; + public const string TYPE_MESSAGE = @"message"; + public const string TYPE_MULTIPART = @"multipart"; + public const string TYPE_TEXT = @"text"; + public const string TYPE_VIDEO = @"video"; + + public static readonly HttpMediaType OctetStream = new(TYPE_APPLICATION, @"octet-stream"); + public static readonly HttpMediaType FWIF = new(TYPE_APPLICATION, @"x.fwif"); + public static readonly HttpMediaType JSON = new(TYPE_APPLICATION, @"json"); + public static readonly HttpMediaType HTML = new(TYPE_TEXT, @"html", args: new[] { Param.UTF8 }); + + public string Type { get; } + public string Subtype { get; } + public string Suffix { get; } + public IEnumerable Params { get; } + + public HttpMediaType(string type, string subtype, string suffix = null, IEnumerable args = null) { + Type = type ?? throw new ArgumentNullException(nameof(type)); + Subtype = subtype ?? throw new ArgumentNullException(nameof(subtype)); + Suffix = suffix ?? string.Empty; + Params = args ?? Enumerable.Empty(); + } + + public string GetParamValue(string name) { + foreach(Param param in Params) + if(param.Name.ToLowerInvariant() == name) + return param.Value; + return null; + } + + public static explicit operator HttpMediaType(string mediaTypeString) => Parse(mediaTypeString); + + public static HttpMediaType Parse(string mediaTypeString) { + if(mediaTypeString == null) + throw new ArgumentNullException(nameof(mediaTypeString)); + + int slashIndex = mediaTypeString.IndexOf('/'); + if(slashIndex == -1) + return OctetStream; + + string type = mediaTypeString[..slashIndex]; + string subtype = mediaTypeString[(slashIndex + 1)..]; + string suffix = null; + IEnumerable args = null; + + int paramIndex = subtype.IndexOf(';'); + if(paramIndex != -1) { + args = subtype[(paramIndex + 1)..] + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Param.Parse); + subtype = subtype[..paramIndex]; + } + + int suffixIndex = subtype.IndexOf('+'); + if(suffixIndex != -1) { + suffix = subtype[(suffixIndex + 1)..]; + subtype = subtype[..suffixIndex]; + } + + return new HttpMediaType(type, subtype, suffix, args); + } + + public override string ToString() { + StringBuilder sb = new(); + sb.AppendFormat(@"{0}/{1}", Type, Subtype); + if(!string.IsNullOrWhiteSpace(Suffix)) + sb.AppendFormat(@"+{0}", Suffix); + if(Params.Any()) + sb.AppendFormat(@";{0}", string.Join(';', Params)); + return sb.ToString(); + } + + public int CompareTo(HttpMediaType? other) { + if(!other.HasValue) + return -1; + int type = Type.CompareTo(other.Value.Type); + if(type != 0) + return type; + int subtype = Subtype.CompareTo(other.Value.Subtype); + if(subtype != 0) + return subtype; + int suffix = Suffix.CompareTo(other.Value.Suffix); + if(suffix != 0) + return suffix; + int paramCount = Params.Count(); + int args = paramCount - other.Value.Params.Count(); + if(args != 0) + return args; + for(int i = 0; i < paramCount; ++i) { + args = Params.ElementAt(i).CompareTo(other.Value.Params.ElementAt(i)); + if(args != 0) + return args; + } + return 0; + } + + public bool Equals(HttpMediaType? other) { + if(!other.HasValue) + return false; + if(!Type.Equals(other.Value.Type) || !Subtype.Equals(other.Value.Subtype) || !Suffix.Equals(other.Value.Suffix)) + return false; + int paramCount = Params.Count(); + if(paramCount != other.Value.Params.Count()) + return false; + for(int i = 0; i < paramCount; ++i) + if(!Params.ElementAt(i).Equals(other.Value.Params.ElementAt(i))) + return false; + return true; + } + + public readonly struct Param : IComparable, IEquatable { + public const string CHARSET = @"charset"; + + public static readonly Param ASCII = new(CHARSET, @"us-ascii"); + public static readonly Param UTF8 = new(CHARSET, @"utf-8"); + + public string Name { get; } + public string Value { get; } + + public Param(string name, string value) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Value = value ?? throw new ArgumentNullException(nameof(name)); + } + + public override string ToString() { + return string.Format(@"{0}={1}", Name, Value); + } + + public static explicit operator Param(string paramStr) => Parse(paramStr); + + public static Param Parse(string paramStr) { + string[] parts = (paramStr ?? throw new ArgumentNullException(nameof(paramStr))).Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return new Param(parts[0], parts[1]); + } + + public int CompareTo(Param? other) { + if(!other.HasValue) + return -1; + int name = Name.CompareTo(other.Value.Name); + return name != 0 + ? name + : Value.CompareTo(other.Value.Value); + } + + public bool Equals(Param? other) { + return other.HasValue && Name.Equals(other.Value.Name) && Value.Equals(other.Value.Value); + } + } + } +} diff --git a/Hamakaze/HttpMessage.cs b/Hamakaze/HttpMessage.cs new file mode 100644 index 0000000..cbfc22e --- /dev/null +++ b/Hamakaze/HttpMessage.cs @@ -0,0 +1,46 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Hamakaze { + public abstract class HttpMessage : IDisposable { + public abstract string ProtocolVersion { get; } + public abstract IEnumerable Headers { get; } + public abstract Stream Body { get; } + + public virtual bool HasBody => Body != null; + + protected bool OwnsBodyStream { get; set; } + + public virtual IEnumerable GetHeader(string header) { + header = HttpHeader.NormaliseName(header); + return Headers.Where(h => h.Name == header); + } + + public virtual bool HasHeader(string header) { + header = HttpHeader.NormaliseName(header); + return Headers.Any(h => h.Name == header); + } + + public virtual string GetHeaderLine(string header) { + return string.Join(@", ", GetHeader(header).Select(h => h.Value)); + } + + private bool IsDisposed; + ~HttpMessage() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + protected void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + if(OwnsBodyStream && Body != null) + Body.Dispose(); + } + } +} diff --git a/Hamakaze/HttpRequestMessage.cs b/Hamakaze/HttpRequestMessage.cs new file mode 100644 index 0000000..6ce3ce3 --- /dev/null +++ b/Hamakaze/HttpRequestMessage.cs @@ -0,0 +1,190 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Hamakaze { + public class HttpRequestMessage : HttpMessage { + public const string GET = @"GET"; + public const string PUT = @"PUT"; + public const string HEAD = @"HEAD"; + public const string POST = @"POST"; + public const string DELETE = @"DELETE"; + + public override string ProtocolVersion => @"1.1"; + + public string Method { get; } + public string RequestTarget { get; } + + public bool IsSecure { get; } + + public string Host { get; } + public ushort Port { get; } + public bool IsDefaultPort { get; } + + public override IEnumerable Headers => HeaderList; + private List HeaderList { get; } = new(); + + private Stream BodyStream { get; set; } + public override Stream Body { + get { + if(BodyStream == null) { + OwnsBodyStream = true; + SetBody(new MemoryStream()); + } + return BodyStream; + } + } + + private static readonly string[] HEADERS_READONLY = new[] { + HttpHostHeader.NAME, HttpContentLengthHeader.NAME, + }; + private static readonly string[] HEADERS_SINGLE = new[] { + HttpUserAgentHeader.NAME, HttpConnectionHeader.NAME, HttpAcceptEncodingHeader.NAME, + }; + + public IEnumerable AcceptedEncodings { + get => HeaderList.Where(x => x.Name == HttpAcceptEncodingHeader.NAME).Cast().FirstOrDefault()?.Encodings + ?? Enumerable.Empty(); + + set { + HeaderList.RemoveAll(x => x.Name == HttpAcceptEncodingHeader.NAME); + HeaderList.Add(new HttpAcceptEncodingHeader(value)); + } + } + + public string UserAgent { + get => HeaderList.FirstOrDefault(x => x.Name == HttpUserAgentHeader.NAME)?.Value.ToString() + ?? string.Empty; + set { + HeaderList.RemoveAll(x => x.Name == HttpUserAgentHeader.NAME); + HeaderList.Add(new HttpUserAgentHeader(value)); + } + } + + public string Connection { + get => HeaderList.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString() + ?? string.Empty; + set { + HeaderList.RemoveAll(x => x.Name == HttpConnectionHeader.NAME); + HeaderList.Add(new HttpConnectionHeader(value)); + } + } + + public HttpMediaType ContentType { + get => HeaderList.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast().FirstOrDefault()?.MediaType + ?? HttpMediaType.OctetStream; + set { + HeaderList.RemoveAll(x => x.Name == HttpContentTypeHeader.NAME); + HeaderList.Add(new HttpContentTypeHeader(value)); + } + } + + public HttpRequestMessage(string method, string uri) : this( + method, new Uri(uri) + ) {} + + public const ushort HTTP = 80; + public const ushort HTTPS = 443; + + public HttpRequestMessage(string method, Uri uri) { + Method = method ?? throw new ArgumentNullException(nameof(method)); + RequestTarget = uri.PathAndQuery; + IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase); + Host = uri.Host; + ushort defaultPort = (IsSecure ? HTTPS : HTTP); + Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port; + IsDefaultPort = Port == defaultPort; + HeaderList.Add(new HttpHostHeader(Host, IsDefaultPort ? -1 : Port)); + } + + public static bool IsHeaderReadOnly(string name) + => HEADERS_READONLY.Contains(name ?? throw new ArgumentNullException(nameof(name))); + public static bool IsHeaderSingleInstance(string name) + => HEADERS_SINGLE.Contains(name ?? throw new ArgumentNullException(nameof(name))); + + public void SetHeader(string name, object value) { + name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + if(IsHeaderReadOnly(name)) + throw new ArgumentException(@"This header is read-only.", nameof(name)); + HeaderList.RemoveAll(x => x.Name == name); + HeaderList.Add(HttpHeader.Create(name, value)); + } + + public void AddHeader(string name, object value) { + name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + if(IsHeaderReadOnly(name)) + throw new ArgumentException(@"This header is read-only.", nameof(name)); + if(IsHeaderSingleInstance(name)) + HeaderList.RemoveAll(x => x.Name == name); + HeaderList.Add(HttpHeader.Create(name, value)); + } + + public void RemoveHeader(string name) { + name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name))); + if(IsHeaderReadOnly(name)) + throw new ArgumentException(@"This header is read-only.", nameof(name)); + HeaderList.RemoveAll(x => x.Name == name); + } + + public void SetBody(Stream stream) { + if(stream == null) { + if(OwnsBodyStream) + BodyStream?.Dispose(); + OwnsBodyStream = false; + BodyStream = null; + HeaderList.RemoveAll(x => x.Name == HttpContentLengthHeader.NAME); + } else { + if(!stream.CanRead || !stream.CanSeek) + throw new ArgumentException(@"Body must readable and seekable.", nameof(stream)); + if(OwnsBodyStream) + BodyStream?.Dispose(); + OwnsBodyStream = false; + BodyStream = stream; + HeaderList.Add(new HttpContentLengthHeader(BodyStream)); + } + } + + public void SetBody(byte[] buffer) { + SetBody(new MemoryStream(buffer)); + OwnsBodyStream = true; + } + + public void SetBody(string str, Encoding encoding = null) { + SetBody((encoding ?? Encoding.UTF8).GetBytes(str)); + } + + public void WriteTo(Stream stream, Action onProgress = null) { + using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) { + sw.NewLine = "\r\n"; + sw.Write(Method); + sw.Write(' '); + sw.Write(RequestTarget); + sw.Write(@" HTTP/"); + sw.WriteLine(ProtocolVersion); + foreach(HttpHeader header in Headers) + sw.WriteLine(header); + sw.WriteLine(); + sw.Flush(); + } + + if(BodyStream != null) { + const int bufferSize = 8192; + byte[] buffer = new byte[bufferSize]; + int read; + long totalRead = 0; + + onProgress?.Invoke(totalRead, BodyStream.Length); + + BodyStream.Seek(0, SeekOrigin.Begin); + while((read = BodyStream.Read(buffer, 0, bufferSize)) > 0) { + stream.Write(buffer, 0, read); + totalRead += read; + onProgress?.Invoke(totalRead, BodyStream.Length); + } + } + } + } +} diff --git a/Hamakaze/HttpResponseMessage.cs b/Hamakaze/HttpResponseMessage.cs new file mode 100644 index 0000000..c041e93 --- /dev/null +++ b/Hamakaze/HttpResponseMessage.cs @@ -0,0 +1,265 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +namespace Hamakaze { + public class HttpResponseMessage : HttpMessage { + public override string ProtocolVersion { get; } + public int StatusCode { get; } + public string StatusMessage { get; } + + public override IEnumerable Headers { get; } + + public override Stream Body { get; } + + public string Connection + => Headers.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString() ?? string.Empty; + public string Server + => Headers.FirstOrDefault(x => x.Name == HttpServerHeader.NAME)?.Value.ToString() ?? string.Empty; + public DateTimeOffset Date + => Headers.Where(x => x.Name == HttpDateHeader.NAME).Cast().FirstOrDefault()?.DateTime ?? DateTimeOffset.MinValue; + public HttpMediaType ContentType + => Headers.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast().FirstOrDefault()?.MediaType + ?? HttpMediaType.OctetStream; + public Encoding ResponseEncoding + => Encoding.GetEncoding(ContentType.GetParamValue(@"charset") ?? @"iso8859-1"); + public IEnumerable ContentEncodings + => Headers.Where(x => x.Name == HttpContentEncodingHeader.NAME).Cast().FirstOrDefault()?.Encodings + ?? Enumerable.Empty(); + public IEnumerable TransferEncodings + => Headers.Where(x => x.Name == HttpTransferEncodingHeader.NAME).Cast().FirstOrDefault()?.Encodings + ?? Enumerable.Empty(); + + public HttpResponseMessage( + int statusCode, string statusMessage, string protocolVersion, + IEnumerable headers, Stream body + ) { + ProtocolVersion = protocolVersion ?? throw new ArgumentNullException(nameof(protocolVersion)); + StatusCode = statusCode; + StatusMessage = statusMessage ?? string.Empty; + Headers = (headers ?? throw new ArgumentNullException(nameof(headers))).ToArray(); + OwnsBodyStream = true; + Body = body; + } + + public byte[] GetBodyBytes() { + if(Body == null) + return null; + if(Body is MemoryStream msBody) + return msBody.ToArray(); + using MemoryStream ms = new(); + if(Body.CanSeek) + Body.Seek(0, SeekOrigin.Begin); + Body.CopyTo(ms); + return ms.ToArray(); + } + + public string GetBodyString() { + byte[] bytes = GetBodyBytes(); + return bytes == null || bytes.Length < 1 + ? string.Empty + : ResponseEncoding.GetString(bytes); + } + + // there's probably a less stupid way to do this, be my guest and call me an idiot + private static void ProcessEncoding(Stack encodings, Stream stream, bool transfer) { + using MemoryStream temp = new(); + bool inTemp = false; + + while(encodings.TryPop(out string encoding)) { + Stream target = (inTemp = !inTemp) ? temp : stream, + source = inTemp ? stream : temp; + + target.SetLength(0); + source.Seek(0, SeekOrigin.Begin); + + switch(encoding) { + case HttpEncoding.GZIP: + case HttpEncoding.XGZIP: + using(GZipStream gzs = new(source, CompressionMode.Decompress, true)) + gzs.CopyTo(target); + break; + + case HttpEncoding.DEFLATE: + using(DeflateStream def = new(source, CompressionMode.Decompress, true)) + def.CopyTo(target); + break; + + case HttpEncoding.BROTLI: + if(transfer) + goto default; + using(BrotliStream br = new(source, CompressionMode.Decompress, true)) + br.CopyTo(target); + break; + + case HttpEncoding.IDENTITY: + break; + + case HttpEncoding.CHUNKED: + if(!transfer) + goto default; + throw new IOException(@"Invalid use of chunked encoding type in Transfer-Encoding header."); + + default: + throw new IOException(@"Unsupported encoding supplied."); + } + } + + if(inTemp) { + stream.SetLength(0); + temp.Seek(0, SeekOrigin.Begin); + temp.CopyTo(stream); + } + } + + public static HttpResponseMessage ReadFrom(Stream stream, Action onProgress = null) { + // ignore this function, it doesn't exist + string readLine() { + const ushort crlf = 0x0D0A; + using MemoryStream ms = new(); + int byt; ushort lastTwo = 0; + + for(; ; ) { + byt = stream.ReadByte(); + if(byt == -1 && ms.Length == 0) + return null; + + ms.WriteByte((byte)byt); + + lastTwo <<= 8; + lastTwo |= (byte)byt; + if(lastTwo == crlf) { + ms.SetLength(ms.Length - 2); + break; + } + } + + return Encoding.ASCII.GetString(ms.ToArray()); + } + + long contentLength = -1; + Stack transferEncodings = null; + Stack contentEncodings = null; + + // Read initial header + string line = readLine(); + if(line == null) + throw new IOException(@"Failed to read initial HTTP header."); + if(!line.StartsWith(@"HTTP/")) + throw new IOException(@"Response is not a valid HTTP message."); + string[] parts = line[5..].Split(' ', 3); + if(!int.TryParse(parts.ElementAtOrDefault(1), out int statusCode)) + throw new IOException(@"Invalid HTTP status code format."); + string protocolVersion = parts.ElementAtOrDefault(0); + string statusMessage = parts.ElementAtOrDefault(2); + + // Read header key-value pairs + List headers = new(); + + while((line = readLine()) != null) { + if(string.IsNullOrWhiteSpace(line)) + break; + + parts = line.Split(':', 2, StringSplitOptions.TrimEntries); + if(parts.Length < 2) + throw new IOException(@"Invalid HTTP header in response."); + + string hName = HttpHeader.NormaliseName(parts.ElementAtOrDefault(0) ?? string.Empty), + hValue = parts.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(hName)) + throw new IOException(@"Invalid HTTP header name."); + + HttpHeader header = HttpHeader.Create(hName, hValue); + + if(header is HttpContentLengthHeader hclh) + contentLength = (long)hclh.Value; + else if(header is HttpTransferEncodingHeader hteh) + transferEncodings = new Stack(hteh.Encodings); + else if(header is HttpContentEncodingHeader hceh) + contentEncodings = new Stack(hceh.Encodings); + + headers.Add(header); + } + + if(statusCode is < 200 or 201 or 204 or 205) + contentLength = 0; + + Stream body = null; + long totalRead = 0; + const int buffer_size = 8192; + byte[] buffer = new byte[buffer_size]; + int read; + + void readBuffer(long length = -1) { + if(length == 0) + return; + long remaining = length; + int bufferRead = buffer_size; + if(bufferRead > length) + bufferRead = (int)length; + + if(totalRead < 1) + onProgress?.Invoke(0, contentLength); + + while((read = stream.Read(buffer, 0, bufferRead)) > 0) { + body.Write(buffer, 0, read); + + totalRead += read; + onProgress?.Invoke(totalRead, contentLength); + + if(length >= 0) { + remaining -= read; + if(remaining < 1) + break; + if(bufferRead > remaining) + bufferRead = (int)remaining; + } + } + } + + // Read body + if(transferEncodings != null && transferEncodings.Any() && transferEncodings.Peek() == HttpEncoding.CHUNKED) { + // oh no the poop is chunky + transferEncodings.Pop(); + body = new MemoryStream(); + + while((line = readLine()) != null) { + if(string.IsNullOrWhiteSpace(line)) + break; + if(!int.TryParse(line, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int chunkLength)) + throw new IOException(@"Failed to decode chunk length."); + if(chunkLength == 0) // final chunk + break; + readBuffer(chunkLength); + readLine(); + } + readLine(); + } else if(contentLength != 0) { + body = new MemoryStream(); + readBuffer(contentLength); + readLine(); + } + + if(body != null) + // Check if body is empty and null it again if so + if(body.Length == 0) { + body.Dispose(); + body = null; + } else { + if(transferEncodings != null) + ProcessEncoding(transferEncodings, body, true); + if(contentEncodings != null) + ProcessEncoding(contentEncodings, body, false); + + body.Seek(0, SeekOrigin.Begin); + } + + return new HttpResponseMessage(statusCode, statusMessage, protocolVersion, headers, body); + } + } +} diff --git a/Hamakaze/HttpTask.cs b/Hamakaze/HttpTask.cs new file mode 100644 index 0000000..ddcd212 --- /dev/null +++ b/Hamakaze/HttpTask.cs @@ -0,0 +1,189 @@ +using Hamakaze.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; + +namespace Hamakaze { + public class HttpTask { + public TaskState State { get; private set; } = TaskState.Initial; + + public bool IsStarted + => State != TaskState.Initial; + public bool IsFinished + => State == TaskState.Finished; + public bool IsCancelled + => State == TaskState.Cancelled; + public bool IsErrored + => Exception != null; + + public Exception Exception { get; private set; } + + public HttpRequestMessage Request { get; } + public HttpResponseMessage Response { get; private set; } + private HttpConnectionManager Connections { get; } + + private IEnumerable Addresses { get; set; } + private HttpConnection Connection { get; set; } + + public bool DisposeRequest { get; set; } + public bool DisposeResponse { get; set; } + + public event Action OnComplete; + public event Action OnError; + public event Action OnCancel; + public event Action OnUploadProgress; + public event Action OnDownloadProgress; + public event Action OnStateChange; + + public HttpTask(HttpConnectionManager conns, HttpRequestMessage request, bool disposeRequest, bool disposeResponse) { + Connections = conns ?? throw new ArgumentNullException(nameof(conns)); + Request = request ?? throw new ArgumentNullException(nameof(request)); + DisposeRequest = disposeRequest; + DisposeResponse = disposeResponse; + } + + public void Run() { + if(IsStarted) + throw new HttpTaskAlreadyStartedException(); + while(NextStep()); + } + + public void Cancel() { + State = TaskState.Cancelled; + OnStateChange?.Invoke(this, State); + OnCancel?.Invoke(this); + if(DisposeResponse) + Response?.Dispose(); + if(DisposeRequest) + Request?.Dispose(); + } + + private void Error(Exception ex) { + Exception = ex; + OnError?.Invoke(this, ex); + Cancel(); + } + + public bool NextStep() { + if(IsCancelled) + return false; + + switch(State) { + case TaskState.Initial: + State = TaskState.Lookup; + OnStateChange?.Invoke(this, State); + DoLookup(); + break; + case TaskState.Lookup: + State = TaskState.Request; + OnStateChange?.Invoke(this, State); + DoRequest(); + break; + case TaskState.Request: + State = TaskState.Response; + OnStateChange?.Invoke(this, State); + DoResponse(); + break; + case TaskState.Response: + State = TaskState.Finished; + OnStateChange?.Invoke(this, State); + OnComplete?.Invoke(this, Response); + if(DisposeResponse) + Response?.Dispose(); + if(DisposeRequest) + Request?.Dispose(); + return false; + default: + Error(new HttpTaskInvalidStateException()); + return false; + } + + return true; + } + + private void DoLookup() { + try { + Addresses = Dns.GetHostAddresses(Request.Host); + } catch(Exception ex) { + Error(ex); + return; + } + + if(!Addresses.Any()) + Error(new HttpTaskNoAddressesException()); + } + + private void DoRequest() { + Exception exception = null; + + try { + foreach(IPAddress addr in Addresses) { + int tries = 0; + IPEndPoint endPoint = new(addr, Request.Port); + + exception = null; + Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); + + retry: + ++tries; + try { + Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t)); + break; + } catch(IOException ex) { + Connection.Dispose(); + Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); + + if(tries < 2) + goto retry; + + exception = ex; + continue; + } finally { + Connection.MarkUsed(); + } + } + } catch(Exception ex) { + Error(ex); + } + + if(exception != null) + Error(exception); + else if(Connection == null) + Error(new HttpTaskNoConnectionException()); + } + + private void DoResponse() { + try { + Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t)); + } catch(Exception ex) { + Error(ex); + return; + } + + if(Response.Connection == HttpConnectionHeader.CLOSE) + Connection.Dispose(); + if(Response == null) + Error(new HttpTaskRequestFailedException()); + + HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast().FirstOrDefault(); + if(hkah != null) { + Connection.MaxIdle = hkah.MaxIdle; + Connection.MaxRequests = hkah.MaxRequests; + } + + Connection.Release(); + } + + public enum TaskState { + Initial = 0, + Lookup = 10, + Request = 20, + Response = 30, + Finished = 40, + + Cancelled = -1, + } + } +} diff --git a/Hamakaze/HttpTaskManager.cs b/Hamakaze/HttpTaskManager.cs new file mode 100644 index 0000000..ef12439 --- /dev/null +++ b/Hamakaze/HttpTaskManager.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; + +namespace Hamakaze { + public class HttpTaskManager : IDisposable { + private Semaphore Lock { get; set; } + + public HttpTaskManager(int maxThreads = 5) { + Lock = new Semaphore(maxThreads, maxThreads); + } + + public void RunTask(HttpTask task) { + if(task == null) + throw new ArgumentNullException(nameof(task)); + if(!Lock.WaitOne()) + throw new HttpTaskManagerLockException(); + new Thread(() => { + try { + task.Run(); + } finally { + Lock?.Release(); + } + }).Start(); + } + + private bool IsDisposed; + ~HttpTaskManager() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Lock.Dispose(); + Lock = null; + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5751ea4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Julian van de Groep + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Protocol-draft.md b/Protocol-draft.md new file mode 100644 index 0000000..b81aff3 --- /dev/null +++ b/Protocol-draft.md @@ -0,0 +1,1323 @@ +# Sock Chat Protocol Information +The protocol operates on a websocket in text mode. Messages sent between the client and server are a series of concatenated strings delimited by the vertical tab character, represented in most languages by the escape sequence `\t` and defined in ASCII as `0x09`. +The first string in this concatenation must be the packet identifier, sent as an `int`. + +Newer versions of the protocol are implemented as extensions, a client for Version 1 should have no trouble using a server built for Version 2 as long as authentication is understood. + +The current stable version of the protocol is **Version 1**. + +## Types + +### `bool` +A value that indicates a true or a false state. `0` represents false and anything non-`0` represents true, please stick to `1` for representing true though. + +### `int` +Any number ranging from `-9007199254740991` to `9007199254740991`, `Number.MAX_SAFE_INTEGER` and `Number.MIN_SAFE_INTEGER` in JavaScript. + +### `string` +Any printable unicode character, except `\t` which is used to separate packets. + +### `timestamp` +Extends `int`, contains a second based UNIX timestamp. + +### `channel name` +A `string` containing only alphanumeric characters (any case), `-` or `_`. + +### `session id` +A `string` containing only alphanumeric characters (any case), `-` or `_`. + +### `color` +Any valid value for the CSS `color` property. +Further documentation can be found [on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). + +### `message flags` +Message flags alter how a message should be displayed to the client, these are all `bool` values. +The parts are as follows: + + - Username should appear using a **bold** font. + - Username should appear using a *cursive* font. + - Username should appear __underlined__. + - A colon `:` should be displayed between the username and the message. + - The message was sent privately, directly to the current user. + +As an example, the most common message flagset is `10010`. This indicates a bold username with a colon separator. + +### `user permissions` +User permissions are a set of flags separated by either the form feed character (`\f` / `0x0C`) or a space ( / `0x20`). +The reason there are two options is due to a past mixup that we now have to live with. +Which of the methods is used remains consistent per server however, so the result of a test can be cached. + + + + + + + + + + + + + + + + + + + + + + + + + + + +
intRank of the user. Used to determine what channels a user can access or what other users the user can moderate.
boolIndicates whether the user the ability kick/ban/unban others.
boolIndicates whether the user can access the logs. This should always be 0, unless the client has a dedicated log view that can be accessed without connecting to the chat server.
boolIndicates whether the user can set an alternate display name.
intIndicates whether the user can create channel. If 0 the user cannot create channels, if 1 the user can create channels but they are to disappear when all users have left it and if 2 the user can create channels that permanently stay in the channel assortment.
+ +## Client Packets +These are the packets sent from the client to the server. + +### Packet `0`: Ping +Used to prevent the client from closing the session due to inactivity. + + + + + + + + + + + + +
intUser ID
timestampTime the packet was sent to the serverAdded in version 2
+ +### Packet `1`: Authentication +Takes a variable number of parameters that are fed into the authentication script associated with the chat. + + + + + + + +
...stringAny amount of data required to complete authentication
+ +### Packet `2`: Message +Informs the server that the user has sent a message. + +Commands are described lower in the document. + + + + + + + + + + + + +
intUser ID
stringMessage text, may not contain \t
+ +### Packet `3`: Focus Channel +Selects which channel messages should be sent to. + +Added in Version 2. + + + + + + + +
channel nameChannel to change focus to
+ +### Packet `4`: Typing +Informs the currently focussed channel that this client is writing a message. + +Added in Version 2. + +## Server Packets +These are the packets sent from the server to the client. + +### Packet `0`: Pong +Response to client packet `0`: Ping. + + + + + + + +
timestampTime the packet was sent to the clientOriginally this field contained the string pong, but this value was completely unused and can be safely replaced.
+ +### Packet `1`: Join/Auth +While authenticated this packet indicates that a new user has joined the server/channel. Before authentication this packet serves as a response to client packet `1`: Authentication. + +#### Successful authentication response +Informs the client that authentication has succeeded. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
stringy
intUser ID
stringUsername
colorUsername color
user permissionsUser permissions
channel nameDefault channel the user will join following this packet
intExtensions version number. If this field is missing, version 1 is implied.Added in Version 2
session idID of the currently active sessionAdded in Version 2
+ +#### Failed authentication response +Informs the client that authentication has failed. + + + + + + + + + + + + + + + + + +
stringn
string + Reason for failure. +
    +
  • authfail: Authentication data is invalid.
  • +
  • userfail: Username in use.
  • +
  • sockfail: A connection has already been started (used to cap maximum connections to 5 in SharpChat).
  • +
  • joinfail: User attempting to authenticate is banned.
  • +
+
timestampIf joinfail this contains expiration time of the ban, otherwise it is empty.
+ +#### User joining +Informs the client that a user has joined. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
timestampTime the user joined
intUser ID
stringUsername
colourUsername color
user permissionsUser permissions
intSequence ID
+ +### Packet `2`: Chat message +Informs the client that a chat message has been received. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
timestampTime when the message was received by the server
int + User ID. + If -1 this message is an informational message from the server and the next field takes on a special form. +
string +

Message, sanitised by the server

+

+ If this is an informational message this field is formatted as follows and concatenated by the form feed character \f, respresented in ASCII by 0x0C. Bot message formats are described lower in the document. + + + + + + + + + + + + + +
int + Message type. +
    +
  • 0 for a normal informational message.
  • +
  • 1 for an error.
  • +
+
stringAn id of a string in the legacy language files.
...stringAny number of parameters used to format the language string.
+

+
intSequence ID
message flagsMessage flags
channel nameChannel this message was sent inAdded in Version 2
+ +### Packet `3`: User disconnect +Informs the client that a user has disconnected. + + + + + + + + + + + + + + + + + + + + + + + + + + + +
intUser ID
stringUsername
string + One of four disconnect reasons. +
    +
  • leave: The user gracefully left, e.g. "x logged out".
  • +
  • timeout: The user lost connection unexpectedly, e.g. "x timed out".
  • +
  • kick: The user has been kicked, e.g. "x has been kicked".
  • +
  • flood: The user has been kicked for exceeding the flood protection limit, e.g. "x has been kicked for spam".
  • +
+
timestampTime when the user disconnected
intSequence ID
+ +### Packet `4`: Channel event +This packet informs the user about channel related updates. The only consistent parameter across sub-packets is the first one described as follows. + + + + + + + +
int + Channel information update event ID. +
    +
  • 0: A channel has been created.
  • +
  • 1: A channel has been updated.
  • +
  • 2: A channel has been deleted.
  • +
+
+ +#### Sub-packet `0`: Channel creation +Informs the client that a channel has been created. + + + + + + + + + + + + + + + + + +
channel nameThe name of the new channel
boolIndicates whether the channel is password protected
boolIndicates whether the channel is temporary, meaning the channel will be deleted after the last person departs
+ +#### Sub-packet `1`: Channel update +Informs the client that details of a channel has changed. + + + + + + + + + + + + + + + + + + + + + + +
channel nameOld/current name of the channel
channel nameNew name of the channel
boolIndicates whether the channel is password protected
boolIndicates whether the channel is temporary, meaning the channel will be deleted after the last person departs
+ +#### Sub-packet `2`: Channel deletion +Informs the client that a channel has been deleted + + + + + + + +
channel nameName of the channel that has been deleted
+ +### Packet `5`: Channel switching +This packet informs the client about channel switching. + + + + + + + +
int + Channel information update event ID. +
    +
  • 0: A user joined the channel. Sent to all users in said channel.
  • +
  • 1: A user left the channel. Sent to all users in said channel.
  • +
  • 2: The client is to forcibly join a channel.
  • +
+
+ +#### Sub-packet `0`: Channel join +Informs the client that a user has joined the channel. + + + + + + + + + + + + + + + + + + + + + + +
intUser ID
stringUsername
colorUsername color
intSequence ID
+ +#### Sub-packet `1`: Channel departure +Informs the client that a user has left the channel. + + + + + + + + + + + + +
intUser ID
intSequence ID
+ +#### Sub-packet `2`: Forced channel switch +Informs the client that it has been forcibly switched to a different channel. + + + + + + + +
channel nameThe name of the channel that the user has been switched to
+ +### Packet `6`: Message deletion +Informs the client that a message has been deleted. + + + + + + + +
intSequence ID of the deleted message
+ +### Packet `7`: Context information +Informs the client about the context of a channel before the client was present. + + + + + + + +
int + Context event ID. +
    +
  • 0: Users present in the current channel.
  • +
  • 1: A message already in the channel, occurs more than once per channel.
  • +
  • 2: Channels on the server.
  • +
+
+ +#### Sub-packet `0`: Existing users +Informs the client about users already present in the channel. + + + + + + + + + + + + +
intAmount of users present in the channel
Context UserAn amount of repetitions of this object based on the number in the previous param, the object is described below
+ +##### Context User object + + + + + + + + + + + + + + + + + + + + + + + + + + + +
intUser ID
stringUsername
colorUsername color
user permissionsUser permissions
boolWhether the user should be visible in the users list
+ +#### Sub-packet `1`: Existing message +Informs the client about an existing message in a channel. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
timestampTime when the message was received by the server
intUser ID
stringUsername
colorUsername color
user permissionsUser permissions
stringMessage text, functions the same as described in Packet 2: Chat message
intSequence ID
boolWhether the client should notify the user about this message
message flagsMessage flags
+ +#### Sub-packet `2`: Channels +Informs the client about the channels on the server. + + + + + + + + + + + + +
intAmount of channels on the channel
Context ChannelAn amount of repetitions of this object based on the number in the previous param, the object is described below
+ +##### Context Channel object + + + + + + + + + + + + + + + + + +
channel nameName of the channel
boolIndicates whether the channel is password protected
boolIndicates whether the channel is temporary, meaning the channel will be deleted after the last person departs
+ +### Packet `8`: Context clearing +Informs the client that the context has been cleared. + + + + + + + + + + + + +
int + Number indicating what has been cleared. +
    +
  • 0: The message list has been cleared.
  • +
  • 1: The user list has been cleared.
  • +
  • 2: The channel list has been cleared.
  • +
  • 3: Both the message and user listing have been cleared.
  • +
  • 4: The message, user and channel listing have all been cleared.
  • +
+
channel nameChannel this clear is targeted towards. Ignore packet if this is set and channels are supposedly to be cleared. If this field is empty this packet is intended for the entire context.Added in Version 2
+ +### Packet `9`: Forced disconnect +Informs the client that they have either been banned or kicked from the server. + + + + + + + + + + + + +
bool +
    +
  • 0: The client has been kicked, can reconnect immediately.
  • +
  • 1: The client has been banned, can reconnect after the timestamp (documented below) in the next param has expired.
  • +
+
timestampBan expiration time
+ +### Packet `10`: User update +Informs that another user's details have been updated. + + + + + + + + + + + + + + + + + + + + + + +
intUser ID of the affected user
stringNew username
colorNew username color
user permissionsUser permissions
+ +### Packet `11`: Typing +Informs the client that a user is typing. + +Added in version 2. + + + + + + + + + + + + + + + + + +
channel nameName of the channel in which the user is typing. If this field is empty, assume it has been sent to the user directly for private messaging.
intUser ID of the typing user
timestampTime when the user started typing.
+ +## Bot Messages +Formatting IDs sent by user -1. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Informational
StringDescriptionArguments
sayJust echoes the arguments in a message.The message.
silenceInforms the client that they have been silenced.
unsilInforms the client that they are no longer silenced.
silokInforms the client that they have successfully silenced another user.The username of the silenced user.
usilokInforms the client that they have successfully removed the silencing from another user.The username of the unsilenced user.
flwarnInforms the client that they are risking being kicking for flood protection (spam).
unbanInforms the client that they have successfully removed the ban from a user or ip address.
banlistProvides a list with banned users and IP addresses. + Sets of "<a href="javascript:void(0);" onclick="Chat.SendMessageWrapper('/unban '+ this.innerHTML);">{0}</a>" where {0} is the username of the banned user or the banned IP address. The set is separated by ", " +
whoProvides a list of users currently online. + Sets of "<a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);">{0}</a>" where {0} is the username of a user. The current online user is highlighted with " style="font-weight: bold;"" before the closing > of the opening <a> tag. The set is separated by ", " +
whochanProvides a list of users currently online in a specific channel. + Sets of "<a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);">{0}</a>" where {0} is the username of a user. The current online user is highlighted with " style="font-weight: bold;"" before the closing > of the opening <a> tag. The set is separated by ", " +
joinInforms the client that a user connected with the server.The username of said user.
jchanInforms the client that a user moved into the channel.The username of said user.
leaveInforms the client that a user disconnected from the server.The username of said user.
lchanInforms the client that a user moved out of the channel.The username of said user.
kickInforms the client that a user has disconnect because they got kicked.The username of said user.
floodInforms the client that a user has disconnect because they got kicked for flood protection.The username of said user.
nickInforms the client that a user has changed their nickname.The first argument is the previous username of said user, the second argument is their new username.
crchanInforms the client that they have successfully created a channel.The name of the channel.
delchanInforms the client that they have successfully deleted a channel.The name of the channel.
cpwdchanInforms the client that they have successfully changed the password of the channel.
cprivchanInforms the client that they have successfully changed the hierarchy level required for the channel.
ipaddrShows the IP address of another user.First argument is the username, second argument is the IP address.
Error
StringDescriptionArguments
generrGeneric fallback error.
silerrInforms the client that the user they tried to silence had already been silenced.
usilerrInforms the client that the user whose silence they tried to revoke hasn't been silenced.
silperrInforms the client that they are not allowed to silence that user.
usilperrInforms the client that they are not allowed to remove the silence from that user.
silselfInforms the client that they cannot silence themselves.
delerrInforms the client that they are not allowed to delete a message.
notbanInforms the client that a username or IP address is not banned.The provided username or IP address.
whoerrInforms the client that they do not have access to the channel they specified for the /who command.The provided channel name.
cmdnaTells the client they're not allowed to use a command.First argument is the name of the command.
nocmdTells the client the command they tried to run does not exist.First argument is the name of the command.
cmderrTells the client that they formatted the last command incorrectly.
usernfTells the client that the user they requested was not found on the server.The requested username.
kicknaTells the client that they are not allowed to kick a given user.Username of the user they tried to kick.
samechanTells the client that they are already in the channel they are trying to switch to.The name of the channel.
ipchanTells the client that they aren't allowed to switch to the provided channel.The name of the channel.
nochanTells the client that the channel they tried to switch to does not exist.The name of the channel.
nopwchanTells the client that the channel they tried to switch to requires a password.The name of the channel.
ipwchanTells the client that the password to tried to switch to the channel to was invalid.The name of the channel.
inchanInforms the client that the channel name contained invalid characters.
nischanTells the client that a channel with that name already exists.The name of the channel.
ndchanTells the client that they're not allowed to delete that channel.The name of the channel.
namchanTells the client that they're not allowed to edit that channel.The name of the channel.
nameinuseTells the client that the nickname they tried to use is already being used by someone else.The name.
rankerrTells the client that they're not allowed to do something to a user because they have a higher hierarchy than them.
+ +## Commands +Actions sent through messages prefixed with `/`. Arguments are described as `[name]`, optional arguments as `[name?]`. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameActionExpected bot messages
/afk [reason?]Marks the current user as afk, the first 5 characters from the user string are prefixed uppercase to the current username prefixed by &lt; and suffixed by &gt;_ resulting in a username that looks like <AWAY>_flash if /afk away is ran by the user flash. If no reason is specified "AFK" is used.
/nick [name?]Temporarily changes the user's nickname prefixed with ~. If the user's original name or no argument at all is specified the command returns the user's name to its original state without the prefix. +
    +
  • cmdna: Not allowed to change own nickname.
  • +
  • nameinuse: Someone else is using the username.
  • +
  • nick: Username has changed.
  • +
+
+ /msg [username] [message]
+ /whisper [username] [message] +
Sends a private message to another user. +
    +
  • cmderr: Missing username and/or message arguments.
  • +
  • usernf: User not found.
  • +
+
+ /me [message]
+ /action [message] +
Sends a message but with flags 11000 instead of the regular 10010, used to describe an action.
+ /who [channel?] + If no argument is specified it'll return all users on the server, if a channel is specified it'll return all users in that channel. +
    +
  • nochan: The given channel does not exist.
  • +
  • whoerr: The user does not have access to the channel.
  • +
  • whochan: Listing of users in a channel.
  • +
  • who: Listing of users.
  • +
+
+ /delete [channel name or message id] + If the argument is entirely numeric this function acts as an alias for /delmsg, otherwise /delchan.
+ /join [channel] + Switches the current user to a different channel. +
    +
  • nochan: The given channel does not exist.
  • +
  • ipchan: The user does not have access to the channel.
  • +
  • ipwchan: The provided password was invalid.
  • +
  • nopwchan: A password is required to enter the given channel.
  • +
+
+ /create [hierarchy?] [name] + Creates a new channel. +
    +
  • cmdna: Not allowed to create channels.
  • +
  • cmderr: Command isn't formatted correctly.
  • +
  • rankerr: Tried to set channel hierarchy higher than own.
  • +
  • inchan: Given name contained invalid characters.
  • +
  • nischan: A channel with the given name already exists.
  • +
  • crchan: Successfully created channel.
  • +
+
+ /delchan [name] + Deletes an existing channel. +
    +
  • cmderr: Command isn't formatted correctly.
  • +
  • nochan: No channel with the given name exists.
  • +
  • ndchan: Not allowed to delete this channel.
  • +
  • delchan: Deleted channel.
  • +
+
+ /password [password?]
+ /pwd [password?] +
Changes the password for a channel. Specify no argument to remove the password. +
    +
  • cmdna: Not allowed to change the password.
  • +
  • cpwdchan: Success.
  • +
+
+ /privilege [hierarchy]
+ /rank [hierarchy]
+ /priv [hierarchy] +
Changes what user hierarchy is required to enter a channel. +
    +
  • cmdna: Not allowed to change hierarchy.
  • +
  • rankerr: Missing rank argument or trying to set hierarchy to one higher than their own.
  • +
  • cprivchan: Success.
  • +
+
+ /say [message] + Broadcasts a message as the server to all users in all channels. +
    +
  • cmdna: Not allowed to broadcast.
  • +
  • say: Broadcast message.
  • +
+
+ /delmsg [message id] + Deletes a message. +
    +
  • cmdna: Not allowed to delete messages.
  • +
  • cmderr: Invalid arguments.
  • +
  • delerr: The message does not exist, or the user's hierarchy is lower than the sender.
  • +
+
+ /kick [user] [time?] + Kicks a user from the server. If no time is specified the kick expires immediately. +
    +
  • cmdna: Not allowed to kick users.
  • +
  • usernf: User not found.
  • +
  • kickna: Sender is trying to kick themselves, someone with a higher hierarchy or someone that's already banned.
  • +
  • cmderr: Provided time is invalid.
  • +
+
+ /ban [user] [time?] + Kicks a user and IP address from the server. If no time is specified the kick will never expire. +
    +
  • cmdna: Not allowed to kick users.
  • +
  • usernf: User not found.
  • +
  • kickna: Sender is trying to kick themselves, someone with a higher hierarchy or someone that's already banned.
  • +
  • cmderr: Provided time is invalid.
  • +
+
+ /unban [user]
+ /pardon [user] +
Revokes the ban of a user. +
    +
  • cmdna: Not allowed to revoke user bans.
  • +
  • notban: User is not banned.
  • +
  • unban: Success.
  • +
+
+ /unbanip [user]
+ /pardonip [user] +
Revokes the ban of an ip address. +
    +
  • cmdna: Not allowed to revoke ip bans.
  • +
  • notban: IP address is not banned.
  • +
  • unban: Success.
  • +
+
+ /bans
+ /banned +
Retrieves the list of banned users and ip addresses. +
    +
  • cmdna: Not allowed to revoke ip bans.
  • +
  • banlist: List of bans.
  • +
+
+ /silence [username] [time?] + Silences a user. If the time argument is not specified the silence is indefinite. +
    +
  • cmdna: Not allowed to silence users.
  • +
  • usernf: User not found.
  • +
  • silself: Tried to silence self.
  • +
  • silperr: Tried to silence user of higher hierarchy.
  • +
  • silerr: User is already silenced.
  • +
  • cmderr: Time isn't formatted correctly.
  • +
  • silence: Informs the user they have been silenced.
  • +
  • silok: Tells the sender that the user has been silenced.
  • +
+
+ /unsilence [username] + Revokes a silence. +
    +
  • cmdna: Not allowed to revoke silences.
  • +
  • usernf: User not found.
  • +
  • usilperr: Tried to revoke silence of user of higher hierarchy.
  • +
  • usilerr: User isn't silenced.
  • +
  • unsil: Informs the user that their silence has been revoked.
  • +
  • usilok: Tells the sender that the user's silence has been revoked.
  • +
+
+ /ip [username]
+ /whois [username] +
Gets a user's IP address. +
    +
  • cmdna: Not allowed to view IP addresses.
  • +
  • usernf: User not found.
  • +
  • ipaddr: IP address of user.
  • +
+
diff --git a/Protocol.md b/Protocol.md new file mode 100644 index 0000000..0e97508 --- /dev/null +++ b/Protocol.md @@ -0,0 +1,1225 @@ +# Sock Chat Protocol Information +The protocol operates on a websocket in text mode. Messages sent between the client and server are a series of concatenated strings delimited by the vertical tab character, represented in most languages by the escape sequence `\t` and defined in ASCII as `0x09`. +The first string in this concatenation must be the packet identifier, sent as an integer. The packet identifiers are as follows. + +Some instructions are specific to newer revisions of the protocol and some instructions behave differently in newer revisions, all versions are documented but it is recommended you use the latest one. If a packet is marked as deprecated and you only aim to implement the latest version, you may omit it in your implementation as it will never be sent. + +The current stable version of the protocol is **Version 1**. + +## Client +These are the packets sent from the client to the server. + +### Packet `0`: Ping +Used to prevent the client from closing the session due to inactivity. + + + + + + + + + +
Version 1
intUser ID
+ +### Packet `1`: Authentication +Takes a variable number of parameters that are fed into the authentication script associated with the chat. + + + + + + + + + +
Version 1
...anyAny amount of data required to complete authentication
+ +### Packet `2`: Message +Informs the server that the user has sent a message. + +Required commands for Version 1 are described lower in the document. + + + + + + + + + + + + + +
Version 1
intUser ID
...stringMessage text, additional packet parameters should be glued on the server using \t
+ +## Server +These are the packets sent from the server to the client. + +### Packet `0`: Pong +Response to client packet `0`: Ping. + + + + + + + + + +
Version 1
stringThe literal string pong
+ +### Packet `1`: Join/Auth +While authenticated this packet indicates that a new user has joined the server/channel. Before authentication this packet serves as a response to client packet `1`: Authentication. + +#### Successful authentication response +Informs the client that authentication has succeeded. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version 1
stringLiteral string y for yes
intSession User ID
stringUsername
stringCSS username color
permissions (string)User permissions, documented below
stringDefault channel the user will join following this packet
+ +#### Failed authentication response +Informs the client that authentication has failed. + + + + + + + + + + + + + + + + + +
Version 1
stringLiteral string n for no
string + Reason for failure. +
    +
  • authfail: Authentication data is invalid.
  • +
  • userfail: Username in use.
  • +
  • sockfail: A connection has already been started (used to cap maximum connections to 5 in SharpChat).
  • +
  • joinfail: User attempting to authenticate is banned.
  • +
+
intIf joinfail; A timestamp (documented below) indicating the length of the ban
+ +#### User joining +Informs the client that a user has joined. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version 1
intTimestamp, documented below of when the user joined
intUser ID
stringUsername
stringCSS username color
permissions (string)User permissions, documented below
intSequence ID
+ +### Packet `2`: Chat message +Informs the client that a chat message has been received. + + + + + + + + + + + + + + + + + + + + + + + + + +
Version 1
intTimestamp, documented below of when the message was received by the server
int + User ID. + If -1 this message is an informational message from the server and the next field takes on a special form. +
string +

Message, sanitised by the server

+

+ If this is an informational message this field is formatted as follows and concatenated by the form feed character \f, respresented in ASCII by 0x0C. Bot message formats are described lower in the document. + + + + + + + + + + + + + +
int + Message type. +
    +
  • 0 for a normal informational message.
  • +
  • 1 for an error.
  • +
+
stringAn id of a string in the legacy language files.
...stringAny number of parameters used to format the language string.
+

+
intSequence ID
message flagsMessage flags, documented below
+ +### Packet `3`: User disconnect +Informs the client that a user has disconnected. + + + + + + + + + + + + + + + + + + + + + + + + + +
Version 1
intUser ID
stringUsername
string + One of four disconnect reasons. +
    +
  • leave: The user gracefully left, e.g. "x logged out".
  • +
  • timeout: The user lost connection unexpectedly, e.g. "x timed out".
  • +
  • kick: The user has been kicked, e.g. "x has been kicked".
  • +
  • flood: The user has been kicked for exceeding the flood protection limit, e.g. "x has been kicked for spam".
  • +
+
intTimestamp, documented below of when the user disconnected
intSequence ID
+ +### Packet `4`: Channel event +This packet informs the user about channel related updates. The only consistent parameter across sub-packets is the first one described as follows. + + + + + + + + + +
Version 1
int + Channel information update event ID. +
    +
  • 0: A channel has been created.
  • +
  • 1: A channel has been updated.
  • +
  • 2: A channel has been deleted.
  • +
+
+ +#### Sub-packet `0`: Channel creation +Informs the client that a channel has been created. + + + + + + + + + + + + + + + + + +
Version 1
stringThe name of the new channel
boolIndicates whether the channel is password protected
boolIndicates whether the channel is temporary, meaning the channel will be deleted after the last person departs
+ +#### Sub-packet `1`: Channel update +Informs the client that details of a channel has changed. + + + + + + + + + + + + + + + + + + + + + +
Version 1
stringThe old/current name of the channel
stringThe new name of the channel
boolIndicates whether the channel is password protected
boolIndicates whether the channel is temporary, meaning the channel will be deleted after the last person departs
+ +#### Sub-packet `2`: Channel deletion +Informs the client that a channel has been deleted + + + + + + + + + +
Version 1
stringThe name of the channel that has been deleted
+ +### Packet `5`: Channel switching +This packet informs the client about channel switching. + + + + + + + + + +
Version 1
int + Channel information update event ID. +
    +
  • 0: A user joined the channel. Sent to all users in said channel.
  • +
  • 1: A user left the channel. Sent to all users in said channel.
  • +
  • 2: The client is to forcibly join a channel.
  • +
+
+ +#### Sub-packet `0`: Channel join +Informs the client that a user has joined the channel. + + + + + + + + + + + + + + + + + + + + + +
Version 1
intUser ID
stringUsername
stringCSS username color
intSequence ID
+ +#### Sub-packet `1`: Channel departure +Informs the client that a user has left the channel. + + + + + + + + + + + + + +
Version 1
intUser ID
intSequence ID
+ +#### Sub-packet `2`: Forced channel switch +Informs the client that it has been forcibly switched to a different channel. + + + + + + + + + +
Version 1
stringThe name of the channel that the user has been switched to
+ +### Packet `6`: Message deletion +Informs the client that a message has been deleted. + + + + + + + + + +
Version 1
intSequence ID of the deleted message
+ +### Packet `7`: Context information +Informs the client about the context of a channel before the client was present. + + + + + + + + + +
Version 1
int + Context event ID. +
    +
  • 0: Users present in the current channel.
  • +
  • 1: A message already in the channel, occurs more than once per channel.
  • +
  • 2: Channels on the server.
  • +
+
+ +#### Sub-packet `0`: Existing users +Informs the client about users already present in the channel. + + + + + + + + + + + + + +
Version 1
intAmount of users present in the channel
Context UserAn amount of repetitions of this object based on the number in the previous param, the object is described below
+ +##### Context User object + + + + + + + + + + + + + + + + + + + + + + + + + +
Version 1
intUser ID
stringUsername
stringCSS username color
permissions (string)User permissions, documented below
boolWhether the user should be visible in the users list
+ +#### Sub-packet `1`: Existing message +Informs the client about an existing message in a channel. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version 1
intTimestamp, documented below
intUser ID
stringUsername
stringCSS username color
permissions (string)User permissions, documented below
stringMessage text, functions the same as described in Packet 2: Chat message
intSequence ID
boolWhether the client should notify the user about this message
message flagsMessage flags, documented below
+ +#### Sub-packet `2`: Channels +Informs the client about the channels on the server. + + + + + + + + + + + + + +
Version 1
intAmount of channels on the channel
Context ChannelAn amount of repetitions of this object based on the number in the previous param, the object is described below
+ +##### Context Channel object + + + + + + + + + + + + + + + + + +
Version 1
stringChannel name
boolIndicates whether the channel is password protected
boolIndicates whether the channel is temporary, meaning the channel will be deleted after the last person departs
+ +### Packet `8`: Context clearing +Informs the client that the context has been cleared. + + + + + + + + + +
Version 1
int + Number indicating what has been cleared. +
    +
  • 0: The message list has been cleared.
  • +
  • 1: The user list has been cleared.
  • +
  • 2: The channel list has been cleared.
  • +
  • 3: Both the message and user listing have been cleared.
  • +
  • 4: The message, user and channel listing have all been cleared.
  • +
+
+ +### Packet `9`: Forced disconnect +Informs the client that they have either been banned or kicked from the server. + + + + + + + + + + + + + +
Version 1
bool +
    +
  • 0: The client has been kicked, can reconnect immediately.
  • +
  • 1: The client has been banned, can reconnect after the timestamp (documented below) in the next param has expired.
  • +
+
intA timestamp (documented below) containing the ban expiration date and time
+ +### Packet `10`: User update +Informs that another user's details have been updated. + + + + + + + + + + + + + + + + + + + + + +
Version 1
intUser ID of the affected user
stringNew username
stringNew CSS username color
permissions (string)User permissions, documented below
+ +## Timestamps + +Timestamps in Sock Chat are seconds elapsed since a certain date. Under Sock Chat V1 timestamps are regular Unix Epoch timestamps where `0` is `1970-01-01 00:00:00 UTC`. + +## User Permission String +The User Permission String consists out of five (5) parts concatenated by a space operator, indentified in most languages as the escape sequence and defined as the ASCII character `0x20`. +In the original specification it appeared as if custom permission flags were possible, these have always gone completely unused and should thus be avoided. +The parts are as follows: + +- An integer indicating the hierarchy of the user, this is used to determine whether a user has access to certain channels or is able to act out moderator actions upon certain users (lower can't affect higher). +- A boolean indicating whether the user has the ability to kick people. +- A boolean indicating whether the user has access to the logs, this should be zero unless the client has direct access to the message history without a connection the actual chat. +- A boolean indicating whether the user is able to change their nick/display name. +- An integer ranging from 0 to 2 indicating whether the user can create channels + - `0`: User cannot create channels. + - `1`: User can create channels, but only temporary ones. These _usually_ disappear after the last user left. + - `2`: User can create permanent channels. + +### Message Flags +The Message Flags alter how a message should be displayed to the client, these are all boolean values. +I'm not entirely sure if these allowed for custom flags, but much like the custom flags in the User Permission String, these have gone unused and should thus, also, be avoided. +The parts are as follows: + +- Username should appear using a **bold** font. +- Username should appear using a *cursive* font. +- Username should appear __underlined__. +- A colon `:` should be displayed between the username and the message. +- The message was sent privately, directly to the current user. + +As an example, the most common message flagset is `10010`. + +## Bot Messages + +Formatting IDs sent by user -1 in Version 1 of the protocol. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Informational
StringDescriptionArguments
sayJust echoes the arguments in a message.The message.
silenceInforms the client that they have been silenced.
unsilInforms the client that they are no longer silenced.
silokInforms the client that they have successfully silenced another user.The username of the silenced user.
usilokInforms the client that they have successfully removed the silencing from another user.The username of the unsilenced user.
flwarnInforms the client that they are risking being kicking for flood protection (spam).
unbanInforms the client that they have successfully removed the ban from a user or ip address.
banlistProvides a list with banned users and IP addresses. + Sets of "<a href="javascript:void(0);" onclick="Chat.SendMessageWrapper('/unban '+ this.innerHTML);">{0}</a>" where {0} is the username of the banned user or the banned IP address. The set is separated by ", " +
whoProvides a list of users currently online. + Sets of "<a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);">{0}</a>" where {0} is the username of a user. The current online user is highlighted with " style="font-weight: bold;"" before the closing > of the opening <a> tag. The set is separated by ", " +
whochanProvides a list of users currently online in a specific channel. + Sets of "<a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);">{0}</a>" where {0} is the username of a user. The current online user is highlighted with " style="font-weight: bold;"" before the closing > of the opening <a> tag. The set is separated by ", " +
joinInforms the client that a user connected with the server.The username of said user.
jchanInforms the client that a user moved into the channel.The username of said user.
leaveInforms the client that a user disconnected from the server.The username of said user.
lchanInforms the client that a user moved out of the channel.The username of said user.
kickInforms the client that a user has disconnect because they got kicked.The username of said user.
floodInforms the client that a user has disconnect because they got kicked for flood protection.The username of said user.
nickInforms the client that a user has changed their nickname.The first argument is the previous username of said user, the second argument is their new username.
crchanInforms the client that they have successfully created a channel.The name of the channel.
delchanInforms the client that they have successfully deleted a channel.The name of the channel.
cpwdchanInforms the client that they have successfully changed the password of the channel.
cprivchanInforms the client that they have successfully changed the hierarchy level required for the channel.
ipaddrShows the IP address of another user.First argument is the username, second argument is the IP address.
Error
StringDescriptionArguments
generrGeneric fallback error.
silerrInforms the client that the user they tried to silence had already been silenced.
usilerrInforms the client that the user whose silence they tried to revoke hasn't been silenced.
silperrInforms the client that they are not allowed to silence that user.
usilperrInforms the client that they are not allowed to remove the silence from that user.
silselfInforms the client that they cannot silence themselves.
delerrInforms the client that they are not allowed to delete a message.
notbanInforms the client that a username or IP address is not banned.The provided username or IP address.
whoerrInforms the client that they do not have access to the channel they specified for the /who command.The provided channel name.
cmdnaTells the client they're not allowed to use a command.First argument is the name of the command.
nocmdTells the client the command they tried to run does not exist.First argument is the name of the command.
cmderrTells the client that they formatted the last command incorrectly.
usernfTells the client that the user they requested was not found on the server.The requested username.
kicknaTells the client that they are not allowed to kick a given user.Username of the user they tried to kick.
samechanTells the client that they are already in the channel they are trying to switch to.The name of the channel.
ipchanTells the client that they aren't allowed to switch to the provided channel.The name of the channel.
nochanTells the client that the channel they tried to switch to does not exist.The name of the channel.
nopwchanTells the client that the channel they tried to switch to requires a password.The name of the channel.
ipwchanTells the client that the password to tried to switch to the channel to was invalid.The name of the channel.
inchanInforms the client that the channel name contained invalid characters.
nischanTells the client that a channel with that name already exists.The name of the channel.
ndchanTells the client that they're not allowed to delete that channel.The name of the channel.
namchanTells the client that they're not allowed to edit that channel.The name of the channel.
nameinuseTells the client that the nickname they tried to use is already being used by someone else.The name.
rankerrTells the client that they're not allowed to do something to a user because they have a higher hierarchy than them.
+ +## Commands + +Actions sent through messages prefixed with `/` in Version 1 of the protocol. Arguments are described as `[name]`, optional arguments as `[name?]`. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameActionExpected bot messages
/afk [reason?]Marks the current user as afk, the first 5 characters from the user string are prefixed uppercase to the current username prefixed by &lt; and suffixed by &gt;_ resulting in a username that looks like <AWAY>_flash if /afk away is ran by the user flash. If no reason is specified "AFK" is used.
/nick [name?]Temporarily changes the user's nickname prefixed with ~. If the user's original name or no argument at all is specified the command returns the user's name to its original state without the prefix. +
    +
  • cmdna: Not allowed to change own nickname.
  • +
  • nameinuse: Someone else is using the username.
  • +
  • nick: Username has changed.
  • +
+
+ /msg [username] [message]
+ /whisper [username] [message] +
Sends a private message to another user. +
    +
  • cmderr: Missing username and/or message arguments.
  • +
  • usernf: User not found.
  • +
+
+ /me [message]
+ /action [message] +
Sends a message but with flags 11000 instead of the regular 10010, used to describe an action.
+ /who [channel?] + If no argument is specified it'll return all users on the server, if a channel is specified it'll return all users in that channel. +
    +
  • nochan: The given channel does not exist.
  • +
  • whoerr: The user does not have access to the channel.
  • +
  • whochan: Listing of users in a channel.
  • +
  • who: Listing of users.
  • +
+
+ /delete [channel name or message id] + If the argument is entirely numeric this function acts as an alias for /delmsg, otherwise /delchan.
+ /join [channel] + Switches the current user to a different channel. +
    +
  • nochan: The given channel does not exist.
  • +
  • ipchan: The user does not have access to the channel.
  • +
  • ipwchan: The provided password was invalid.
  • +
  • nopwchan: A password is required to enter the given channel.
  • +
+
+ /create [hierarchy?] [name] + Creates a new channel. +
    +
  • cmdna: Not allowed to create channels.
  • +
  • cmderr: Command isn't formatted correctly.
  • +
  • rankerr: Tried to set channel hierarchy higher than own.
  • +
  • inchan: Given name contained invalid characters.
  • +
  • nischan: A channel with the given name already exists.
  • +
  • crchan: Successfully created channel.
  • +
+
+ /delchan [name] + Deletes an existing channel. +
    +
  • cmderr: Command isn't formatted correctly.
  • +
  • nochan: No channel with the given name exists.
  • +
  • ndchan: Not allowed to delete this channel.
  • +
  • delchan: Deleted channel.
  • +
+
+ /password [password?]
+ /pwd [password?] +
Changes the password for a channel. Specify no argument to remove the password. +
    +
  • cmdna: Not allowed to change the password.
  • +
  • cpwdchan: Success.
  • +
+
+ /privilege [hierarchy]
+ /rank [hierarchy]
+ /priv [hierarchy] +
Changes what user hierarchy is required to enter a channel. +
    +
  • cmdna: Not allowed to change hierarchy.
  • +
  • rankerr: Missing rank argument or trying to set hierarchy to one higher than their own.
  • +
  • cprivchan: Success.
  • +
+
+ /say [message] + Broadcasts a message as the server to all users in all channels. +
    +
  • cmdna: Not allowed to broadcast.
  • +
  • say: Broadcast message.
  • +
+
+ /delmsg [message id] + Deletes a message. +
    +
  • cmdna: Not allowed to delete messages.
  • +
  • cmderr: Invalid arguments.
  • +
  • delerr: The message does not exist, or the user's hierarchy is lower than the sender.
  • +
+
+ /kick [user] [time?] + Kicks a user from the server. If no time is specified the kick expires immediately. +
    +
  • cmdna: Not allowed to kick users.
  • +
  • usernf: User not found.
  • +
  • kickna: Sender is trying to kick themselves, someone with a higher hierarchy or someone that's already banned.
  • +
  • cmderr: Provided time is invalid.
  • +
+
+ /ban [user] [time?] + Kicks a user and IP address from the server. If no time is specified the kick will never expire. +
    +
  • cmdna: Not allowed to kick users.
  • +
  • usernf: User not found.
  • +
  • kickna: Sender is trying to kick themselves, someone with a higher hierarchy or someone that's already banned.
  • +
  • cmderr: Provided time is invalid.
  • +
+
+ /unban [user]
+ /pardon [user] +
Revokes the ban of a user. +
    +
  • cmdna: Not allowed to revoke user bans.
  • +
  • notban: User is not banned.
  • +
  • unban: Success.
  • +
+
+ /unbanip [user]
+ /pardonip [user] +
Revokes the ban of an ip address. +
    +
  • cmdna: Not allowed to revoke ip bans.
  • +
  • notban: IP address is not banned.
  • +
  • unban: Success.
  • +
+
+ /bans
+ /banned +
Retrieves the list of banned users and ip addresses. +
    +
  • cmdna: Not allowed to revoke ip bans.
  • +
  • banlist: List of bans.
  • +
+
+ /silence [username] [time?] + Silences a user. If the time argument is not specified the silence is indefinite. +
    +
  • cmdna: Not allowed to silence users.
  • +
  • usernf: User not found.
  • +
  • silself: Tried to silence self.
  • +
  • silperr: Tried to silence user of higher hierarchy.
  • +
  • silerr: User is already silenced.
  • +
  • cmderr: Time isn't formatted correctly.
  • +
  • silence: Informs the user they have been silenced.
  • +
  • silok: Tells the sender that the user has been silenced.
  • +
+
+ /unsilence [username] + Revokes a silence. +
    +
  • cmdna: Not allowed to revoke silences.
  • +
  • usernf: User not found.
  • +
  • usilperr: Tried to revoke silence of user of higher hierarchy.
  • +
  • usilerr: User isn't silenced.
  • +
  • unsil: Informs the user that their silence has been revoked.
  • +
  • usilok: Tells the sender that the user's silence has been revoked.
  • +
+
+ /ip [username]
+ /whois [username] +
Gets a user's IP address. +
    +
  • cmdna: Not allowed to view IP addresses.
  • +
  • usernf: User not found.
  • +
  • ipaddr: IP address of user.
  • +
+
diff --git a/README.md b/README.md new file mode 100644 index 0000000..156636f --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +``` + _____ __ ________ __ + / ___// /_ ____ __________ / ____/ /_ ____ _/ /_ + \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/ + ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ +/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ + /_/ +``` + +Welcome to the repository of the temporary Flashii chat server. This is a reimplementation of the old [PHP based Sock Chat server](https://github.com/flashwave/mahou-chat/) in C#. diff --git a/SharpChat.sln b/SharpChat.sln new file mode 100644 index 0000000..0140e92 --- /dev/null +++ b/SharpChat.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29025.244 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat", "SharpChat\SharpChat.csproj", "{DDB24C19-B802-4C96-AC15-0449C6FC77F2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hamakaze", "Hamakaze\Hamakaze.csproj", "{6059200F-141C-42A5-AA3F-E38C9721AEC8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.Build.0 = Release|Any CPU + {6059200F-141C-42A5-AA3F-E38C9721AEC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6059200F-141C-42A5-AA3F-E38C9721AEC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6059200F-141C-42A5-AA3F-E38C9721AEC8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6059200F-141C-42A5-AA3F-E38C9721AEC8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {42279FE1-5980-440A-87F8-25338DFE54CF} + EndGlobalSection +EndGlobal diff --git a/SharpChat/BanManager.cs b/SharpChat/BanManager.cs new file mode 100644 index 0000000..782c221 --- /dev/null +++ b/SharpChat/BanManager.cs @@ -0,0 +1,184 @@ +using SharpChat.Flashii; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace SharpChat { + public interface IBan { + DateTimeOffset Expires { get; } + string ToString(); + } + + public class BannedUser : IBan { + public long UserId { get; set; } + public DateTimeOffset Expires { get; set; } + public string Username { get; set; } + + public BannedUser() { + } + + public BannedUser(FlashiiBan fb) { + UserId = fb.UserId; + Expires = fb.Expires; + Username = fb.Username; + } + + public override string ToString() => Username; + } + + public class BannedIPAddress : IBan { + public IPAddress Address { get; set; } + public DateTimeOffset Expires { get; set; } + + public BannedIPAddress() { + } + + public BannedIPAddress(FlashiiBan fb) { + Address = IPAddress.Parse(fb.UserIP); + Expires = fb.Expires; + } + + public override string ToString() => Address.ToString(); + } + + public class BanManager : IDisposable { + private readonly List BanList = new List(); + + public readonly ChatContext Context; + + public bool IsDisposed { get; private set; } + + public BanManager(ChatContext context) { + Context = context; + RefreshFlashiiBans(); + } + + public void Add(ChatUser user, DateTimeOffset expires) { + if (expires <= DateTimeOffset.Now) + return; + + lock (BanList) { + BannedUser ban = BanList.OfType().FirstOrDefault(x => x.UserId == user.UserId); + + if (ban == null) + Add(new BannedUser { UserId = user.UserId, Expires = expires, Username = user.Username }); + else + ban.Expires = expires; + } + } + + public void Add(IPAddress addr, DateTimeOffset expires) { + if (expires <= DateTimeOffset.Now) + return; + + lock (BanList) { + BannedIPAddress ban = BanList.OfType().FirstOrDefault(x => x.Address.Equals(addr)); + + if (ban == null) + Add(new BannedIPAddress { Address = addr, Expires = expires }); + else + ban.Expires = expires; + } + } + + private void Add(IBan ban) { + if (ban == null) + return; + + lock (BanList) + if (!BanList.Contains(ban)) + BanList.Add(ban); + } + + public void Remove(ChatUser user) { + lock(BanList) + BanList.RemoveAll(x => x is BannedUser ub && ub.UserId == user.UserId); + } + + public void Remove(IPAddress addr) { + lock(BanList) + BanList.RemoveAll(x => x is BannedIPAddress ib && ib.Address.Equals(addr)); + } + + public void Remove(IBan ban) { + lock (BanList) + BanList.Remove(ban); + } + + public DateTimeOffset Check(ChatUser user) { + if (user == null) + return DateTimeOffset.MinValue; + + lock(BanList) + return BanList.OfType().Where(x => x.UserId == user.UserId).FirstOrDefault()?.Expires ?? DateTimeOffset.MinValue; + } + + public DateTimeOffset Check(IPAddress addr) { + if (addr == null) + return DateTimeOffset.MinValue; + + lock (BanList) + return BanList.OfType().Where(x => x.Address.Equals(addr)).FirstOrDefault()?.Expires ?? DateTimeOffset.MinValue; + } + + public BannedUser GetUser(string username) { + if (username == null) + return null; + + if (!long.TryParse(username, out long userId)) + userId = 0; + + lock (BanList) + return BanList.OfType().FirstOrDefault(x => x.Username.ToLowerInvariant() == username.ToLowerInvariant() || (userId > 0 && x.UserId == userId)); + } + + public BannedIPAddress GetIPAddress(IPAddress addr) { + lock (BanList) + return BanList.OfType().FirstOrDefault(x => x.Address.Equals(addr)); + } + + public void RemoveExpired() { + lock(BanList) + BanList.RemoveAll(x => x.Expires <= DateTimeOffset.Now); + } + + public void RefreshFlashiiBans() { + FlashiiBan.GetList(bans => { + if(!bans.Any()) + return; + + lock(BanList) { + foreach(FlashiiBan fb in bans) { + if(!BanList.OfType().Any(x => x.UserId == fb.UserId)) + Add(new BannedUser(fb)); + if(!BanList.OfType().Any(x => x.Address.ToString() == fb.UserIP)) + Add(new BannedIPAddress(fb)); + } + } + }, ex => Logger.Write($@"Ban Refresh: {ex}")); + } + + public IEnumerable All() { + lock (BanList) + return BanList.ToList(); + } + + ~BanManager() + => Dispose(false); + + public void Dispose() + => Dispose(true); + + private void Dispose(bool disposing) { + if (IsDisposed) + return; + IsDisposed = true; + + BanList.Clear(); + + if (disposing) + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat/ChannelManager.cs b/SharpChat/ChannelManager.cs new file mode 100644 index 0000000..bfac51f --- /dev/null +++ b/SharpChat/ChannelManager.cs @@ -0,0 +1,160 @@ +using SharpChat.Packet; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat { + public class ChannelException : Exception { } + public class ChannelExistException : ChannelException { } + public class ChannelInvalidNameException : ChannelException { } + + public class ChannelManager : IDisposable { + private readonly List Channels = new List(); + + public readonly ChatContext Context; + + public bool IsDisposed { get; private set; } + + public ChannelManager(ChatContext context) { + Context = context; + } + + private ChatChannel _DefaultChannel; + + public ChatChannel DefaultChannel { + get { + if (_DefaultChannel == null) + _DefaultChannel = Channels.FirstOrDefault(); + + return _DefaultChannel; + } + set { + if (value == null) + return; + + if (Channels.Contains(value)) + _DefaultChannel = value; + } + } + + + public void Add(ChatChannel channel) { + if (channel == null) + throw new ArgumentNullException(nameof(channel)); + if (!channel.Name.All(c => char.IsLetter(c) || char.IsNumber(c) || c == '-')) + throw new ChannelInvalidNameException(); + if (Get(channel.Name) != null) + throw new ChannelExistException(); + + // Add channel to the listing + Channels.Add(channel); + + // Set as default if there's none yet + if (_DefaultChannel == null) + _DefaultChannel = channel; + + // Broadcast creation of channel + foreach (ChatUser user in Context.Users.OfHierarchy(channel.Rank)) + user.Send(new ChannelCreatePacket(channel)); + } + + public void Remove(ChatChannel channel) { + if (channel == null || channel == DefaultChannel) + return; + + // Remove channel from the listing + Channels.Remove(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 (ChatUser user in channel.GetUsers()) { + Context.SwitchChannel(user, DefaultChannel, string.Empty); + } + + // Broadcast deletion of channel + foreach (ChatUser user in Context.Users.OfHierarchy(channel.Rank)) + user.Send(new ChannelDeletePacket(channel)); + } + + public bool Contains(ChatChannel chan) { + if (chan == null) + return false; + + lock (Channels) + return Channels.Contains(chan) || Channels.Any(c => c.Name.ToLowerInvariant() == chan.Name.ToLowerInvariant()); + } + + public void Update(ChatChannel channel, string name = null, bool? temporary = null, int? hierarchy = null, string password = null) { + if (channel == null) + throw new ArgumentNullException(nameof(channel)); + if (!Channels.Contains(channel)) + throw new ArgumentException(@"Provided channel is not registered with this manager.", nameof(channel)); + + string prevName = channel.Name; + int prevHierarchy = channel.Rank; + bool nameUpdated = !string.IsNullOrWhiteSpace(name) && name != prevName; + + if (nameUpdated) { + if (!name.All(c => char.IsLetter(c) || char.IsNumber(c) || c == '-')) + throw new ChannelInvalidNameException(); + if (Get(name) != null) + throw new ChannelExistException(); + + channel.Name = name; + } + + if (temporary.HasValue) + channel.IsTemporary = temporary.Value; + + if (hierarchy.HasValue) + channel.Rank = hierarchy.Value; + + if (password != null) + channel.Password = password; + + // 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 + foreach (ChatUser user in Context.Users.OfHierarchy(channel.Rank)) { + user.Send(new ChannelUpdatePacket(prevName, channel)); + + if (nameUpdated) + user.ForceChannel(); + } + } + + public ChatChannel Get(string name) { + if (string.IsNullOrWhiteSpace(name)) + return null; + + return Channels.FirstOrDefault(x => x.Name.ToLowerInvariant() == name.ToLowerInvariant()); + } + + public IEnumerable GetUser(ChatUser user) { + if (user == null) + return null; + + return Channels.Where(x => x.HasUser(user)); + } + + public IEnumerable OfHierarchy(int hierarchy) { + lock (Channels) + return Channels.Where(c => c.Rank <= hierarchy).ToList(); + } + + ~ChannelManager() + => Dispose(false); + + public void Dispose() + => Dispose(true); + + private void Dispose(bool disposing) { + if (IsDisposed) + return; + IsDisposed = true; + + Channels.Clear(); + + if (disposing) + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat/ChatChannel.cs b/SharpChat/ChatChannel.cs new file mode 100644 index 0000000..0e856b6 --- /dev/null +++ b/SharpChat/ChatChannel.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat { + public class ChatChannel : IPacketTarget { + public string Name { get; set; } + public string Password { get; set; } = string.Empty; + public bool IsTemporary { get; set; } = false; + public int Rank { get; set; } = 0; + public ChatUser Owner { get; set; } = null; + + private List Users { get; } = new List(); + private List Typing { get; } = new List(); + + public bool HasPassword + => !string.IsNullOrWhiteSpace(Password); + + public string TargetName => Name; + + public ChatChannel() { + } + + public ChatChannel(string name) { + Name = name; + } + + public bool HasUser(ChatUser user) { + lock (Users) + return Users.Contains(user); + } + + public void UserJoin(ChatUser user) { + if (!user.InChannel(this)) { + // Remove this, a different means for this should be established for V1 compat. + user.Channel?.UserLeave(user); + user.JoinChannel(this); + } + + lock (Users) { + if (!HasUser(user)) + Users.Add(user); + } + } + + public void UserLeave(ChatUser user) { + lock (Users) + Users.Remove(user); + + if (user.InChannel(this)) + user.LeaveChannel(this); + } + + public void Send(IServerPacket packet) { + lock (Users) { + foreach (ChatUser user in Users) + user.Send(packet); + } + } + + public IEnumerable GetUsers(IEnumerable exclude = null) { + lock (Users) { + IEnumerable users = Users.OrderByDescending(x => x.Rank); + + if (exclude != null) + users = users.Except(exclude); + + return users.ToList(); + } + } + + public bool IsTyping(ChatUser user) { + if(user == null) + return false; + lock(Typing) + return Typing.Any(x => x.User == user && !x.HasExpired); + } + public bool CanType(ChatUser user) { + if(user == null || !HasUser(user)) + return false; + return !IsTyping(user); + } + public ChatChannelTyping RegisterTyping(ChatUser user) { + if(user == null || !HasUser(user)) + return null; + ChatChannelTyping typing = new ChatChannelTyping(user); + lock(Typing) { + Typing.RemoveAll(x => x.HasExpired); + Typing.Add(typing); + } + return typing; + } + + public string Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append(Name); + sb.Append('\t'); + sb.Append(string.IsNullOrEmpty(Password) ? '0' : '1'); + sb.Append('\t'); + sb.Append(IsTemporary ? '1' : '0'); + + return sb.ToString(); + } + } +} diff --git a/SharpChat/ChatChannelTyping.cs b/SharpChat/ChatChannelTyping.cs new file mode 100644 index 0000000..6e55ae4 --- /dev/null +++ b/SharpChat/ChatChannelTyping.cs @@ -0,0 +1,18 @@ +using System; + +namespace SharpChat { + public class ChatChannelTyping { + public static TimeSpan Lifetime { get; } = TimeSpan.FromSeconds(5); + + public ChatUser User { get; } + public DateTimeOffset Started { get; } + + public bool HasExpired + => DateTimeOffset.Now - Started > Lifetime; + + public ChatChannelTyping(ChatUser user) { + User = user ?? throw new ArgumentNullException(nameof(user)); + Started = DateTimeOffset.Now; + } + } +} diff --git a/SharpChat/ChatColour.cs b/SharpChat/ChatColour.cs new file mode 100644 index 0000000..c80ca90 --- /dev/null +++ b/SharpChat/ChatColour.cs @@ -0,0 +1,55 @@ +namespace SharpChat { + public class ChatColour { + public const int INHERIT = 0x40000000; + + public int Raw { get; set; } + + public ChatColour(bool inherit = true) { + Inherit = inherit; + } + + public ChatColour(int colour) { + Raw = colour; + } + + public bool Inherit { + get => (Raw & INHERIT) > 0; + set { + if (value) + Raw |= INHERIT; + else + Raw &= ~INHERIT; + } + } + + public int Red { + get => (Raw >> 16) & 0xFF; + set { + Raw &= ~0xFF0000; + Raw |= (value & 0xFF) << 16; + } + } + + public int Green { + get => (Raw >> 8) & 0xFF; + set { + Raw &= ~0xFF00; + Raw |= (value & 0xFF) << 8; + } + } + + public int Blue { + get => Raw & 0xFF; + set { + Raw &= ~0xFF; + Raw |= value & 0xFF; + } + } + + public override string ToString() { + if (Inherit) + return @"inherit"; + return string.Format(@"#{0:X6}", Raw); + } + } +} diff --git a/SharpChat/ChatContext.cs b/SharpChat/ChatContext.cs new file mode 100644 index 0000000..5e4ad95 --- /dev/null +++ b/SharpChat/ChatContext.cs @@ -0,0 +1,190 @@ +using SharpChat.Events; +using SharpChat.Flashii; +using SharpChat.Packet; +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; + +namespace SharpChat { + public class ChatContext : IDisposable, IPacketTarget { + public bool IsDisposed { get; private set; } + + public SockChatServer Server { get; } + public Timer BumpTimer { get; } + public BanManager Bans { get; } + public ChannelManager Channels { get; } + public UserManager Users { get; } + public ChatEventManager Events { get; } + + public string TargetName => @"@broadcast"; + + public ChatContext(SockChatServer server) { + Server = server; + Bans = new BanManager(this); + Users = new UserManager(this); + Channels = new ChannelManager(this); + Events = new ChatEventManager(this); + + BumpTimer = new Timer(e => FlashiiBump.Submit(Users.WithActiveConnections()), null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); + } + + public void Update() { + Bans.RemoveExpired(); + CheckPings(); + } + + public void BanUser(ChatUser user, DateTimeOffset? until = null, bool banIPs = false, UserDisconnectReason reason = UserDisconnectReason.Kicked) { + if (until.HasValue && until.Value <= DateTimeOffset.UtcNow) + until = null; + + if (until.HasValue) { + user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Banned, until.Value)); + Bans.Add(user, until.Value); + + if (banIPs) { + foreach (IPAddress ip in user.RemoteAddresses) + Bans.Add(ip, until.Value); + } + } else + user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Kicked)); + + user.Close(); + UserLeave(user.Channel, user, reason); + } + + public void HandleJoin(ChatUser user, ChatChannel chan, ChatUserSession sess) { + if (!chan.HasUser(user)) { + chan.Send(new UserConnectPacket(DateTimeOffset.Now, user)); + Events.Add(new UserConnectEvent(DateTimeOffset.Now, user, chan)); + } + + sess.Send(new AuthSuccessPacket(user, chan, sess)); + sess.Send(new ContextUsersPacket(chan.GetUsers(new[] { user }))); + + IEnumerable msgs = Events.GetTargetLog(chan); + + foreach(IChatEvent msg in msgs) + sess.Send(new ContextMessagePacket(msg)); + + sess.Send(new ContextChannelsPacket(Channels.OfHierarchy(user.Rank))); + + if (!chan.HasUser(user)) + chan.UserJoin(user); + + if (!Users.Contains(user)) + Users.Add(user); + } + + public void UserLeave(ChatChannel chan, ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) { + user.Status = ChatUserStatus.Offline; + + if (chan == null) { + foreach(ChatChannel channel in user.GetChannels()) { + UserLeave(channel, user, reason); + } + return; + } + + if (chan.IsTemporary && chan.Owner == user) + Channels.Remove(chan); + + chan.UserLeave(user); + chan.Send(new UserDisconnectPacket(DateTimeOffset.Now, user, reason)); + Events.Add(new UserDisconnectEvent(DateTimeOffset.Now, user, chan, reason)); + } + + public void SwitchChannel(ChatUser user, ChatChannel chan, string password) { + if (user.CurrentChannel == chan) { + //user.Send(true, @"samechan", chan.Name); + user.ForceChannel(); + return; + } + + if (!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.Owner != user) { + if (chan.Rank > user.Rank) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name)); + user.ForceChannel(); + return; + } + + if (chan.Password != password) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name)); + user.ForceChannel(); + return; + } + } + + ForceChannelSwitch(user, chan); + } + + public void ForceChannelSwitch(ChatUser user, ChatChannel chan) { + if (!Channels.Contains(chan)) + return; + + ChatChannel oldChan = user.CurrentChannel; + + oldChan.Send(new UserChannelLeavePacket(user)); + Events.Add(new UserChannelLeaveEvent(DateTimeOffset.Now, user, oldChan)); + chan.Send(new UserChannelJoinPacket(user)); + Events.Add(new UserChannelJoinEvent(DateTimeOffset.Now, user, chan)); + + user.Send(new ContextClearPacket(chan, ContextClearMode.MessagesUsers)); + user.Send(new ContextUsersPacket(chan.GetUsers(new[] { user }))); + + IEnumerable msgs = Events.GetTargetLog(chan); + + foreach (IChatEvent msg in msgs) + user.Send(new ContextMessagePacket(msg)); + + user.ForceChannel(chan); + oldChan.UserLeave(user); + chan.UserJoin(user); + + if (oldChan.IsTemporary && oldChan.Owner == user) + Channels.Remove(oldChan); + } + + public void CheckPings() { + lock(Users) + foreach (ChatUser user in Users.All()) { + IEnumerable timedOut = user.GetDeadSessions(); + + foreach(ChatUserSession sess in timedOut) { + user.RemoveSession(sess); + sess.Dispose(); + Logger.Write($@"Nuked session {sess.Id} from {user.Username} (timeout)"); + } + + if(!user.HasSessions) + UserLeave(null, user, UserDisconnectReason.TimeOut); + } + } + + public void Send(IServerPacket packet) { + foreach (ChatUser user in Users.All()) + user.Send(packet); + } + + ~ChatContext() + => Dispose(false); + + public void Dispose() + => Dispose(true); + + private void Dispose(bool disposing) { + if (IsDisposed) + return; + IsDisposed = true; + + BumpTimer?.Dispose(); + Events?.Dispose(); + Channels?.Dispose(); + Users?.Dispose(); + Bans?.Dispose(); + + if (disposing) + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat/ChatEventManager.cs b/SharpChat/ChatEventManager.cs new file mode 100644 index 0000000..35bc3a3 --- /dev/null +++ b/SharpChat/ChatEventManager.cs @@ -0,0 +1,100 @@ +using SharpChat.Events; +using SharpChat.Packet; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat { + public class ChatEventManager : IDisposable { + private readonly List Events = null; + + public readonly ChatContext Context; + + public bool IsDisposed { get; private set; } + + public ChatEventManager(ChatContext context) { + Context = context; + + if (!Database.HasDatabase) + Events = new List(); + } + + public void Add(IChatEvent evt) { + if (evt == null) + throw new ArgumentNullException(nameof(evt)); + + if(Events != null) + lock(Events) + Events.Add(evt); + + if(Database.HasDatabase) + Database.LogEvent(evt); + } + + public void Remove(IChatEvent evt) { + if (evt == null) + return; + + if (Events != null) + lock (Events) + Events.Remove(evt); + + if (Database.HasDatabase) + Database.DeleteEvent(evt); + + Context.Send(new ChatMessageDeletePacket(evt.SequenceId)); + } + + public IChatEvent Get(long seqId) { + if (seqId < 1) + return null; + + if (Database.HasDatabase) + return Database.GetEvent(seqId); + + if (Events != null) + lock (Events) + return Events.FirstOrDefault(e => e.SequenceId == seqId); + + return null; + } + + public IEnumerable GetTargetLog(IPacketTarget target, int amount = 20, int offset = 0) { + if (Database.HasDatabase) + return Database.GetEvents(target, amount, offset).Reverse(); + + if (Events != null) + lock (Events) { + IEnumerable subset = Events.Where(e => e.Target == target || e.Target == null); + + int start = subset.Count() - offset - amount; + + if(start < 0) { + amount += start; + start = 0; + } + + return subset.Skip(start).Take(amount).ToList(); + } + + return Enumerable.Empty(); + } + + ~ChatEventManager() + => Dispose(false); + + public void Dispose() + => Dispose(true); + + private void Dispose(bool disposing) { + if (IsDisposed) + return; + IsDisposed = true; + + Events?.Clear(); + + if (disposing) + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat/ChatRateLimiter.cs b/SharpChat/ChatRateLimiter.cs new file mode 100644 index 0000000..1806e7d --- /dev/null +++ b/SharpChat/ChatRateLimiter.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat { + public enum ChatRateLimitState { + None, + Warning, + Kick, + } + + public class ChatRateLimiter { + private const int FLOOD_PROTECTION_AMOUNT = 30; + private const int FLOOD_PROTECTION_THRESHOLD = 10; + + private readonly Queue TimePoints = new Queue(); + + public ChatRateLimitState State { + get { + lock (TimePoints) { + if (TimePoints.Count == FLOOD_PROTECTION_AMOUNT) { + if ((TimePoints.Last() - TimePoints.First()).TotalSeconds <= FLOOD_PROTECTION_THRESHOLD) + return ChatRateLimitState.Kick; + + if ((TimePoints.Last() - TimePoints.Skip(5).First()).TotalSeconds <= FLOOD_PROTECTION_THRESHOLD) + return ChatRateLimitState.Warning; + } + + return ChatRateLimitState.None; + } + } + } + + public void AddTimePoint(DateTimeOffset? dto = null) { + if (!dto.HasValue) + dto = DateTimeOffset.Now; + + lock (TimePoints) { + if (TimePoints.Count >= FLOOD_PROTECTION_AMOUNT) + TimePoints.Dequeue(); + + TimePoints.Enqueue(dto.Value); + } + } + } +} diff --git a/SharpChat/ChatUser.cs b/SharpChat/ChatUser.cs new file mode 100644 index 0000000..769542c --- /dev/null +++ b/SharpChat/ChatUser.cs @@ -0,0 +1,216 @@ +using SharpChat.Flashii; +using SharpChat.Packet; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Text; + +namespace SharpChat { + public class BasicUser : IEquatable { + private const int RANK_NO_FLOOD = 9; + + public long UserId { get; set; } + public string Username { get; set; } + public ChatColour Colour { get; set; } + public int Rank { get; set; } + public string Nickname { get; set; } + public ChatUserPermissions Permissions { get; set; } + public ChatUserStatus Status { get; set; } = ChatUserStatus.Online; + public string StatusMessage { get; set; } + + public bool HasFloodProtection + => Rank < RANK_NO_FLOOD; + + public bool Equals([AllowNull] BasicUser other) + => UserId == other.UserId; + + public string DisplayName { + get { + StringBuilder sb = new StringBuilder(); + + if(Status == ChatUserStatus.Away) + sb.AppendFormat(@"<{0}>_", StatusMessage.Substring(0, Math.Min(StatusMessage.Length, 5)).ToUpperInvariant()); + + if(string.IsNullOrWhiteSpace(Nickname)) + sb.Append(Username); + else { + sb.Append('~'); + sb.Append(Nickname); + } + + return sb.ToString(); + } + } + + public bool Can(ChatUserPermissions perm, bool strict = false) { + ChatUserPermissions perms = Permissions & perm; + return strict ? perms == perm : perms > 0; + } + + public string Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append(UserId); + sb.Append('\t'); + sb.Append(DisplayName); + sb.Append('\t'); + sb.Append(Colour); + sb.Append('\t'); + sb.Append(Rank); + sb.Append(' '); + sb.Append(Can(ChatUserPermissions.KickUser) ? '1' : '0'); + sb.Append(@" 0 "); + sb.Append(Can(ChatUserPermissions.SetOwnNickname) ? '1' : '0'); + sb.Append(' '); + sb.Append(Can(ChatUserPermissions.CreateChannel | ChatUserPermissions.SetChannelPermanent, true) ? 2 : ( + Can(ChatUserPermissions.CreateChannel) ? 1 : 0 + )); + + return sb.ToString(); + } + } + + public class ChatUser : BasicUser, IPacketTarget { + public DateTimeOffset SilencedUntil { get; set; } + + private readonly List Sessions = new List(); + private readonly List Channels = new List(); + + public readonly ChatRateLimiter RateLimiter = new ChatRateLimiter(); + + public string TargetName => @"@log"; + + [Obsolete] + public ChatChannel Channel { + get { + lock(Channels) + return Channels.FirstOrDefault(); + } + } + + // This needs to be a session thing + public ChatChannel CurrentChannel { get; private set; } + + public bool IsSilenced + => DateTimeOffset.UtcNow - SilencedUntil <= TimeSpan.Zero; + + public bool HasSessions { + get { + lock(Sessions) + return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Any(); + } + } + + public int SessionCount { + get { + lock (Sessions) + return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Count(); + } + } + + public IEnumerable RemoteAddresses { + get { + lock(Sessions) + return Sessions.Select(c => c.RemoteAddress); + } + } + + public ChatUser() { + } + + public ChatUser(FlashiiAuth auth) { + UserId = auth.UserId; + ApplyAuth(auth, true); + } + + public void ApplyAuth(FlashiiAuth auth, bool invalidateRestrictions = false) { + Username = auth.Username; + + if (Status == ChatUserStatus.Offline) + Status = ChatUserStatus.Online; + + Colour = new ChatColour(auth.ColourRaw); + Rank = auth.Rank; + Permissions = auth.Permissions; + + if (invalidateRestrictions || !IsSilenced) + SilencedUntil = auth.SilencedUntil; + } + + public void Send(IServerPacket packet) { + lock(Sessions) + foreach (ChatUserSession conn in Sessions) + conn.Send(packet); + } + + public void Close() { + lock (Sessions) { + foreach (ChatUserSession conn in Sessions) + conn.Dispose(); + Sessions.Clear(); + } + } + + public void ForceChannel(ChatChannel chan = null) + => Send(new UserChannelForceJoinPacket(chan ?? CurrentChannel)); + + public void FocusChannel(ChatChannel chan) { + lock(Channels) { + if(InChannel(chan)) + CurrentChannel = chan; + } + } + + public bool InChannel(ChatChannel chan) { + lock (Channels) + return Channels.Contains(chan); + } + + public void JoinChannel(ChatChannel chan) { + lock (Channels) { + if(!InChannel(chan)) { + Channels.Add(chan); + CurrentChannel = chan; + } + } + } + + public void LeaveChannel(ChatChannel chan) { + lock(Channels) { + Channels.Remove(chan); + CurrentChannel = Channels.FirstOrDefault(); + } + } + + public IEnumerable GetChannels() { + lock (Channels) + return Channels.ToList(); + } + + public void AddSession(ChatUserSession sess) { + if (sess == null) + return; + sess.User = this; + + lock (Sessions) + Sessions.Add(sess); + } + + public void RemoveSession(ChatUserSession sess) { + if (sess == null) + return; + if(!sess.IsDisposed) // this could be possible + sess.User = null; + + lock(Sessions) + Sessions.Remove(sess); + } + + public IEnumerable GetDeadSessions() { + lock (Sessions) + return Sessions.Where(x => x.HasTimedOut || x.IsDisposed).ToList(); + } + } +} diff --git a/SharpChat/ChatUserPermissions.cs b/SharpChat/ChatUserPermissions.cs new file mode 100644 index 0000000..49901e1 --- /dev/null +++ b/SharpChat/ChatUserPermissions.cs @@ -0,0 +1,25 @@ +using System; + +namespace SharpChat { + [Flags] + public enum ChatUserPermissions : int { + KickUser = 0x00000001, + BanUser = 0x00000002, + SilenceUser = 0x00000004, + Broadcast = 0x00000008, + SetOwnNickname = 0x00000010, + SetOthersNickname = 0x00000020, + CreateChannel = 0x00000040, + DeleteChannel = 0x00010000, + SetChannelPermanent = 0x00000080, + SetChannelPassword = 0x00000100, + SetChannelHierarchy = 0x00000200, + JoinAnyChannel = 0x00020000, + SendMessage = 0x00000400, + DeleteOwnMessage = 0x00000800, + DeleteAnyMessage = 0x00001000, + EditOwnMessage = 0x00002000, + EditAnyMessage = 0x00004000, + SeeIPAddress = 0x00008000, + } +} diff --git a/SharpChat/ChatUserSession.cs b/SharpChat/ChatUserSession.cs new file mode 100644 index 0000000..69c6658 --- /dev/null +++ b/SharpChat/ChatUserSession.cs @@ -0,0 +1,89 @@ +using Fleck; +using System; +using System.Collections.Generic; +using System.Net; + +namespace SharpChat { + public class ChatUserSession : IDisposable, IPacketTarget { + public const int ID_LENGTH = 32; + +#if DEBUG + public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1); +#else + public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5); +#endif + + public IWebSocketConnection Connection { get; } + + public string Id { get; private set; } + public bool IsDisposed { get; private set; } + public DateTimeOffset LastPing { get; set; } = DateTimeOffset.MinValue; + public ChatUser User { get; set; } + + public string TargetName => @"@log"; + + + private IPAddress _RemoteAddress = null; + + public IPAddress RemoteAddress { + get { + if (_RemoteAddress == null) { + if ((Connection.ConnectionInfo.ClientIpAddress == @"127.0.0.1" || Connection.ConnectionInfo.ClientIpAddress == @"::1") + && Connection.ConnectionInfo.Headers.ContainsKey(@"X-Real-IP")) + _RemoteAddress = IPAddress.Parse(Connection.ConnectionInfo.Headers[@"X-Real-IP"]); + else + _RemoteAddress = IPAddress.Parse(Connection.ConnectionInfo.ClientIpAddress); + } + + return _RemoteAddress; + + } + } + + public ChatUserSession(IWebSocketConnection ws) { + Connection = ws; + Id = GenerateId(); + } + + private static string GenerateId() { + byte[] buffer = new byte[ID_LENGTH]; + RNG.NextBytes(buffer); + return buffer.GetIdString(); + } + + public void Send(IServerPacket packet) { + if (!Connection.IsAvailable) + return; + + IEnumerable data = packet.Pack(); + + if (data != null) + foreach (string line in data) + if (!string.IsNullOrWhiteSpace(line)) + Connection.Send(line); + } + + public void BumpPing() + => LastPing = DateTimeOffset.Now; + + public bool HasTimedOut + => DateTimeOffset.Now - LastPing > SessionTimeOut; + + public void Dispose() + => Dispose(true); + + ~ChatUserSession() + => Dispose(false); + + private void Dispose(bool disposing) { + if (IsDisposed) + return; + + IsDisposed = true; + Connection.Close(); + + if (disposing) + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat/ChatUserStatus.cs b/SharpChat/ChatUserStatus.cs new file mode 100644 index 0000000..63cf945 --- /dev/null +++ b/SharpChat/ChatUserStatus.cs @@ -0,0 +1,7 @@ +namespace SharpChat { + public enum ChatUserStatus { + Online, + Away, + Offline, + } +} diff --git a/SharpChat/Commands/AFKCommand.cs b/SharpChat/Commands/AFKCommand.cs new file mode 100644 index 0000000..de30586 --- /dev/null +++ b/SharpChat/Commands/AFKCommand.cs @@ -0,0 +1,31 @@ +using SharpChat.Events; +using SharpChat.Packet; +using System.Linq; + +namespace SharpChat.Commands { + public class AFKCommand : IChatCommand { + private const string DEFAULT = @"AFK"; + private const int MAX_LENGTH = 5; + + public bool IsMatch(string name) { + return name == @"afk"; + } + + public IChatMessage Dispatch(IChatCommandContext context) { + string statusText = context.Args.ElementAtOrDefault(1); + if(string.IsNullOrWhiteSpace(statusText)) + statusText = DEFAULT; + else { + statusText = statusText.Trim(); + if(statusText.Length > MAX_LENGTH) + statusText = statusText.Substring(0, MAX_LENGTH).Trim(); + } + + context.User.Status = ChatUserStatus.Away; + context.User.StatusMessage = statusText; + context.Channel.Send(new UserUpdatePacket(context.User)); + + return null; + } + } +} diff --git a/SharpChat/Database.cs b/SharpChat/Database.cs new file mode 100644 index 0000000..5b28e3e --- /dev/null +++ b/SharpChat/Database.cs @@ -0,0 +1,230 @@ +using MySqlConnector; +using SharpChat.Events; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace SharpChat { + public static partial class Database { + private static string ConnectionString = null; + + public static bool HasDatabase + => !string.IsNullOrWhiteSpace(ConnectionString); + + public static void ReadConfig() { + if(!File.Exists(@"mariadb.txt")) + return; + string[] config = File.ReadAllLines(@"mariadb.txt"); + if (config.Length < 4) + return; + Init(config[0], config[1], config[2], config[3]); + } + + public static void Init(string host, string username, string password, string database) { + ConnectionString = new MySqlConnectionStringBuilder { + Server = host, + UserID = username, + Password = password, + Database = database, + OldGuids = false, + TreatTinyAsBoolean = false, + CharacterSet = @"utf8mb4", + SslMode = MySqlSslMode.None, + ForceSynchronous = true, + ConnectionTimeout = 5, + }.ToString(); + RunMigrations(); + } + + public static void Deinit() { + ConnectionString = null; + } + + private static MySqlConnection GetConnection() { + if (!HasDatabase) + return null; + + MySqlConnection conn = new MySqlConnection(ConnectionString); + conn.Open(); + + return conn; + } + + private static int RunCommand(string command, params MySqlParameter[] parameters) { + if (!HasDatabase) + return 0; + + try { + using MySqlConnection conn = GetConnection(); + using MySqlCommand cmd = conn.CreateCommand(); + if (parameters?.Length > 0) + cmd.Parameters.AddRange(parameters); + cmd.CommandText = command; + return cmd.ExecuteNonQuery(); + } catch (MySqlException ex) { + Logger.Write(ex); + } + + return 0; + } + + private static MySqlDataReader RunQuery(string command, params MySqlParameter[] parameters) { + if (!HasDatabase) + return null; + + try { + MySqlConnection conn = GetConnection(); + MySqlCommand cmd = conn.CreateCommand(); + if (parameters?.Length > 0) + cmd.Parameters.AddRange(parameters); + cmd.CommandText = command; + return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection); + } catch(MySqlException ex) { + Logger.Write(ex); + } + + return null; + } + + private static object RunQueryValue(string command, params MySqlParameter[] parameters) { + if (!HasDatabase) + return null; + + try { + using MySqlConnection conn = GetConnection(); + using MySqlCommand cmd = conn.CreateCommand(); + if (parameters?.Length > 0) + cmd.Parameters.AddRange(parameters); + cmd.CommandText = command; + cmd.Prepare(); + return cmd.ExecuteScalar(); + } catch(MySqlException ex) { + Logger.Write(ex); + } + + return null; + } + + private const long ID_EPOCH = 1588377600000; + private static int IdCounter = 0; + + public static long GenerateId() { + if (IdCounter > 200) + IdCounter = 0; + + long id = 0; + id |= (DateTimeOffset.Now.ToUnixTimeMilliseconds() - ID_EPOCH) << 8; + id |= (ushort)(++IdCounter); + return id; + } + + public static void LogEvent(IChatEvent evt) { + if(evt.SequenceId < 1) + evt.SequenceId = GenerateId(); + + RunCommand( + @"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`" + + @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)" + + @" VALUES (@id, FROM_UNIXTIME(@created), @type, @target, @flags, @data" + + @", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)", + new MySqlParameter(@"id", evt.SequenceId), + new MySqlParameter(@"created", evt.DateTime.ToUnixTimeSeconds()), + new MySqlParameter(@"type", evt.GetType().FullName), + new MySqlParameter(@"target", evt.Target.TargetName), + new MySqlParameter(@"flags", (byte)evt.Flags), + new MySqlParameter(@"data", JsonSerializer.SerializeToUtf8Bytes(evt, evt.GetType())), + new MySqlParameter(@"sender", evt.Sender?.UserId < 1 ? null : (long?)evt.Sender.UserId), + new MySqlParameter(@"sender_name", evt.Sender?.Username), + new MySqlParameter(@"sender_colour", evt.Sender?.Colour.Raw), + new MySqlParameter(@"sender_rank", evt.Sender?.Rank), + new MySqlParameter(@"sender_nick", evt.Sender?.Nickname), + new MySqlParameter(@"sender_perms", evt.Sender?.Permissions) + ); + } + + public static void DeleteEvent(IChatEvent evt) { + RunCommand( + @"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL", + new MySqlParameter(@"id", evt.SequenceId) + ); + } + + private static IChatEvent ReadEvent(MySqlDataReader reader, IPacketTarget target = null) { + Type evtType = Type.GetType(Encoding.ASCII.GetString((byte[])reader[@"event_type"])); + IChatEvent evt = JsonSerializer.Deserialize(Encoding.ASCII.GetString((byte[])reader[@"event_data"]), evtType) as IChatEvent; + evt.SequenceId = reader.GetInt64(@"event_id"); + evt.Target = target; + evt.TargetName = target?.TargetName ?? Encoding.ASCII.GetString((byte[])reader[@"event_target"]); + evt.Flags = (ChatMessageFlags)reader.GetByte(@"event_flags"); + evt.DateTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32(@"event_created")); + + if (!reader.IsDBNull(reader.GetOrdinal(@"event_sender"))) { + evt.Sender = new BasicUser { + UserId = reader.GetInt64(@"event_sender"), + Username = reader.GetString(@"event_sender_name"), + Colour = new ChatColour(reader.GetInt32(@"event_sender_colour")), + Rank = reader.GetInt32(@"event_sender_rank"), + Nickname = reader.IsDBNull(reader.GetOrdinal(@"event_sender_nick")) ? null : reader.GetString(@"event_sender_nick"), + Permissions = (ChatUserPermissions)reader.GetInt32(@"event_sender_perms") + }; + } + + return evt; + } + + public static IEnumerable GetEvents(IPacketTarget target, int amount, int offset) { + List events = new List(); + + try { + using MySqlDataReader reader = RunQuery( + @"SELECT `event_id`, `event_type`, `event_flags`, `event_data`" + + @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`" + + @", UNIX_TIMESTAMP(`event_created`) AS `event_created`" + + @" FROM `sqc_events`" + + @" WHERE `event_deleted` IS NULL AND `event_target` = @target" + + @" AND `event_id` > @offset" + + @" ORDER BY `event_id` DESC" + + @" LIMIT @amount", + new MySqlParameter(@"target", target.TargetName), + new MySqlParameter(@"amount", amount), + new MySqlParameter(@"offset", offset) + ); + + while (reader.Read()) { + IChatEvent evt = ReadEvent(reader, target); + if (evt != null) + events.Add(evt); + } + } catch(MySqlException ex) { + Logger.Write(ex); + } + + return events; + } + + public static IChatEvent GetEvent(long seqId) { + try { + using MySqlDataReader reader = RunQuery( + @"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`" + + @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`" + + @", UNIX_TIMESTAMP(`event_created`) AS `event_created`" + + @" FROM `sqc_events`" + + @" WHERE `event_id` = @id", + new MySqlParameter(@"id", seqId) + ); + + while (reader.Read()) { + IChatEvent evt = ReadEvent(reader); + if (evt != null) + return evt; + } + } catch(MySqlException ex) { + Logger.Write(ex); + } + + return null; + } + } +} diff --git a/SharpChat/Database_Migrations.cs b/SharpChat/Database_Migrations.cs new file mode 100644 index 0000000..c01516f --- /dev/null +++ b/SharpChat/Database_Migrations.cs @@ -0,0 +1,60 @@ +using MySqlConnector; +using System; + +namespace SharpChat { + public static partial class Database { + private static void DoMigration(string name, Action action) { + bool done = (long)RunQueryValue( + @"SELECT COUNT(*) FROM `sqc_migrations` WHERE `migration_name` = @name", + new MySqlParameter(@"name", name) + ) > 0; + if (!done) { + Logger.Write($@"Running migration '{name}'..."); + action(); + RunCommand( + @"INSERT INTO `sqc_migrations` (`migration_name`) VALUES (@name)", + new MySqlParameter(@"name", name) + ); + } + } + + private static void RunMigrations() { + RunCommand( + @"CREATE TABLE IF NOT EXISTS `sqc_migrations` (" + + @"`migration_name` VARCHAR(255) NOT NULL," + + @"`migration_completed` TIMESTAMP NOT NULL DEFAULT current_timestamp()," + + @"UNIQUE INDEX `migration_name` (`migration_name`)," + + @"INDEX `migration_completed` (`migration_completed`)" + + @") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;" + ); + + DoMigration(@"create_events_table", CreateEventsTable); + } + + private static void CreateEventsTable() { + RunCommand( + @"CREATE TABLE `sqc_events` (" + + @"`event_id` BIGINT(20) NOT NULL," + + @"`event_sender` BIGINT(20) UNSIGNED NULL DEFAULT NULL," + + @"`event_sender_name` VARCHAR(255) NULL DEFAULT NULL," + + @"`event_sender_colour` INT(11) NULL DEFAULT NULL," + + @"`event_sender_rank` INT(11) NULL DEFAULT NULL," + + @"`event_sender_nick` VARCHAR(255) NULL DEFAULT NULL," + + @"`event_sender_perms` INT(11) NULL DEFAULT NULL," + + @"`event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp()," + + @"`event_deleted` TIMESTAMP NULL DEFAULT NULL," + + @"`event_type` VARBINARY(255) NOT NULL," + + @"`event_target` VARBINARY(255) NOT NULL," + + @"`event_flags` TINYINT(3) UNSIGNED NOT NULL," + + @"`event_data` BLOB NULL DEFAULT NULL," + + @"PRIMARY KEY (`event_id`)," + + @"INDEX `event_target` (`event_target`)," + + @"INDEX `event_type` (`event_type`)," + + @"INDEX `event_sender` (`event_sender`)," + + @"INDEX `event_datetime` (`event_created`)," + + @"INDEX `event_deleted` (`event_deleted`)" + + @") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;" + ); + } + } +} diff --git a/SharpChat/Events/ChatMessage.cs b/SharpChat/Events/ChatMessage.cs new file mode 100644 index 0000000..83e2a3c --- /dev/null +++ b/SharpChat/Events/ChatMessage.cs @@ -0,0 +1,31 @@ +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.Events { + public class ChatMessage : IChatMessage { + [JsonIgnore] + public BasicUser Sender { get; set; } + + [JsonIgnore] + public IPacketTarget Target { get; set; } + + [JsonIgnore] + public string TargetName { get; set; } + + [JsonIgnore] + public DateTimeOffset DateTime { get; set; } + + [JsonIgnore] + public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.None; + + [JsonIgnore] + public long SequenceId { get; set; } + + [JsonPropertyName(@"text")] + public string Text { get; set; } + + public static string PackBotMessage(int type, string id, params string[] parts) { + return type.ToString() + '\f' + id + '\f' + string.Join('\f', parts); + } + } +} diff --git a/SharpChat/Events/IChatEvent.cs b/SharpChat/Events/IChatEvent.cs new file mode 100644 index 0000000..d78b202 --- /dev/null +++ b/SharpChat/Events/IChatEvent.cs @@ -0,0 +1,25 @@ +using System; + +namespace SharpChat.Events { + [Flags] + public enum ChatMessageFlags { + None = 0, + Action = 1, + Broadcast = 1 << 1, + Log = 1 << 2, + Private = 1 << 3, + } + + public interface IChatEvent { + DateTimeOffset DateTime { get; set; } + BasicUser Sender { get; set; } + IPacketTarget Target { get; set; } + string TargetName { get; set; } + ChatMessageFlags Flags { get; set; } + long SequenceId { get; set; } + } + + public interface IChatMessage : IChatEvent { + string Text { get; } + } +} diff --git a/SharpChat/Events/UserChannelJoinEvent.cs b/SharpChat/Events/UserChannelJoinEvent.cs new file mode 100644 index 0000000..fa54412 --- /dev/null +++ b/SharpChat/Events/UserChannelJoinEvent.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.Events { + public class UserChannelJoinEvent : IChatEvent { + [JsonIgnore] + public DateTimeOffset DateTime { get; set; } + + [JsonIgnore] + public BasicUser Sender { get; set; } + + [JsonIgnore] + public IPacketTarget Target { get; set; } + + [JsonIgnore] + public string TargetName { get; set; } + + [JsonIgnore] + public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log; + + [JsonIgnore] + public long SequenceId { get; set; } + + public UserChannelJoinEvent() { } + public UserChannelJoinEvent(DateTimeOffset joined, BasicUser user, IPacketTarget target) { + DateTime = joined; + Sender = user; + Target = target; + TargetName = target?.TargetName; + } + } +} diff --git a/SharpChat/Events/UserChannelLeaveEvent.cs b/SharpChat/Events/UserChannelLeaveEvent.cs new file mode 100644 index 0000000..3d52219 --- /dev/null +++ b/SharpChat/Events/UserChannelLeaveEvent.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.Events { + public class UserChannelLeaveEvent : IChatEvent { + [JsonIgnore] + public DateTimeOffset DateTime { get; set; } + + [JsonIgnore] + public BasicUser Sender { get; set; } + + [JsonIgnore] + public IPacketTarget Target { get; set; } + + [JsonIgnore] + public string TargetName { get; set; } + + [JsonIgnore] + public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log; + + [JsonIgnore] + public long SequenceId { get; set; } + + public UserChannelLeaveEvent() { } + public UserChannelLeaveEvent(DateTimeOffset parted, BasicUser user, IPacketTarget target) { + DateTime = parted; + Sender = user; + Target = target; + TargetName = target?.TargetName; + } + } +} diff --git a/SharpChat/Events/UserConnectEvent.cs b/SharpChat/Events/UserConnectEvent.cs new file mode 100644 index 0000000..5d8ab69 --- /dev/null +++ b/SharpChat/Events/UserConnectEvent.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.Events { + public class UserConnectEvent : IChatEvent { + [JsonIgnore] + public DateTimeOffset DateTime { get; set; } + + [JsonIgnore] + public BasicUser Sender { get; set; } + + [JsonIgnore] + public IPacketTarget Target { get; set; } + + [JsonIgnore] + public string TargetName { get; set; } + + [JsonIgnore] + public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log; + + [JsonIgnore] + public long SequenceId { get; set; } + + public UserConnectEvent() { } + public UserConnectEvent(DateTimeOffset joined, BasicUser user, IPacketTarget target) { + DateTime = joined; + Sender = user; + Target = target; + TargetName = target?.TargetName; + } + } +} diff --git a/SharpChat/Events/UserDisconnectEvent.cs b/SharpChat/Events/UserDisconnectEvent.cs new file mode 100644 index 0000000..b6cd52c --- /dev/null +++ b/SharpChat/Events/UserDisconnectEvent.cs @@ -0,0 +1,38 @@ +using SharpChat.Packet; +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.Events { + public class UserDisconnectEvent : IChatEvent { + + [JsonIgnore] + public DateTimeOffset DateTime { get; set; } + + [JsonIgnore] + public BasicUser Sender { get; set; } + + [JsonIgnore] + public IPacketTarget Target { get; set; } + + [JsonIgnore] + public string TargetName { get; set; } + + [JsonIgnore] + public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log; + + [JsonIgnore] + public long SequenceId { get; set; } + + [JsonPropertyName(@"reason")] + public UserDisconnectReason Reason { get; set; } + + public UserDisconnectEvent() { } + public UserDisconnectEvent(DateTimeOffset parted, BasicUser user, IPacketTarget target, UserDisconnectReason reason) { + DateTime = parted; + Sender = user; + Target = target; + TargetName = target?.TargetName; + Reason = reason; + } + } +} diff --git a/SharpChat/Extensions.cs b/SharpChat/Extensions.cs new file mode 100644 index 0000000..247ea6c --- /dev/null +++ b/SharpChat/Extensions.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace SharpChat { + public static class Extensions { + public static string GetSignedHash(this string str, string key = null) + => Encoding.UTF8.GetBytes(str).GetSignedHash(key); + + public static string GetSignedHash(this byte[] bytes, string key = null) { + if (key == null) + key = File.Exists(@"login_key.txt") ? File.ReadAllText(@"login_key.txt") : @"woomy"; + + StringBuilder sb = new StringBuilder(); + + using (HMACSHA256 algo = new HMACSHA256(Encoding.UTF8.GetBytes(key))) { + byte[] hash = algo.ComputeHash(bytes); + + foreach (byte b in hash) + sb.AppendFormat(@"{0:x2}", b); + } + + return sb.ToString(); + } + + public static string GetIdString(this byte[] buffer) { + const string id_chars = @"abcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + StringBuilder sb = new StringBuilder(); + foreach(byte b in buffer) + sb.Append(id_chars[b % id_chars.Length]); + return sb.ToString(); + } + } +} diff --git a/SharpChat/Flashii/FlashiiAuth.cs b/SharpChat/Flashii/FlashiiAuth.cs new file mode 100644 index 0000000..7382bb3 --- /dev/null +++ b/SharpChat/Flashii/FlashiiAuth.cs @@ -0,0 +1,81 @@ +using Hamakaze; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpChat.Flashii { + public class FlashiiAuthRequest { + [JsonPropertyName(@"user_id")] + public long UserId { get; set; } + + [JsonPropertyName(@"token")] + public string Token { get; set; } + + [JsonPropertyName(@"ip")] + public string IPAddress { get; set; } + + [JsonIgnore] + public string Hash + => string.Join(@"#", UserId, Token, IPAddress).GetSignedHash(); + + public byte[] GetJSON() + => JsonSerializer.SerializeToUtf8Bytes(this); + } + + public class FlashiiAuth { + [JsonPropertyName(@"success")] + public bool Success { get; set; } + + [JsonPropertyName(@"reason")] + public string Reason { get; set; } = @"none"; + + [JsonPropertyName(@"user_id")] + public long UserId { get; set; } + + [JsonPropertyName(@"username")] + public string Username { get; set; } + + [JsonPropertyName(@"colour_raw")] + public int ColourRaw { get; set; } + + [JsonPropertyName(@"hierarchy")] + public int Rank { get; set; } + + [JsonPropertyName(@"is_silenced")] + public DateTimeOffset SilencedUntil { get; set; } + + [JsonPropertyName(@"perms")] + public ChatUserPermissions Permissions { get; set; } + + public static void Attempt(FlashiiAuthRequest authRequest, Action onComplete, Action onError) { + if(authRequest == null) + throw new ArgumentNullException(nameof(authRequest)); + +#if DEBUG + if(authRequest.UserId >= 10000) { + onComplete(new FlashiiAuth { + Success = true, + UserId = authRequest.UserId, + Username = @"Misaka-" + (authRequest.UserId - 10000), + ColourRaw = (RNG.Next(0, 255) << 16) | (RNG.Next(0, 255) << 8) | RNG.Next(0, 255), + Rank = 0, + SilencedUntil = DateTimeOffset.MinValue, + Permissions = ChatUserPermissions.SendMessage | ChatUserPermissions.EditOwnMessage | ChatUserPermissions.DeleteOwnMessage, + }); + return; + } +#endif + + HttpRequestMessage hrm = new HttpRequestMessage(@"POST", FlashiiUrls.AUTH); + hrm.AddHeader(@"X-SharpChat-Signature", authRequest.Hash); + hrm.SetBody(authRequest.GetJSON()); + HttpClient.Send(hrm, (t, r) => { + try { + onComplete(JsonSerializer.Deserialize(r.GetBodyBytes())); + } catch(Exception ex) { + onError(ex); + } + }, (t, e) => onError(e)); + } + } +} diff --git a/SharpChat/Flashii/FlashiiBan.cs b/SharpChat/Flashii/FlashiiBan.cs new file mode 100644 index 0000000..56d411d --- /dev/null +++ b/SharpChat/Flashii/FlashiiBan.cs @@ -0,0 +1,40 @@ +using Hamakaze; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpChat.Flashii { + public class FlashiiBan { + private const string STRING = @"givemethebeans"; + + [JsonPropertyName(@"id")] + public int UserId { get; set; } + + [JsonPropertyName(@"ip")] + public string UserIP { get; set; } + + [JsonPropertyName(@"expires")] + public DateTimeOffset Expires { get; set; } + + [JsonPropertyName(@"username")] + public string Username { get; set; } + + public static void GetList(Action> onComplete, Action onError) { + if(onComplete == null) + throw new ArgumentNullException(nameof(onComplete)); + if(onError == null) + throw new ArgumentNullException(nameof(onError)); + + HttpRequestMessage hrm = new HttpRequestMessage(@"GET", FlashiiUrls.BANS); + hrm.AddHeader(@"X-SharpChat-Signature", STRING.GetSignedHash()); + HttpClient.Send(hrm, (t, r) => { + try { + onComplete(JsonSerializer.Deserialize>(r.GetBodyBytes())); + } catch(Exception ex) { + onError(ex); + } + }, (t, e) => onError(e)); + } + } +} diff --git a/SharpChat/Flashii/FlashiiBump.cs b/SharpChat/Flashii/FlashiiBump.cs new file mode 100644 index 0000000..06c6168 --- /dev/null +++ b/SharpChat/Flashii/FlashiiBump.cs @@ -0,0 +1,37 @@ +using Hamakaze; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SharpChat.Flashii { + public class FlashiiBump { + [JsonPropertyName(@"id")] + public long UserId { get; set; } + + [JsonPropertyName(@"ip")] + public string UserIP { get; set; } + + public static void Submit(IEnumerable users) { + List bups = users.Where(u => u.HasSessions).Select(x => new FlashiiBump { UserId = x.UserId, UserIP = x.RemoteAddresses.First().ToString() }).ToList(); + + if(bups.Any()) + Submit(bups); + } + + public static void Submit(IEnumerable users) { + if(users == null) + throw new ArgumentNullException(nameof(users)); + if(!users.Any()) + return; + + byte[] data = JsonSerializer.SerializeToUtf8Bytes(users); + + HttpRequestMessage hrm = new HttpRequestMessage(@"POST", FlashiiUrls.BUMP); + hrm.AddHeader(@"X-SharpChat-Signature", data.GetSignedHash()); + hrm.SetBody(data); + HttpClient.Send(hrm, onError: (t, e) => Logger.Write($@"Flashii Bump Error: {e}")); + } + } +} diff --git a/SharpChat/Flashii/FlashiiUrls.cs b/SharpChat/Flashii/FlashiiUrls.cs new file mode 100644 index 0000000..58c939b --- /dev/null +++ b/SharpChat/Flashii/FlashiiUrls.cs @@ -0,0 +1,14 @@ +namespace SharpChat.Flashii { + public static class FlashiiUrls { + public const string BASE_URL = +#if DEBUG + @"https://misuzu.misaka.nl/_sockchat"; +#else + @"https://flashii.net/_sockchat"; +#endif + + public const string AUTH = BASE_URL + @"/verify"; + public const string BANS = BASE_URL + @"/bans"; + public const string BUMP = BASE_URL + @"/bump"; + } +} diff --git a/SharpChat/IChatCommand.cs b/SharpChat/IChatCommand.cs new file mode 100644 index 0000000..92f03b0 --- /dev/null +++ b/SharpChat/IChatCommand.cs @@ -0,0 +1,8 @@ +using SharpChat.Events; + +namespace SharpChat { + public interface IChatCommand { + bool IsMatch(string name); + IChatMessage Dispatch(IChatCommandContext context); + } +} diff --git a/SharpChat/IChatCommandContext.cs b/SharpChat/IChatCommandContext.cs new file mode 100644 index 0000000..5bab770 --- /dev/null +++ b/SharpChat/IChatCommandContext.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace SharpChat { + public interface IChatCommandContext { + IEnumerable Args { get; } + ChatUser User { get; } + ChatChannel Channel { get; } + } + + public class ChatCommandContext : IChatCommandContext { + public IEnumerable Args { get; } + public ChatUser User { get; } + public ChatChannel Channel { get; } + + public ChatCommandContext(IEnumerable args, ChatUser user, ChatChannel channel) { + Args = args ?? throw new ArgumentNullException(nameof(args)); + User = user ?? throw new ArgumentNullException(nameof(user)); + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + } +} diff --git a/SharpChat/IPacketTarget.cs b/SharpChat/IPacketTarget.cs new file mode 100644 index 0000000..50e442e --- /dev/null +++ b/SharpChat/IPacketTarget.cs @@ -0,0 +1,6 @@ +namespace SharpChat { + public interface IPacketTarget { + string TargetName { get; } + void Send(IServerPacket packet); + } +} diff --git a/SharpChat/IServerPacket.cs b/SharpChat/IServerPacket.cs new file mode 100644 index 0000000..6d63d82 --- /dev/null +++ b/SharpChat/IServerPacket.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading; + +namespace SharpChat { + public interface IServerPacket { + long SequenceId { get; } + IEnumerable Pack(); + } + + public abstract class ServerPacket : IServerPacket { + private static long SequenceIdCounter = 0; + + public long SequenceId { get; } + + public ServerPacket(long sequenceId = 0) { + // Allow sequence id to be manually set for potential message repeats + SequenceId = sequenceId > 0 ? sequenceId : Interlocked.Increment(ref SequenceIdCounter); + } + + public abstract IEnumerable Pack(); + } +} diff --git a/SharpChat/Logger.cs b/SharpChat/Logger.cs new file mode 100644 index 0000000..b8334e1 --- /dev/null +++ b/SharpChat/Logger.cs @@ -0,0 +1,28 @@ +using System; +using System.Diagnostics; +using System.Text; + +namespace SharpChat { + public static class Logger { + public static void Write(string str) + => Console.WriteLine(string.Format(@"[{1}] {0}", str, DateTime.Now)); + + public static void Write(byte[] bytes) + => Write(Encoding.UTF8.GetString(bytes)); + + public static void Write(object obj) + => Write(obj?.ToString() ?? string.Empty); + + [Conditional(@"DEBUG")] + public static void Debug(string str) + => Write(str); + + [Conditional(@"DEBUG")] + public static void Debug(byte[] bytes) + => Write(bytes); + + [Conditional(@"DEBUG")] + public static void Debug(object obj) + => Write(obj); + } +} diff --git a/SharpChat/Packet/AuthFailPacket.cs b/SharpChat/Packet/AuthFailPacket.cs new file mode 100644 index 0000000..7b36837 --- /dev/null +++ b/SharpChat/Packet/AuthFailPacket.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public enum AuthFailReason { + AuthInvalid, + MaxSessions, + Banned, + } + + public class AuthFailPacket : ServerPacket { + public AuthFailReason Reason { get; private set; } + public DateTimeOffset Expires { get; private set; } + + public AuthFailPacket(AuthFailReason reason, DateTimeOffset? expires = null) { + Reason = reason; + + if (reason == AuthFailReason.Banned) { + if (!expires.HasValue) + throw new ArgumentNullException(nameof(expires)); + Expires = expires.Value; + } + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserConnect); + sb.Append("\tn\t"); + + switch (Reason) { + case AuthFailReason.AuthInvalid: + default: + sb.Append(@"authfail"); + break; + case AuthFailReason.MaxSessions: + sb.Append(@"sockfail"); + break; + case AuthFailReason.Banned: + sb.Append(@"joinfail"); + break; + } + + if (Reason == AuthFailReason.Banned) { + sb.Append('\t'); + + if (Expires == DateTimeOffset.MaxValue) + sb.Append(@"-1"); + else + sb.Append(Expires.ToUnixTimeSeconds()); + } + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/AuthSuccessPacket.cs b/SharpChat/Packet/AuthSuccessPacket.cs new file mode 100644 index 0000000..b886066 --- /dev/null +++ b/SharpChat/Packet/AuthSuccessPacket.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class AuthSuccessPacket : ServerPacket { + public ChatUser User { get; private set; } + public ChatChannel Channel { get; private set; } + public ChatUserSession Session { get; private set; } + + public AuthSuccessPacket(ChatUser user, ChatChannel channel, ChatUserSession sess) { + User = user ?? throw new ArgumentNullException(nameof(user)); + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Session = sess ?? throw new ArgumentNullException(nameof(channel)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserConnect); + sb.Append("\ty\t"); + sb.Append(User.Pack()); + sb.Append('\t'); + sb.Append(Channel.Name); + /*sb.Append('\t'); + sb.Append(SockChatServer.EXT_VERSION); + sb.Append('\t'); + sb.Append(Session.Id);*/ + + return new[] { sb.ToString() }; + } + } +} diff --git a/SharpChat/Packet/BanListPacket.cs b/SharpChat/Packet/BanListPacket.cs new file mode 100644 index 0000000..8979bcd --- /dev/null +++ b/SharpChat/Packet/BanListPacket.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Packet { + public class BanListPacket : ServerPacket { + public IEnumerable Bans { get; private set; } + + public BanListPacket(IEnumerable bans) { + Bans = bans ?? throw new ArgumentNullException(nameof(bans)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.MessageAdd); + sb.Append('\t'); + sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); + sb.Append("\t-1\t0\fbanlist\f"); + + foreach (IBan ban in Bans) + sb.AppendFormat(@"{0}, ", ban); + + if (Bans.Any()) + sb.Length -= 2; + + sb.Append('\t'); + sb.Append(SequenceId); + sb.Append("\t10010"); + + return new[] { sb.ToString() }; + } + } +} diff --git a/SharpChat/Packet/ChannelCreatePacket.cs b/SharpChat/Packet/ChannelCreatePacket.cs new file mode 100644 index 0000000..8bab3c2 --- /dev/null +++ b/SharpChat/Packet/ChannelCreatePacket.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class ChannelCreatePacket : ServerPacket { + public ChatChannel Channel { get; private set; } + + public ChannelCreatePacket(ChatChannel channel) { + Channel = channel; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ChannelEvent); + sb.Append('\t'); + sb.Append((int)SockChatServerChannelPacket.Create); + sb.Append('\t'); + sb.Append(Channel.Pack()); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ChannelDeletePacket.cs b/SharpChat/Packet/ChannelDeletePacket.cs new file mode 100644 index 0000000..615319e --- /dev/null +++ b/SharpChat/Packet/ChannelDeletePacket.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class ChannelDeletePacket : ServerPacket { + public ChatChannel Channel { get; private set; } + + public ChannelDeletePacket(ChatChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ChannelEvent); + sb.Append('\t'); + sb.Append((int)SockChatServerChannelPacket.Delete); + sb.Append('\t'); + sb.Append(Channel.Name); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ChannelUpdatePacket.cs b/SharpChat/Packet/ChannelUpdatePacket.cs new file mode 100644 index 0000000..a151885 --- /dev/null +++ b/SharpChat/Packet/ChannelUpdatePacket.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class ChannelUpdatePacket : ServerPacket { + public string PreviousName { get; private set; } + public ChatChannel Channel { get; private set; } + + public ChannelUpdatePacket(string previousName, ChatChannel channel) { + PreviousName = previousName; + Channel = channel; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ChannelEvent); + sb.Append('\t'); + sb.Append((int)SockChatServerChannelPacket.Update); + sb.Append('\t'); + sb.Append(PreviousName); + sb.Append('\t'); + sb.Append(Channel.Pack()); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ChatMessageAddPacket.cs b/SharpChat/Packet/ChatMessageAddPacket.cs new file mode 100644 index 0000000..b42ca87 --- /dev/null +++ b/SharpChat/Packet/ChatMessageAddPacket.cs @@ -0,0 +1,57 @@ +using SharpChat.Events; +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class ChatMessageAddPacket : ServerPacket { + public IChatMessage Message { get; private set; } + + public ChatMessageAddPacket(IChatMessage message) : base(message?.SequenceId ?? 0) { + Message = message ?? throw new ArgumentNullException(nameof(message)); + + if (Message.SequenceId < 1) + Message.SequenceId = SequenceId; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.MessageAdd); + sb.Append('\t'); + + sb.Append(Message.DateTime.ToUnixTimeSeconds()); + sb.Append('\t'); + + sb.Append(Message.Sender?.UserId ?? -1); + sb.Append('\t'); + + if (Message.Flags.HasFlag(ChatMessageFlags.Action)) + sb.Append(@""); + + sb.Append( + Message.Text + .Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
") + .Replace("\t", @" ") + ); + + if (Message.Flags.HasFlag(ChatMessageFlags.Action)) + sb.Append(@"
"); + + sb.Append('\t'); + sb.Append(SequenceId); + sb.AppendFormat( + "\t1{0}0{1}{2}", + Message.Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0', + Message.Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1', + Message.Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0' + ); + sb.Append('\t'); + sb.Append(Message.TargetName); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ChatMessageDeletePacket.cs b/SharpChat/Packet/ChatMessageDeletePacket.cs new file mode 100644 index 0000000..0d94b74 --- /dev/null +++ b/SharpChat/Packet/ChatMessageDeletePacket.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class ChatMessageDeletePacket : ServerPacket { + public long EventId { get; private set; } + + public ChatMessageDeletePacket(long eventId) { + EventId = eventId; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.MessageDelete); + sb.Append('\t'); + sb.Append(EventId); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ContextChannelsPacket.cs b/SharpChat/Packet/ContextChannelsPacket.cs new file mode 100644 index 0000000..f7919bb --- /dev/null +++ b/SharpChat/Packet/ContextChannelsPacket.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Packet { + public class ContextChannelsPacket : ServerPacket { + public IEnumerable Channels { get; private set; } + + public ContextChannelsPacket(IEnumerable channels) { + Channels = channels?.Where(c => c != null) ?? throw new ArgumentNullException(nameof(channels)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ContextPopulate); + sb.Append('\t'); + sb.Append((int)SockChatServerContextPacket.Channels); + sb.Append('\t'); + sb.Append(Channels.Count()); + + foreach (ChatChannel channel in Channels) { + sb.Append('\t'); + sb.Append(channel.Pack()); + } + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ContextClearPacket.cs b/SharpChat/Packet/ContextClearPacket.cs new file mode 100644 index 0000000..80b10b8 --- /dev/null +++ b/SharpChat/Packet/ContextClearPacket.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public enum ContextClearMode { + Messages = 0, + Users = 1, + Channels = 2, + MessagesUsers = 3, + MessagesUsersChannels = 4, + } + + public class ContextClearPacket : ServerPacket { + public ChatChannel Channel { get; private set; } + public ContextClearMode Mode { get; private set; } + + public bool IsGlobal + => Channel == null; + + public ContextClearPacket(ChatChannel channel, ContextClearMode mode) { + Channel = channel; + Mode = mode; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ContextClear); + sb.Append('\t'); + sb.Append((int)Mode); + sb.Append('\t'); + sb.Append(Channel?.TargetName ?? string.Empty); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ContextMessagePacket.cs b/SharpChat/Packet/ContextMessagePacket.cs new file mode 100644 index 0000000..2b87d77 --- /dev/null +++ b/SharpChat/Packet/ContextMessagePacket.cs @@ -0,0 +1,99 @@ +using SharpChat.Events; +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class ContextMessagePacket : ServerPacket { + public IChatEvent Event { get; private set; } + public bool Notify { get; private set; } + + public ContextMessagePacket(IChatEvent evt, bool notify = false) { + Event = evt ?? throw new ArgumentNullException(nameof(evt)); + Notify = notify; + } + + private const string V1_CHATBOT = "-1\tChatBot\tinherit\t\t"; + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ContextPopulate); + sb.Append('\t'); + sb.Append((int)SockChatServerContextPacket.Message); + sb.Append('\t'); + sb.Append(Event.DateTime.ToUnixTimeSeconds()); + sb.Append('\t'); + + switch (Event) { + case IChatMessage msg: + sb.Append(Event.Sender.Pack()); + sb.Append('\t'); + sb.Append( + msg.Text + .Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
") + .Replace("\t", @" ") + ); + break; + + case UserConnectEvent _: + sb.Append(V1_CHATBOT); + sb.Append("0\fjoin\f"); + sb.Append(Event.Sender.Username); + break; + + case UserChannelJoinEvent _: + sb.Append(V1_CHATBOT); + sb.Append("0\fjchan\f"); + sb.Append(Event.Sender.Username); + break; + + case UserChannelLeaveEvent _: + sb.Append(V1_CHATBOT); + sb.Append("0\flchan\f"); + sb.Append(Event.Sender.Username); + break; + + case UserDisconnectEvent ude: + sb.Append(V1_CHATBOT); + sb.Append("0\f"); + + switch (ude.Reason) { + case UserDisconnectReason.Flood: + sb.Append(@"flood"); + break; + case UserDisconnectReason.Kicked: + sb.Append(@"kick"); + break; + case UserDisconnectReason.TimeOut: + sb.Append(@"timeout"); + break; + case UserDisconnectReason.Leave: + default: + sb.Append(@"leave"); + break; + } + + sb.Append('\f'); + sb.Append(Event.Sender.Username); + break; + } + + + sb.Append('\t'); + sb.Append(Event.SequenceId < 1 ? SequenceId : Event.SequenceId); + sb.Append('\t'); + sb.Append(Notify ? '1' : '0'); + sb.AppendFormat( + "\t1{0}0{1}{2}", + Event.Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0', + Event.Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1', + Event.Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0' + ); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ContextUsersPacket.cs b/SharpChat/Packet/ContextUsersPacket.cs new file mode 100644 index 0000000..bb13774 --- /dev/null +++ b/SharpChat/Packet/ContextUsersPacket.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Packet { + public class ContextUsersPacket : ServerPacket { + public IEnumerable Users { get; private set; } + + public ContextUsersPacket(IEnumerable users) { + Users = users?.Where(u => u != null) ?? throw new ArgumentNullException(nameof(users)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.ContextPopulate); + sb.Append('\t'); + sb.Append((int)SockChatServerContextPacket.Users); + sb.Append('\t'); + sb.Append(Users.Count()); + + foreach (ChatUser user in Users) { + sb.Append('\t'); + sb.Append(user.Pack()); + sb.Append('\t'); + sb.Append('1'); // visibility flag + } + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/FloodWarningPacket.cs b/SharpChat/Packet/FloodWarningPacket.cs new file mode 100644 index 0000000..410ac86 --- /dev/null +++ b/SharpChat/Packet/FloodWarningPacket.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class FloodWarningPacket : ServerPacket { + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.MessageAdd); + sb.Append('\t'); + sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); + sb.Append("\t-1\t0\fflwarn\t"); + sb.Append(SequenceId); + sb.Append("\t10010"); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/ForceDisconnectPacket.cs b/SharpChat/Packet/ForceDisconnectPacket.cs new file mode 100644 index 0000000..1de41aa --- /dev/null +++ b/SharpChat/Packet/ForceDisconnectPacket.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public enum ForceDisconnectReason { + Kicked = 0, + Banned = 1, + } + + public class ForceDisconnectPacket : ServerPacket { + public ForceDisconnectReason Reason { get; private set; } + public DateTimeOffset Expires { get; private set; } + + public ForceDisconnectPacket(ForceDisconnectReason reason, DateTimeOffset? expires = null) { + Reason = reason; + + if (reason == ForceDisconnectReason.Banned) { + if (!expires.HasValue) + throw new ArgumentNullException(nameof(expires)); + Expires = expires.Value; + } + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.BAKA); + sb.Append('\t'); + sb.Append((int)Reason); + + if (Reason == ForceDisconnectReason.Banned) { + sb.Append('\t'); + sb.Append(Expires.ToUnixTimeSeconds()); + } + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/LegacyCommandResponse.cs b/SharpChat/Packet/LegacyCommandResponse.cs new file mode 100644 index 0000000..c4fb3fa --- /dev/null +++ b/SharpChat/Packet/LegacyCommandResponse.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Packet { + public class LegacyCommandResponse : ServerPacket { + public bool IsError { get; private set; } + public string StringId { get; private set; } + public IEnumerable Arguments { get; private set; } + + public LegacyCommandResponse( + string stringId, + bool isError = true, + params object[] args + ) { + IsError = isError; + StringId = stringId; + Arguments = args; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + if (StringId == LCR.WELCOME) { + sb.Append((int)SockChatServerPacket.ContextPopulate); + sb.Append('\t'); + sb.Append((int)SockChatServerContextPacket.Message); + sb.Append('\t'); + sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); + sb.Append("\t-1\tChatBot\tinherit\t\t"); + } else { + sb.Append((int)SockChatServerPacket.MessageAdd); + sb.Append('\t'); + sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); + sb.Append("\t-1\t"); + } + + sb.Append(IsError ? '1' : '0'); + sb.Append('\f'); + sb.Append(StringId == LCR.WELCOME ? LCR.BROADCAST : StringId); + + if (Arguments?.Any() == true) + foreach (object arg in Arguments) { + sb.Append('\f'); + sb.Append(arg); + } + + sb.Append('\t'); + + if (StringId == LCR.WELCOME) { + sb.Append(StringId); + sb.Append("\t0"); + } else + sb.Append(SequenceId); + + sb.Append("\t10010"); + /*sb.AppendFormat( + "\t1{0}0{1}{2}", + Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0', + Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1', + Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0' + );*/ + + yield return sb.ToString(); + } + } + + // Abbreviated class name because otherwise shit gets wide + public static class LCR { + public const string COMMAND_NOT_FOUND = @"nocmd"; + public const string COMMAND_NOT_ALLOWED = @"cmdna"; + public const string COMMAND_FORMAT_ERROR = @"cmderr"; + public const string WELCOME = @"welcome"; + public const string BROADCAST = @"say"; + public const string IP_ADDRESS = @"ipaddr"; + public const string USER_NOT_FOUND = @"usernf"; + public const string SILENCE_SELF = @"silself"; + public const string SILENCE_HIERARCHY = @"silperr"; + public const string SILENCE_ALREADY = @"silerr"; + public const string TARGET_SILENCED = @"silok"; + public const string SILENCED = @"silence"; + public const string UNSILENCED = @"unsil"; + public const string TARGET_UNSILENCED = @"usilok"; + public const string NOT_SILENCED = @"usilerr"; + public const string UNSILENCE_HIERARCHY = @"usilperr"; + public const string NAME_IN_USE = @"nameinuse"; + public const string CHANNEL_INSUFFICIENT_HIERARCHY = @"ipchan"; + public const string CHANNEL_INVALID_PASSWORD = @"ipwchan"; + public const string CHANNEL_NOT_FOUND = @"nochan"; + public const string CHANNEL_ALREADY_EXISTS = @"nischan"; + public const string CHANNEL_NAME_INVALID = "inchan"; + public const string CHANNEL_CREATED = @"crchan"; + public const string CHANNEL_DELETE_FAILED = @"ndchan"; + public const string CHANNEL_DELETED = @"delchan"; + public const string CHANNEL_PASSWORD_CHANGED = @"cpwdchan"; + public const string CHANNEL_HIERARCHY_CHANGED = @"cprivchan"; + public const string USERS_LISTING_ERROR = @"whoerr"; + public const string USERS_LISTING_CHANNEL = @"whochan"; + public const string USERS_LISTING_SERVER = @"who"; + public const string INSUFFICIENT_HIERARCHY = @"rankerr"; + public const string MESSAGE_DELETE_ERROR = @"delerr"; + public const string KICK_NOT_ALLOWED = @"kickna"; + public const string USER_NOT_BANNED = @"notban"; + public const string USER_UNBANNED = @"unban"; + } +} diff --git a/SharpChat/Packet/PongPacket.cs b/SharpChat/Packet/PongPacket.cs new file mode 100644 index 0000000..bda008e --- /dev/null +++ b/SharpChat/Packet/PongPacket.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class PongPacket : ServerPacket { + public DateTimeOffset PongTime { get; private set; } + + public PongPacket(DateTimeOffset dto) { + PongTime = dto; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.Pong); + sb.Append('\t'); + sb.Append(PongTime.ToUnixTimeSeconds()); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/TypingPacket.cs b/SharpChat/Packet/TypingPacket.cs new file mode 100644 index 0000000..57b2dfd --- /dev/null +++ b/SharpChat/Packet/TypingPacket.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class TypingPacket : ServerPacket { + public ChatChannel Channel { get; } + public ChatChannelTyping TypingInfo { get; } + + public TypingPacket(ChatChannel channel, ChatChannelTyping typingInfo) { + Channel = channel; + TypingInfo = typingInfo ?? throw new ArgumentNullException(nameof(typingInfo)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.Typing); + sb.Append('\t'); + sb.Append(Channel?.TargetName ?? string.Empty); + sb.Append('\t'); + sb.Append(TypingInfo.User.UserId); + sb.Append('\t'); + sb.Append(TypingInfo.Started.ToUnixTimeSeconds()); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/UserChannelForceJoinPacket.cs b/SharpChat/Packet/UserChannelForceJoinPacket.cs new file mode 100644 index 0000000..da58ea2 --- /dev/null +++ b/SharpChat/Packet/UserChannelForceJoinPacket.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class UserChannelForceJoinPacket : ServerPacket { + public ChatChannel Channel { get; private set; } + + public UserChannelForceJoinPacket(ChatChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserSwitch); + sb.Append('\t'); + sb.Append((int)SockChatServerMovePacket.ForcedMove); + sb.Append('\t'); + sb.Append(Channel.Name); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/UserChannelJoinPacket.cs b/SharpChat/Packet/UserChannelJoinPacket.cs new file mode 100644 index 0000000..39b7dd6 --- /dev/null +++ b/SharpChat/Packet/UserChannelJoinPacket.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class UserChannelJoinPacket : ServerPacket { + public ChatUser User { get; private set; } + + public UserChannelJoinPacket(ChatUser user) { + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserSwitch); + sb.Append('\t'); + sb.Append((int)SockChatServerMovePacket.UserJoined); + sb.Append('\t'); + sb.Append(User.UserId); + sb.Append('\t'); + sb.Append(User.DisplayName); + sb.Append('\t'); + sb.Append(User.Colour); + sb.Append('\t'); + sb.Append(SequenceId); + + return new[] { sb.ToString() }; + } + } +} diff --git a/SharpChat/Packet/UserChannelLeavePacket.cs b/SharpChat/Packet/UserChannelLeavePacket.cs new file mode 100644 index 0000000..2bc6275 --- /dev/null +++ b/SharpChat/Packet/UserChannelLeavePacket.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class UserChannelLeavePacket : ServerPacket { + public ChatUser User { get; private set; } + + public UserChannelLeavePacket(ChatUser user) { + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserSwitch); + sb.Append('\t'); + sb.Append((int)SockChatServerMovePacket.UserLeft); + sb.Append('\t'); + sb.Append(User.UserId); + sb.Append('\t'); + sb.Append(SequenceId); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/UserConnectPacket.cs b/SharpChat/Packet/UserConnectPacket.cs new file mode 100644 index 0000000..b04bbf5 --- /dev/null +++ b/SharpChat/Packet/UserConnectPacket.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class UserConnectPacket : ServerPacket { + public DateTimeOffset Joined { get; private set; } + public ChatUser User { get; private set; } + + public UserConnectPacket(DateTimeOffset joined, ChatUser user) { + Joined = joined; + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserConnect); + sb.Append('\t'); + sb.Append(Joined.ToUnixTimeSeconds()); + sb.Append('\t'); + sb.Append(User.Pack()); + sb.Append('\t'); + sb.Append(SequenceId); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Packet/UserDisconnectPacket.cs b/SharpChat/Packet/UserDisconnectPacket.cs new file mode 100644 index 0000000..1b3623b --- /dev/null +++ b/SharpChat/Packet/UserDisconnectPacket.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public enum UserDisconnectReason { + Leave, + TimeOut, + Kicked, + Flood, + } + + public class UserDisconnectPacket : ServerPacket { + public DateTimeOffset Disconnected { get; private set; } + public ChatUser User { get; private set; } + public UserDisconnectReason Reason { get; private set; } + + public UserDisconnectPacket(DateTimeOffset disconnected, ChatUser user, UserDisconnectReason reason) { + Disconnected = disconnected; + User = user ?? throw new ArgumentNullException(nameof(user)); + Reason = reason; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + sb.Append((int)SockChatServerPacket.UserDisconnect); + sb.Append('\t'); + sb.Append(User.UserId); + sb.Append('\t'); + sb.Append(User.DisplayName); + sb.Append('\t'); + + switch (Reason) { + case UserDisconnectReason.Leave: + default: + sb.Append(@"leave"); + break; + case UserDisconnectReason.TimeOut: + sb.Append(@"timeout"); + break; + case UserDisconnectReason.Kicked: + sb.Append(@"kick"); + break; + case UserDisconnectReason.Flood: + sb.Append(@"flood"); + break; + } + + sb.Append('\t'); + sb.Append(Disconnected.ToUnixTimeSeconds()); + sb.Append('\t'); + sb.Append(SequenceId); + + return new[] { sb.ToString() }; + } + } +} diff --git a/SharpChat/Packet/UserUpdatePacket.cs b/SharpChat/Packet/UserUpdatePacket.cs new file mode 100644 index 0000000..36d12fe --- /dev/null +++ b/SharpChat/Packet/UserUpdatePacket.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Packet { + public class UserUpdatePacket : ServerPacket { + public ChatUser User { get; private set; } + public string PreviousName { get; private set; } + + public UserUpdatePacket(ChatUser user, string previousName = null) { + User = user ?? throw new ArgumentNullException(nameof(user)); + PreviousName = previousName; + } + + public override IEnumerable Pack() { + StringBuilder sb = new StringBuilder(); + + bool isSilent = string.IsNullOrEmpty(PreviousName); + + if (!isSilent) { + sb.Append((int)SockChatServerPacket.MessageAdd); + sb.Append('\t'); + sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); + sb.Append("\t-1\t0\fnick\f"); + sb.Append(PreviousName); + sb.Append('\f'); + sb.Append(User.DisplayName); + sb.Append('\t'); + sb.Append(SequenceId); + sb.Append("\t10010"); + yield return sb.ToString(); + sb.Clear(); + } + + sb.Append((int)SockChatServerPacket.UserUpdate); + sb.Append('\t'); + sb.Append(User.Pack()); + + yield return sb.ToString(); + } + } +} diff --git a/SharpChat/Program.cs b/SharpChat/Program.cs new file mode 100644 index 0000000..16ebfb5 --- /dev/null +++ b/SharpChat/Program.cs @@ -0,0 +1,30 @@ +using Hamakaze; +using System; +using System.Threading; + +namespace SharpChat { + public class Program { + public const ushort PORT = 6770; + + public static void Main(string[] args) { + Console.WriteLine(@" _____ __ ________ __ "); + Console.WriteLine(@" / ___// /_ ____ __________ / ____/ /_ ____ _/ /_"); + Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/"); + Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ "); + Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ "); + Console.WriteLine(@" / _/ Sock Chat Server"); +#if DEBUG + Console.WriteLine(@"============================================ DEBUG =="); +#endif + + HttpClient.Instance.DefaultUserAgent = @"SharpChat/0.9"; + + Database.ReadConfig(); + + using ManualResetEvent mre = new ManualResetEvent(false); + using SockChatServer scs = new SockChatServer(PORT); + Console.CancelKeyPress += (s, e) => { e.Cancel = true; mre.Set(); }; + mre.WaitOne(); + } + } +} diff --git a/SharpChat/RNG.cs b/SharpChat/RNG.cs new file mode 100644 index 0000000..8357b7c --- /dev/null +++ b/SharpChat/RNG.cs @@ -0,0 +1,30 @@ +using System; +using System.Security.Cryptography; + +namespace SharpChat { + public static class RNG { + private static object Lock { get; } = new object(); + private static Random NormalRandom { get; } = new Random(); + private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create(); + + public static int Next() { + lock (Lock) + return NormalRandom.Next(); + } + + public static int Next(int max) { + lock (Lock) + return NormalRandom.Next(max); + } + + public static int Next(int min, int max) { + lock (Lock) + return NormalRandom.Next(min, max); + } + + public static void NextBytes(byte[] buffer) { + lock(Lock) + SecureRandom.GetBytes(buffer); + } + } +} diff --git a/SharpChat/SharpChat.csproj b/SharpChat/SharpChat.csproj new file mode 100644 index 0000000..6d2806a --- /dev/null +++ b/SharpChat/SharpChat.csproj @@ -0,0 +1,17 @@ + + + + Exe + net5.0 + + + + + + + + + + + + diff --git a/SharpChat/SharpChatWebSocketServer.cs b/SharpChat/SharpChatWebSocketServer.cs new file mode 100644 index 0000000..e01b75a --- /dev/null +++ b/SharpChat/SharpChatWebSocketServer.cs @@ -0,0 +1,166 @@ +using Fleck; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +// Near direct reimplementation of Fleck's WebSocketServer with address reusing +// Fleck's Socket wrapper doesn't provide any way to do this with the normally provided APIs +// https://github.com/statianzo/Fleck/blob/1.1.0/src/Fleck/WebSocketServer.cs + +namespace SharpChat { + public class SharpChatWebSocketServer : IWebSocketServer { + + private readonly string _scheme; + private readonly IPAddress _locationIP; + private Action _config; + + public SharpChatWebSocketServer(string location, bool supportDualStack = true) { + Uri uri = new Uri(location); + + Port = uri.Port; + Location = location; + SupportDualStack = supportDualStack; + + _locationIP = ParseIPAddress(uri); + _scheme = uri.Scheme; + Socket socket = new Socket(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); + + if (SupportDualStack && Type.GetType(@"Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false); + } + + ListenerSocket = new SocketWrapper(socket); + SupportedSubProtocols = new string[0]; + } + + public ISocket ListenerSocket { get; set; } + public string Location { get; private set; } + public bool SupportDualStack { get; } + public int Port { get; private set; } + public X509Certificate2 Certificate { get; set; } + public SslProtocols EnabledSslProtocols { get; set; } + public IEnumerable SupportedSubProtocols { get; set; } + public bool RestartAfterListenError { get; set; } + + public bool IsSecure { + get { return _scheme == "wss" && Certificate != null; } + } + + public void Dispose() { + ListenerSocket.Dispose(); + } + + private IPAddress ParseIPAddress(Uri uri) { + string ipStr = uri.Host; + + if (ipStr == "0.0.0.0") { + return IPAddress.Any; + } else if (ipStr == "[0000:0000:0000:0000:0000:0000:0000:0000]") { + return IPAddress.IPv6Any; + } else { + try { + return IPAddress.Parse(ipStr); + } catch (Exception ex) { + throw new FormatException("Failed to parse the IP address part of the location. Please make sure you specify a valid IP address. Use 0.0.0.0 or [::] to listen on all interfaces.", ex); + } + } + } + + public void Start(Action config) { + IPEndPoint ipLocal = new IPEndPoint(_locationIP, Port); + ListenerSocket.Bind(ipLocal); + ListenerSocket.Listen(100); + Port = ((IPEndPoint)ListenerSocket.LocalEndPoint).Port; + FleckLog.Info(string.Format("Server started at {0} (actual port {1})", Location, Port)); + if (_scheme == "wss") { + if (Certificate == null) { + FleckLog.Error("Scheme cannot be 'wss' without a Certificate"); + return; + } + + if (EnabledSslProtocols == SslProtocols.None) { + EnabledSslProtocols = SslProtocols.Tls; + FleckLog.Debug("Using default TLS 1.0 security protocol."); + } + } + ListenForClients(); + _config = config; + } + + private void ListenForClients() { + ListenerSocket.Accept(OnClientConnect, e => { + FleckLog.Error("Listener socket is closed", e); + if (RestartAfterListenError) { + FleckLog.Info("Listener socket restarting"); + try { + ListenerSocket.Dispose(); + Socket socket = new Socket(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); + ListenerSocket = new SocketWrapper(socket); + Start(_config); + FleckLog.Info("Listener socket restarted"); + } catch (Exception ex) { + FleckLog.Error("Listener could not be restarted", ex); + } + } + }); + } + + private void OnClientConnect(ISocket clientSocket) { + if (clientSocket == null) return; // socket closed + + FleckLog.Debug(string.Format("Client connected from {0}:{1}", clientSocket.RemoteIpAddress, clientSocket.RemotePort.ToString())); + ListenForClients(); + + WebSocketConnection connection = null; + + connection = new WebSocketConnection( + clientSocket, + _config, + bytes => RequestParser.Parse(bytes, _scheme), + r => { + try { + return HandlerFactory.BuildHandler( + r, s => connection.OnMessage(s), connection.Close, b => connection.OnBinary(b), + b => connection.OnPing(b), b => connection.OnPong(b) + ); + } catch(WebSocketException) { + const string responseMsg = "HTTP/1.1 200 OK\r\n" + + "Date: {0}\r\n" + + "Server: SharpChat\r\n" + + "Content-Length: {1}\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Connection: close\r\n" + + "\r\n" + + "{2}"; + string responseBody = File.Exists(@"http-motd.txt") ? File.ReadAllText(@"http-motd.txt") : @"SharpChat"; + + clientSocket.Stream.Write(Encoding.UTF8.GetBytes(string.Format( + responseMsg, DateTimeOffset.Now.ToString(@"r"), Encoding.UTF8.GetByteCount(responseBody), responseBody + ))); + clientSocket.Close(); + return null; + } + }, + s => SubProtocolNegotiator.Negotiate(SupportedSubProtocols, s)); + + if (IsSecure) { + FleckLog.Debug("Authenticating Secure Connection"); + clientSocket + .Authenticate(Certificate, + EnabledSslProtocols, + connection.StartReceiving, + e => FleckLog.Warn("Failed to Authenticate", e)); + } else { + connection.StartReceiving(); + } + } + } +} diff --git a/SharpChat/SockChatEnums.cs b/SharpChat/SockChatEnums.cs new file mode 100644 index 0000000..99ea7ca --- /dev/null +++ b/SharpChat/SockChatEnums.cs @@ -0,0 +1,48 @@ +namespace SharpChat { + public enum SockChatClientPacket { + // Version 1 + Ping = 0, + Authenticate = 1, + MessageSend = 2, + + // Version 2 + FocusChannel = 3, + Typing = 4, + } + + public enum SockChatServerPacket { + // Version 1 + Pong = 0, + UserConnect = 1, + MessageAdd = 2, + UserDisconnect = 3, + ChannelEvent = 4, + UserSwitch = 5, + MessageDelete = 6, + ContextPopulate = 7, + ContextClear = 8, + BAKA = 9, + UserUpdate = 10, + + // Version 2 + Typing = 11, + } + + public enum SockChatServerChannelPacket { + Create = 0, + Update = 1, + Delete = 2, + } + + public enum SockChatServerMovePacket { + UserJoined = 0, + UserLeft = 1, + ForcedMove = 2, + } + + public enum SockChatServerContextPacket { + Users = 0, + Message = 1, + Channels = 2, + } +} diff --git a/SharpChat/SockChatServer.cs b/SharpChat/SockChatServer.cs new file mode 100644 index 0000000..a9dc4f5 --- /dev/null +++ b/SharpChat/SockChatServer.cs @@ -0,0 +1,836 @@ +using Fleck; +using SharpChat.Commands; +using SharpChat.Events; +using SharpChat.Flashii; +using SharpChat.Packet; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; + +namespace SharpChat { + public class SockChatServer : IDisposable { + public const int EXT_VERSION = 2; + public const int MSG_LENGTH_MAX = 5000; + +#if DEBUG + public const int MAX_CONNECTIONS = 9001; + public const int FLOOD_KICK_LENGTH = 5; + public const bool ENABLE_TYPING_EVENT = true; +#else + public const int MAX_CONNECTIONS = 5; + public const int FLOOD_KICK_LENGTH = 30; + public const bool ENABLE_TYPING_EVENT = false; +#endif + + 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 IReadOnlyCollection Commands { get; } = new IChatCommand[] { + new AFKCommand(), + }; + + public List Sessions { get; } = new List(); + private object SessionsLock { get; } = new object(); + + public ChatUserSession GetSession(IWebSocketConnection conn) { + lock(SessionsLock) + return Sessions.FirstOrDefault(x => x.Connection == conn); + } + + public SockChatServer(ushort port) { + Logger.Write("Starting Sock Chat server..."); + + Context = new ChatContext(this); + + Context.Channels.Add(new ChatChannel(@"Lounge")); +#if DEBUG + Context.Channels.Add(new ChatChannel(@"Programming")); + Context.Channels.Add(new ChatChannel(@"Games")); + Context.Channels.Add(new ChatChannel(@"Splatoon")); + Context.Channels.Add(new ChatChannel(@"Password") { Password = @"meow", }); +#endif + Context.Channels.Add(new ChatChannel(@"Staff") { Rank = 5 }); + + Server = new SharpChatWebSocketServer($@"ws://0.0.0.0:{port}"); + + Server.Start(sock => { + sock.OnOpen = () => OnOpen(sock); + sock.OnClose = () => OnClose(sock); + sock.OnError = err => OnError(sock, err); + sock.OnMessage = msg => OnMessage(sock, msg); + }); + } + + private void OnOpen(IWebSocketConnection conn) { + lock(SessionsLock) { + if(!Sessions.Any(x => x.Connection == conn)) + Sessions.Add(new ChatUserSession(conn)); + } + + Context.Update(); + } + + private void OnClose(IWebSocketConnection conn) { + ChatUserSession sess = 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(SessionsLock) + Sessions.Remove(sess); + + sess?.Dispose(); + } + + private void OnError(IWebSocketConnection conn, Exception ex) { + ChatUserSession sess = GetSession(conn); + string sessId = sess?.Id ?? new string('0', ChatUserSession.ID_LENGTH); + Logger.Write($@"[{sessId} {conn.ConnectionInfo.ClientIpAddress}] {ex}"); + Context.Update(); + } + + private void OnMessage(IWebSocketConnection conn, string msg) { + Context.Update(); + + ChatUserSession sess = GetSession(conn); + + if(sess == null) { + conn.Close(); + return; + } + + if(sess.User is ChatUser && sess.User.HasFloodProtection) { + sess.User.RateLimiter.AddTimePoint(); + + if(sess.User.RateLimiter.State == ChatRateLimitState.Kick) { + Context.BanUser(sess.User, DateTimeOffset.UtcNow.AddSeconds(FLOOD_KICK_LENGTH), false, UserDisconnectReason.Flood); + return; + } else if(sess.User.RateLimiter.State == ChatRateLimitState.Warning) + sess.User.Send(new FloodWarningPacket()); // make it so this thing only sends once + } + + string[] args = msg.Split('\t'); + + if(args.Length < 1 || !Enum.TryParse(args[0], out SockChatClientPacket opCode)) + return; + + switch(opCode) { + case SockChatClientPacket.Ping: + if(!int.TryParse(args[1], out int pTime)) + break; + + sess.BumpPing(); + sess.Send(new PongPacket(sess.LastPing)); + break; + + case SockChatClientPacket.Authenticate: + if(sess.User != null) + break; + + DateTimeOffset aBanned = Context.Bans.Check(sess.RemoteAddress); + + if(aBanned > DateTimeOffset.UtcNow) { + sess.Send(new AuthFailPacket(AuthFailReason.Banned, aBanned)); + sess.Dispose(); + break; + } + + if(args.Length < 3 || !long.TryParse(args[1], out long aUserId)) + break; + + FlashiiAuth.Attempt(new FlashiiAuthRequest { + UserId = aUserId, + Token = args[2], + IPAddress = sess.RemoteAddress.ToString(), + }, auth => { + if(!auth.Success) { + Logger.Debug($@"<{sess.Id}> Auth fail: {auth.Reason}"); + sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); + sess.Dispose(); + return; + } + + ChatUser aUser = Context.Users.Get(auth.UserId); + + if(aUser == null) + aUser = new ChatUser(auth); + else { + aUser.ApplyAuth(auth); + aUser.Channel?.Send(new UserUpdatePacket(aUser)); + } + + aBanned = Context.Bans.Check(aUser); + + if(aBanned > DateTimeOffset.Now) { + sess.Send(new AuthFailPacket(AuthFailReason.Banned, aBanned)); + sess.Dispose(); + return; + } + + // Enforce a maximum amount of connections per user + if(aUser.SessionCount >= MAX_CONNECTIONS) { + 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, Context.Channels.DefaultChannel, sess); + }, ex => { + Logger.Write($@"<{sess.Id}> Auth task fail: {ex}"); + sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid)); + sess.Dispose(); + }); + break; + + case SockChatClientPacket.MessageSend: + 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; + +#if !DEBUG + // 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; +#endif + 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)); + } + + if(messageText.Length > MSG_LENGTH_MAX) + messageText = messageText.Substring(0, MSG_LENGTH_MAX); + + messageText = messageText.Trim(); + +#if DEBUG + Logger.Write($@"<{sess.Id} {mUser.Username}> {messageText}"); +#endif + + IChatMessage message = null; + + if(messageText[0] == '/') { + message = HandleV1Command(messageText, mUser, mChannel); + + if(message == null) + break; + } + + if(message == null) + message = new ChatMessage { + Target = mChannel, + TargetName = mChannel.TargetName, + DateTime = DateTimeOffset.UtcNow, + Sender = mUser, + Text = messageText, + }; + + Context.Events.Add(message); + mChannel.Send(new ChatMessageAddPacket(message)); + break; + + case SockChatClientPacket.FocusChannel: + if(sess.User == null || args.Length < 2) + break; + + ChatChannel fChannel = Context.Channels.Get(args[1]); + if(fChannel == null || sess.User.CurrentChannel == fChannel) + break; + + sess.User.FocusChannel(fChannel); + break; + + case SockChatClientPacket.Typing: + if(!ENABLE_TYPING_EVENT || sess.User == null) + break; + + ChatChannel tChannel = sess.User.CurrentChannel; + if(tChannel == null || !tChannel.CanType(sess.User)) + break; + + ChatChannelTyping tInfo = tChannel.RegisterTyping(sess.User); + if(tInfo == null) + return; + + tChannel.Send(new TypingPacket(tChannel, tInfo)); + break; + } + } + + public IChatMessage HandleV1Command(string message, ChatUser user, ChatChannel channel) { + string[] parts = message.Substring(1).Split(' '); + string commandName = parts[0].Replace(@".", string.Empty).ToLowerInvariant(); + + for(int i = 1; i < parts.Length; i++) + parts[i] = parts[i].Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
"); + + IChatCommand command = null; + foreach(IChatCommand cmd in Commands) + if(cmd.IsMatch(commandName)) { + command = cmd; + break; + } + + if(command != null) + return command.Dispatch(new ChatCommandContext(parts, user, channel)); + + switch(commandName) { + case @"nick": // sets a temporary nickname + bool setOthersNick = user.Can(ChatUserPermissions.SetOthersNickname); + + if(!setOthersNick && !user.Can(ChatUserPermissions.SetOwnNickname)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + ChatUser targetUser = null; + int offset = 1; + + if(setOthersNick && parts.Length > 1 && long.TryParse(parts[1], out long targetUserId) && targetUserId > 0) { + targetUser = Context.Users.Get(targetUserId); + offset = 2; + } + + if(targetUser == null) + targetUser = user; + + if(parts.Length < offset) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + string nickStr = string.Join('_', parts.Skip(offset)) + .Replace(' ', '_') + .Replace("\n", string.Empty) + .Replace("\r", string.Empty) + .Replace("\f", string.Empty) + .Replace("\t", string.Empty) + .Trim(); + + if(nickStr == targetUser.Username) + nickStr = null; + else if(nickStr.Length > 15) + nickStr = nickStr.Substring(0, 15); + else if(string.IsNullOrEmpty(nickStr)) + nickStr = null; + + if(nickStr != null && Context.Users.Get(nickStr) != null) { + user.Send(new LegacyCommandResponse(LCR.NAME_IN_USE, true, nickStr)); + break; + } + + string previousName = targetUser == user ? (targetUser.Nickname ?? targetUser.Username) : null; + targetUser.Nickname = nickStr; + channel.Send(new UserUpdatePacket(targetUser, previousName)); + break; + case @"whisper": // sends a pm to another user + case @"msg": + if(parts.Length < 3) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + ChatUser whisperUser = Context.Users.Get(parts[1]); + + if(whisperUser == null) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts[1])); + break; + } + + if(whisperUser == user) + break; + + string whisperStr = string.Join(' ', parts.Skip(2)); + + whisperUser.Send(new ChatMessageAddPacket(new ChatMessage { + DateTime = DateTimeOffset.Now, + Target = whisperUser, + TargetName = whisperUser.TargetName, + Sender = user, + Text = whisperStr, + Flags = ChatMessageFlags.Private, + })); + user.Send(new ChatMessageAddPacket(new ChatMessage { + DateTime = DateTimeOffset.Now, + Target = whisperUser, + TargetName = whisperUser.TargetName, + Sender = user, + Text = $@"{whisperUser.DisplayName} {whisperStr}", + Flags = ChatMessageFlags.Private, + })); + break; + case @"action": // describe an action + case @"me": + if(parts.Length < 2) + break; + + string actionMsg = string.Join(' ', parts.Skip(1)); + + return new ChatMessage { + Target = channel, + TargetName = channel.TargetName, + DateTime = DateTimeOffset.UtcNow, + Sender = user, + Text = actionMsg, + Flags = ChatMessageFlags.Action, + }; + case @"who": // gets all online users/online users in a channel if arg + StringBuilder whoChanSB = new StringBuilder(); + string whoChanStr = parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : string.Empty; + + if(!string.IsNullOrEmpty(whoChanStr)) { + ChatChannel whoChan = Context.Channels.Get(whoChanStr); + + if(whoChan == null) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, whoChanStr)); + break; + } + + if(whoChan.Rank > user.Rank || (whoChan.HasPassword && !user.Can(ChatUserPermissions.JoinAnyChannel))) { + user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_ERROR, true, whoChanStr)); + break; + } + + foreach(ChatUser whoUser in whoChan.GetUsers()) { + whoChanSB.Append(@""); + whoChanSB.Append(whoUser.DisplayName); + whoChanSB.Append(@", "); + } + + if(whoChanSB.Length > 2) + whoChanSB.Length -= 2; + + user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_CHANNEL, false, whoChanSB)); + } else { + foreach(ChatUser whoUser in Context.Users.All()) { + whoChanSB.Append(@""); + whoChanSB.Append(whoUser.DisplayName); + whoChanSB.Append(@", "); + } + + if(whoChanSB.Length > 2) + whoChanSB.Length -= 2; + + user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_SERVER, false, whoChanSB)); + } + break; + + // double alias for delchan and delmsg + // if the argument is a number we're deleting a message + // if the argument is a string we're deleting a channel + case @"delete": + if(parts.Length < 2) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + if(parts[1].All(char.IsDigit)) + goto case @"delmsg"; + goto case @"delchan"; + + // anyone can use these + case @"join": // join a channel + if(parts.Length < 2) + break; + + ChatChannel joinChan = Context.Channels.Get(parts[1]); + + if(joinChan == null) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, parts[1])); + user.ForceChannel(); + break; + } + + Context.SwitchChannel(user, joinChan, string.Join(' ', parts.Skip(2))); + break; + case @"create": // create a new channel + if(user.Can(ChatUserPermissions.CreateChannel)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + bool createChanHasHierarchy; + if(parts.Length < 2 || (createChanHasHierarchy = parts[1].All(char.IsDigit) && parts.Length < 3)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + int createChanHierarchy = 0; + if(createChanHasHierarchy) + int.TryParse(parts[1], out createChanHierarchy); + + if(createChanHierarchy > user.Rank) { + user.Send(new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY)); + break; + } + + string createChanName = string.Join('_', parts.Skip(createChanHasHierarchy ? 2 : 1)); + ChatChannel createChan = new() { + Name = createChanName, + IsTemporary = !user.Can(ChatUserPermissions.SetChannelPermanent), + Rank = createChanHierarchy, + Owner = user, + }; + + try { + Context.Channels.Add(createChan); + } catch(ChannelExistException) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_ALREADY_EXISTS, true, createChan.Name)); + break; + } catch(ChannelInvalidNameException) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_NAME_INVALID)); + break; + } + + Context.SwitchChannel(user, createChan, createChan.Password); + user.Send(new LegacyCommandResponse(LCR.CHANNEL_CREATED, false, createChan.Name)); + break; + case @"delchan": // delete a channel + if(parts.Length < 2 || string.IsNullOrWhiteSpace(parts[1])) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + string delChanName = string.Join('_', parts.Skip(1)); + ChatChannel delChan = Context.Channels.Get(delChanName); + + if(delChan == null) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, delChanName)); + break; + } + + if(!user.Can(ChatUserPermissions.DeleteChannel) && delChan.Owner != user) { + user.Send(new LegacyCommandResponse(LCR.CHANNEL_DELETE_FAILED, true, delChan.Name)); + break; + } + + Context.Channels.Remove(delChan); + user.Send(new LegacyCommandResponse(LCR.CHANNEL_DELETED, false, delChan.Name)); + break; + case @"password": // set a password on the channel + case @"pwd": + if(!user.Can(ChatUserPermissions.SetChannelPassword) || channel.Owner != user) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + string chanPass = string.Join(' ', parts.Skip(1)).Trim(); + + if(string.IsNullOrWhiteSpace(chanPass)) + chanPass = string.Empty; + + Context.Channels.Update(channel, password: chanPass); + user.Send(new LegacyCommandResponse(LCR.CHANNEL_PASSWORD_CHANGED, false)); + break; + case @"privilege": // sets a minimum hierarchy requirement on the channel + case @"rank": + case @"priv": + if(!user.Can(ChatUserPermissions.SetChannelHierarchy) || channel.Owner != user) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + if(parts.Length < 2 || !int.TryParse(parts[1], out int chanHierarchy) || chanHierarchy > user.Rank) { + user.Send(new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY)); + break; + } + + Context.Channels.Update(channel, hierarchy: chanHierarchy); + user.Send(new LegacyCommandResponse(LCR.CHANNEL_HIERARCHY_CHANGED, false)); + break; + + case @"say": // pretend to be the bot + if(!user.Can(ChatUserPermissions.Broadcast)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + Context.Send(new LegacyCommandResponse(LCR.BROADCAST, false, string.Join(' ', parts.Skip(1)))); + break; + case @"delmsg": // deletes a message + bool deleteAnyMessage = user.Can(ChatUserPermissions.DeleteAnyMessage); + + if(!deleteAnyMessage && !user.Can(ChatUserPermissions.DeleteOwnMessage)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + if(parts.Length < 2 || !parts[1].All(char.IsDigit) || !long.TryParse(parts[1], out long delSeqId)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + IChatEvent delMsg = Context.Events.Get(delSeqId); + + if(delMsg == null || delMsg.Sender.Rank > user.Rank || (!deleteAnyMessage && delMsg.Sender.UserId != user.UserId)) { + user.Send(new LegacyCommandResponse(LCR.MESSAGE_DELETE_ERROR)); + break; + } + + Context.Events.Remove(delMsg); + break; + case @"kick": // kick a user from the server + case @"ban": // ban a user from the server, this differs from /kick in that it adds all remote address to the ip banlist + bool isBanning = commandName == @"ban"; + + if(!user.Can(isBanning ? ChatUserPermissions.BanUser : ChatUserPermissions.KickUser)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + ChatUser banUser; + + if(parts.Length < 2 || (banUser = Context.Users.Get(parts[1])) == null) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1])); + break; + } + + if(banUser == user || banUser.Rank >= user.Rank || Context.Bans.Check(banUser) > DateTimeOffset.Now) { + user.Send(new LegacyCommandResponse(LCR.KICK_NOT_ALLOWED, true, banUser.DisplayName)); + break; + } + + DateTimeOffset? banUntil = isBanning ? (DateTimeOffset?)DateTimeOffset.MaxValue : null; + + if(parts.Length > 2) { + if(!double.TryParse(parts[2], out double silenceSeconds)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + banUntil = DateTimeOffset.UtcNow.AddSeconds(silenceSeconds); + } + + Context.BanUser(banUser, banUntil, isBanning); + break; + case @"pardon": + case @"unban": + if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + if(parts.Length < 2) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, string.Empty)); + break; + } + + BannedUser unbanUser = Context.Bans.GetUser(parts[1]); + + if(unbanUser == null || unbanUser.Expires <= DateTimeOffset.Now) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanUser?.Username ?? parts[1])); + break; + } + + Context.Bans.Remove(unbanUser); + + user.Send(new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanUser)); + break; + case @"pardonip": + case @"unbanip": + if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + if(parts.Length < 2 || !IPAddress.TryParse(parts[1], out IPAddress unbanIP)) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, string.Empty)); + break; + } + + if(Context.Bans.Check(unbanIP) <= DateTimeOffset.Now) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanIP)); + break; + } + + Context.Bans.Remove(unbanIP); + + user.Send(new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanIP)); + break; + case @"bans": // gets a list of bans + case @"banned": + if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + user.Send(new BanListPacket(Context.Bans.All())); + break; + case @"silence": // silence a user + if(!user.Can(ChatUserPermissions.SilenceUser)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + ChatUser silUser; + + if(parts.Length < 2 || (silUser = Context.Users.Get(parts[1])) == null) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1])); + break; + } + + if(silUser == user) { + user.Send(new LegacyCommandResponse(LCR.SILENCE_SELF)); + break; + } + + if(silUser.Rank >= user.Rank) { + user.Send(new LegacyCommandResponse(LCR.SILENCE_HIERARCHY)); + break; + } + + if(silUser.IsSilenced) { + user.Send(new LegacyCommandResponse(LCR.SILENCE_ALREADY)); + break; + } + + DateTimeOffset silenceUntil = DateTimeOffset.MaxValue; + + if(parts.Length > 2) { + if(!double.TryParse(parts[2], out double silenceSeconds)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR)); + break; + } + + silenceUntil = DateTimeOffset.UtcNow.AddSeconds(silenceSeconds); + } + + silUser.SilencedUntil = silenceUntil; + silUser.Send(new LegacyCommandResponse(LCR.SILENCED, false)); + user.Send(new LegacyCommandResponse(LCR.TARGET_SILENCED, false, silUser.DisplayName)); + break; + case @"unsilence": // unsilence a user + if(!user.Can(ChatUserPermissions.SilenceUser)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}")); + break; + } + + ChatUser unsilUser; + + if(parts.Length < 2 || (unsilUser = Context.Users.Get(parts[1])) == null) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1])); + break; + } + + if(unsilUser.Rank >= user.Rank) { + user.Send(new LegacyCommandResponse(LCR.UNSILENCE_HIERARCHY)); + break; + } + + if(!unsilUser.IsSilenced) { + user.Send(new LegacyCommandResponse(LCR.NOT_SILENCED)); + break; + } + + unsilUser.SilencedUntil = DateTimeOffset.MinValue; + unsilUser.Send(new LegacyCommandResponse(LCR.UNSILENCED, false)); + user.Send(new LegacyCommandResponse(LCR.TARGET_UNSILENCED, false, unsilUser.DisplayName)); + break; + case @"ip": // gets a user's ip (from all connections in this case) + case @"whois": + if(!user.Can(ChatUserPermissions.SeeIPAddress)) { + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, @"/ip")); + break; + } + + ChatUser ipUser; + if(parts.Length < 2 || (ipUser = Context.Users.Get(parts[1])) == null) { + user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1])); + break; + } + + foreach(IPAddress ip in ipUser.RemoteAddresses.Distinct().ToArray()) + user.Send(new LegacyCommandResponse(LCR.IP_ADDRESS, false, ipUser.Username, ip)); + break; + + default: + user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_FOUND, true, commandName)); + break; + } + + return null; + } + + ~SockChatServer() + => DoDispose(); + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Sessions?.Clear(); + Server?.Dispose(); + Context?.Dispose(); + } + } +} diff --git a/SharpChat/UserManager.cs b/SharpChat/UserManager.cs new file mode 100644 index 0000000..605882c --- /dev/null +++ b/SharpChat/UserManager.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat { + public class UserManager : IDisposable { + private readonly List Users = new List(); + + public readonly ChatContext Context; + + public bool IsDisposed { get; private set; } + + public UserManager(ChatContext context) { + Context = context; + } + + public void Add(ChatUser user) { + if (user == null) + throw new ArgumentNullException(nameof(user)); + + lock(Users) + if(!Contains(user)) + Users.Add(user); + } + + public void Remove(ChatUser user) { + if (user == null) + return; + + lock(Users) + Users.Remove(user); + } + + public bool Contains(ChatUser user) { + if (user == null) + return false; + + lock (Users) + return Users.Contains(user) || Users.Any(x => x.UserId == user.UserId || x.Username.ToLowerInvariant() == user.Username.ToLowerInvariant()); + } + + public ChatUser Get(long userId) { + lock(Users) + return Users.FirstOrDefault(x => x.UserId == userId); + } + + public ChatUser Get(string username, bool includeNickName = true, bool includeDisplayName = true) { + if (string.IsNullOrWhiteSpace(username)) + return null; + username = username.ToLowerInvariant(); + + lock(Users) + return Users.FirstOrDefault(x => x.Username.ToLowerInvariant() == username + || (includeNickName && x.Nickname?.ToLowerInvariant() == username) + || (includeDisplayName && x.DisplayName.ToLowerInvariant() == username)); + } + + public IEnumerable OfHierarchy(int hierarchy) { + lock (Users) + return Users.Where(u => u.Rank >= hierarchy).ToList(); + } + + public IEnumerable WithActiveConnections() { + lock (Users) + return Users.Where(u => u.HasSessions).ToList(); + } + + public IEnumerable All() { + lock (Users) + return Users.ToList(); + } + + ~UserManager() + => Dispose(false); + + public void Dispose() + => Dispose(true); + + private void Dispose(bool disposing) { + if (IsDisposed) + return; + IsDisposed = true; + + Users.Clear(); + + if (disposing) + GC.SuppressFinalize(this); + } + } +}