diff --git a/.gitignore b/.gitignore index 40daee9..1d85891 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. +SharpChat.Common/version.txt + # User-specific files *.suo *.user diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..115f10f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "hamakaze"] + path = hamakaze + url = https://git.flash.moe/flash/hamakaze.git diff --git a/Hamakaze/Headers/HttpAcceptEncodingHeader.cs b/Hamakaze/Headers/HttpAcceptEncodingHeader.cs deleted file mode 100644 index 47b60a7..0000000 --- a/Hamakaze/Headers/HttpAcceptEncodingHeader.cs +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 50b1318..0000000 --- a/Hamakaze/Headers/HttpConnectionHeader.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 9972819..0000000 --- a/Hamakaze/Headers/HttpContentEncodingHeader.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 53f2d31..0000000 --- a/Hamakaze/Headers/HttpContentLengthHeader.cs +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 8ef6846..0000000 --- a/Hamakaze/Headers/HttpContentTypeHeader.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index bfd7196..0000000 --- a/Hamakaze/Headers/HttpCustomHeader.cs +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 91c36fe..0000000 --- a/Hamakaze/Headers/HttpDateHeader.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index b9f75fa..0000000 --- a/Hamakaze/Headers/HttpHeader.cs +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index c263ff9..0000000 --- a/Hamakaze/Headers/HttpHostHeader.cs +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index d8ab2c3..0000000 --- a/Hamakaze/Headers/HttpKeepAliveHeader.cs +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index c2d665f..0000000 --- a/Hamakaze/Headers/HttpServerHeader.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 0ccc4c3..0000000 --- a/Hamakaze/Headers/HttpTeHeader.cs +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index a62939a..0000000 --- a/Hamakaze/Headers/HttpTransferEncodingHeader.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index b8aa5ec..0000000 --- a/Hamakaze/Headers/HttpUserAgentHeader.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index be009b5..0000000 --- a/Hamakaze/HttpClient.cs +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 8bb90d0..0000000 --- a/Hamakaze/HttpConnection.cs +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index a0c5853..0000000 --- a/Hamakaze/HttpConnectionManager.cs +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 5b6e3a5..0000000 --- a/Hamakaze/HttpEncoding.cs +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index d6e0bce..0000000 --- a/Hamakaze/HttpException.cs +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index e3ca1e6..0000000 --- a/Hamakaze/HttpMediaType.cs +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index cbfc22e..0000000 --- a/Hamakaze/HttpMessage.cs +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 6ce3ce3..0000000 --- a/Hamakaze/HttpRequestMessage.cs +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index c041e93..0000000 --- a/Hamakaze/HttpResponseMessage.cs +++ /dev/null @@ -1,265 +0,0 @@ -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 deleted file mode 100644 index ddcd212..0000000 --- a/Hamakaze/HttpTask.cs +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index ef12439..0000000 --- a/Hamakaze/HttpTaskManager.cs +++ /dev/null @@ -1,41 +0,0 @@ -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/HttpClientTest/HttpClientTest.csproj b/HttpClientTest/HttpClientTest.csproj new file mode 100644 index 0000000..4e308dd --- /dev/null +++ b/HttpClientTest/HttpClientTest.csproj @@ -0,0 +1,12 @@ + + + + Exe + net5.0 + + + + + + + diff --git a/HttpClientTest/Program.cs b/HttpClientTest/Program.cs new file mode 100644 index 0000000..586ef1e --- /dev/null +++ b/HttpClientTest/Program.cs @@ -0,0 +1,147 @@ +using Hamakaze; +using System; +using System.Threading; +using static System.Console; + +namespace HttpClientTest { + public static class Program { + public static void Main(string[] args) { + ResetColor(); + + HttpClient.Instance.DefaultUserAgent = @"SharpChat/1.0"; + + /*string[] commonMediaTypes = new[] { + @"application/x-executable", + @"application/graphql", + @"application/javascript", + @"application/x.fwif", + @"application/json", + @"application/ld+json", + @"application/msword", + @"application/pdf", + @"application/sql", + @"application/vnd.api+json", + @"application/vnd.ms-excel", + @"application/vnd.ms-powerpoint", + @"application/vnd.oasis.opendocument.text", + @"application/vnd.openxmlformats-officedocument.presentationml.presentation", + @"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + @"application/vnd.openxmlformats-officedocument.wordprocessingml.document", + @"application/x-www-form-urlencoded", + @"application/xml", + @"application/zip", + @"application/zstd", + @"audio/mpeg", + @"audio/ogg", + @"image/gif", + @"image/apng", + @"image/flif", + @"image/webp", + @"image/x-mng", + @"image/jpeg", + @"image/png", + @"multipart/form-data", + @"text/css", + @"text/csv", + @"text/html", + @"text/php", + @"text/plain", + @"text/xml", + @"text/html; charset=utf-8", + }; + + Logger.Write(@"Testing Media Type parsing..."); + foreach(string mts in commonMediaTypes) { + HttpMediaType hmt = HttpMediaType.Parse(mts); + Logger.Write($@"O {mts}"); + Logger.Write($@"P {hmt}"); + } + + return;*/ + + static void setForeground(ConsoleColor color) { + ResetColor(); + ForegroundColor = color; + } + + using ManualResetEvent mre = new(false); + bool kill = false; + string[] urls = { + @"https://flashii.net/", + @"https://flashii.net/changelog", + @"https://abyss.flash.moe/", + @"https://flashii.net/info/contact", + @"https://flashii.net/news/", + @"https://flash.moe/", + @"https://flashii.net/forum/", + }; + + foreach(string url in urls) { + // routine lifted out of satori + string paramUrl = Uri.EscapeDataString(url); + HttpClient.Send( + new HttpRequestMessage(HttpRequestMessage.GET, $@"https://mii.flashii.net/metadata?url={paramUrl}"), + onComplete: (task, res) => { + WriteLine($@"Connection: {task.Request.Connection}"); + WriteLine($@"AcceptEncodings: {string.Join(@", ", task.Request.AcceptedEncodings)}"); + WriteLine($@"IsSecure: {task.Request.IsSecure}"); + WriteLine($@"RequestTarget: {task.Request.RequestTarget}"); + WriteLine($@"UserAgent: {task.Request.UserAgent}"); + WriteLine($@"ContentType: {task.Request.ContentType}"); + WriteLine(); + + setForeground(ConsoleColor.Green); + + WriteLine($@"Connection: {res.StatusCode}"); + WriteLine($@"Connection: {res.StatusMessage}"); + WriteLine($@"Connection: {res.Connection}"); + WriteLine($@"ContentEncodings: {string.Join(@", ", res.ContentEncodings)}"); + WriteLine($@"TransferEncodings: {string.Join(@", ", res.TransferEncodings)}"); + WriteLine($@"Date: {res.Date}"); + WriteLine($@"Server: {res.Server}"); + WriteLine($@"ContentType: {res.ContentType}"); + WriteLine(); + + /*if(res.HasBody) { + string line; + using StreamWriter sw = new StreamWriter(@"out.html", false, new UTF8Encoding(false)); + using StreamReader sr = new StreamReader(res.Body, new UTF8Encoding(false), false, leaveOpen: true); + while((line = sr.ReadLine()) != null) { + //Logger.Debug(line); + sw.WriteLine(line); + } + }*/ + }, + onError: (task, ex) => { + setForeground(ConsoleColor.Red); + WriteLine(ex); + }, + onCancel: task => { + setForeground(ConsoleColor.Yellow); + WriteLine(@"Cancelled."); + }, + onDownloadProgress: (task, p, t) => { + setForeground(ConsoleColor.Blue); + WriteLine($@"Downloaded {p} bytes of {t} bytes."); + }, + onUploadProgress: (task, p, t) => { + setForeground(ConsoleColor.Magenta); + WriteLine($@"Uploaded {p} bytes of {t} bytes."); + }, + onStateChange: (task, s) => { + setForeground(ConsoleColor.White); + WriteLine($@"State changed: {s}"); + + if(!kill && (task.IsFinished || task.IsCancelled)) { + kill = true; + mre?.Set(); + } + } + ); + } + + mre.WaitOne(); + ResetColor(); + } + } +} diff --git a/LICENSE b/LICENSE index 667de78..dad4156 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2022 flashwave +Copyright (c) 2019-2022 flashwave Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MisuzuDataProviderTest/MisuzuDataProviderTest.csproj b/MisuzuDataProviderTest/MisuzuDataProviderTest.csproj new file mode 100644 index 0000000..e11ac18 --- /dev/null +++ b/MisuzuDataProviderTest/MisuzuDataProviderTest.csproj @@ -0,0 +1,13 @@ + + + + Exe + net5.0 + + + + + + + + diff --git a/MisuzuDataProviderTest/Program.cs b/MisuzuDataProviderTest/Program.cs new file mode 100644 index 0000000..af81fa7 --- /dev/null +++ b/MisuzuDataProviderTest/Program.cs @@ -0,0 +1,99 @@ +using Hamakaze; +using SharpChat.Bans; +using SharpChat.Configuration; +using SharpChat.DataProvider; +using SharpChat.DataProvider.Misuzu; +using SharpChat.Users.Remote; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading; +using static System.Console; + +namespace MisuzuDataProviderTest { + public static class Program { + public static void Main() { + WriteLine("Misuzu Authentication Tester"); + + using ManualResetEvent mre = new(false); + + string cfgPath = Path.GetDirectoryName(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); + string buildMode = Path.GetFileName(cfgPath); + cfgPath = Path.Combine( + Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(cfgPath))), + @"SharpChat", @"bin", buildMode, @"net5.0", @"sharpchat.cfg" + ); + + WriteLine($@"Reading config from {cfgPath}"); + + using IConfig config = new StreamConfig(cfgPath); + + WriteLine($@"Enter token found on {config.ReadValue(@"dp:misuzu:endpoint")}/login:"); + string[] token = ReadLine().Split(new[] { '_' }, 2); + + HttpClient.Instance.DefaultUserAgent = @"SharpChat/1.0"; + + IDataProvider dataProvider = new MisuzuDataProvider(config.ScopeTo(@"dp:misuzu"), HttpClient.Instance); + + long userId = long.Parse(token[0]); + IPAddress remoteAddr = IPAddress.Parse(@"1.2.4.8"); + + IUserAuthResponse authRes = null; + mre.Reset(); + dataProvider.UserClient.AuthenticateUser( + new UserAuthRequest(userId, token[1], remoteAddr), + onSuccess: res => { + authRes = res; + WriteLine(@"Auth success!"); + WriteLine($@" User ID: {authRes.UserId}"); + WriteLine($@" Username: {authRes.UserName}"); + WriteLine($@" Colour: {authRes.Colour.Raw:X8}"); + WriteLine($@" Hierarchy: {authRes.Rank}"); + WriteLine($@" Silenced: {authRes.SilencedUntil}"); + WriteLine($@" Perms: {authRes.Permissions}"); + mre.Set(); + }, + onFailure: ex => { + WriteLine($@"Auth failed: {ex.Message}"); + mre.Set(); + } + ); + mre.WaitOne(); + + if(authRes == null) + return; + +#if FUCKED + WriteLine(@"Bumping last seen..."); + mre.Reset(); + dataProvider.UserBumpClient.SubmitBumpUsers( + new[] { new User(authRes) }, + onSuccess: () => mre.Set(), + onFailure: ex => { + WriteLine($@"Bump failed: {ex.Message}"); + mre.Set(); + } + ); + mre.WaitOne(); +#endif + + WriteLine(@"Fetching ban list..."); + IEnumerable bans = Enumerable.Empty(); + + mre.Reset(); + dataProvider.BanClient.GetBanList(x => { bans = x; mre.Set(); }, e => { WriteLine(e); mre.Set(); }); + mre.WaitOne(); + + WriteLine($@"{bans.Count()} BANS"); + foreach(IBanRecord ban in bans) { + WriteLine($@"BAN INFO"); + WriteLine($@" User ID: {ban.UserId}"); + WriteLine($@" Username: {ban.UserName}"); + WriteLine($@" IP Address: {ban.UserIP}"); + WriteLine($@" Expires: {ban.Expires}"); + } + } + } +} diff --git a/Protocol-draft.md b/Protocol-draft.md index b81aff3..d8fd791 100644 --- a/Protocol-draft.md +++ b/Protocol-draft.md @@ -6,30 +6,38 @@ Newer versions of the protocol are implemented as extensions, a client for Versi 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: @@ -42,6 +50,7 @@ The parts are as follows: 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. @@ -75,6 +84,7 @@ Which of the methods is used remains consistent per server however, so the resul + ## Client Packets These are the packets sent from the client to the server. @@ -87,13 +97,9 @@ Used to prevent the client from closing the session due to inactivity. User ID - - timestamp - Time the packet was sent to the server - Added in version 2 - + ### Packet `1`: Authentication Takes a variable number of parameters that are fed into the authentication script associated with the chat. @@ -105,6 +111,7 @@ Takes a variable number of parameters that are fed into the authentication scrip + ### Packet `2`: Message Informs the server that the user has sent a message. @@ -123,23 +130,6 @@ Commands are described lower in the document. -### 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. @@ -155,6 +145,7 @@ Response to client packet `0`: Ping. + ### 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. @@ -192,18 +183,9 @@ Informs the client that authentication has succeeded. Default channel the user will join following this packet - - int - Extensions version number. If this field is missing, version 1 is implied. - Added in Version 2 - - - session id - ID of the currently active session - Added in Version 2 - + #### Failed authentication response Informs the client that authentication has failed. @@ -233,6 +215,7 @@ Informs the client that authentication has failed. + #### User joining Informs the client that a user has joined. @@ -269,6 +252,7 @@ Informs the client that a user has joined. + ### Packet `2`: Chat message Informs the client that a chat message has been received. @@ -326,13 +310,9 @@ Informs the client that a chat message has been received. Message flags - - channel name - Channel this message was sent in - Added in Version 2 - + ### Packet `3`: User disconnect Informs the client that a user has disconnected. @@ -372,6 +352,7 @@ Informs the client that a user has disconnected. + ### 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. @@ -390,6 +371,7 @@ This packet informs the user about channel related updates. The only consistent + #### Sub-packet `0`: Channel creation Informs the client that a channel has been created. @@ -411,6 +393,7 @@ Informs the client that a channel has been created. + #### Sub-packet `1`: Channel update Informs the client that details of a channel has changed. @@ -437,6 +420,7 @@ Informs the client that details of a channel has changed. + #### Sub-packet `2`: Channel deletion Informs the client that a channel has been deleted @@ -448,6 +432,7 @@ Informs the client that a channel has been deleted + ### Packet `5`: Channel switching This packet informs the client about channel switching. @@ -466,6 +451,7 @@ This packet informs the client about channel switching. + #### Sub-packet `0`: Channel join Informs the client that a user has joined the channel. @@ -492,6 +478,7 @@ Informs the client that a user has joined the channel. + #### Sub-packet `1`: Channel departure Informs the client that a user has left the channel. @@ -508,6 +495,7 @@ Informs the client that a user has left the channel. + #### Sub-packet `2`: Forced channel switch Informs the client that it has been forcibly switched to a different channel. @@ -519,6 +507,7 @@ Informs the client that it has been forcibly switched to a different channel. + ### Packet `6`: Message deletion Informs the client that a message has been deleted. @@ -530,6 +519,7 @@ Informs the client that a message has been deleted. + ### Packet `7`: Context information Informs the client about the context of a channel before the client was present. @@ -594,6 +584,7 @@ Informs the client about users already present in the channel. + #### Sub-packet `1`: Existing message Informs the client about an existing message in a channel. @@ -645,6 +636,7 @@ Informs the client about an existing message in a channel. + #### Sub-packet `2`: Channels Informs the client about the channels on the server. @@ -681,6 +673,7 @@ Informs the client about the channels on the server. + ### Packet `8`: Context clearing Informs the client that the context has been cleared. @@ -699,13 +692,9 @@ Informs the client that the context has been cleared. - - channel name - Channel 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. @@ -727,6 +716,7 @@ Informs the client that they have either been banned or kicked from the server. + ### Packet `10`: User update Informs that another user's details have been updated. @@ -753,571 +743,632 @@ Informs that another user's details have been updated. -### 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.
+### Informational + +#### `say`: Broadcast +Just echo whatever is specified in the first argument. + +##### Arguments + - `string`: Message to be broadcast. + + +#### `silence`: Silence notice +Informs the client that they've been silenced. + + +#### `unsil`: Silence revocation notice +Informs the client that their silence has been revoked. + + +#### `silok`: Silence confirmation +Informs the client that they have successfully silenced another user. + +##### Arguments + - `string`: Username of the user. + + +#### `usilok`: Silence revocation confirmation +Informs the client that they have successfully revoked another user's silence. + +##### Arguments + - `string`: Username of the user. + + +#### `flwarn`: Flood protection warning +Informs the client that they are risking getting kicked for flood protection (spam) if they continue sending messages at the same rate. + + +#### `unban`: Ban revocation confirmation +Informs the client that they have successfully revoked a ban on a user or an IP address. + + +#### `banlist`: List of banned entities +Provides the client with a list of all banned users and IP addresses. + +##### Arguments + - `string`: HTML with the information on the users with the following format: "<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 ", ". + + +#### `who`: List of online users +Provides the client with a list of users currently online on the server. + +##### Arguments + - `string`: HTML with the information on the users with the following format: "<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 ", ". + + +#### `whochan`: List of users in a channel. +Provides the client with a list of users currently online in a channel. + +##### Arguments + - `string`: HTML with the information on the users with the following format: "<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 ", " + + +#### `join`: User connected +Informs the client that a user just connected to the server. + +##### Arguments + - `string`: Username of the user. + + +#### `jchan`: User joined channel +Informs the client that a user just joined a channel they're in. + +##### Arguments + - `string`: Username of the user. + + +#### `leave`: User disconnected +Informs the client that a user just disconnected from the server. + +##### Arguments + - `string`: Username of the user. + + +#### `lchan`: User left channel +Informs the client that a user just left a channel they're in. + + +#### `kick`: User has been kicked +Informs the client that another user has just been kicked. + +##### Arguments + - `string`: Username of the user. + + +#### `flood`: User exceeded flood limit +Informs the client that another user has just been kicked for exceeding the flood protection limit. + +##### Arguments + - `string`: Username of the user. + + +#### `timeout`: User has timed out +Informs the client that another user has been disconnected from the server automatically. + +##### Arguments + - `string`: Username of the user. + + +#### `nick`: User has changed their nickname +Informs the client that a user has changed their nickname. + +##### Arguments + - `string`: Previous username of the user. + - `string`: New username of the user. + + +#### `crchan`: Channel creation confirmation +Informs the client that the channel they attempted to create has been successfully created. + +##### Arguments + - `string`: Name of the target channel. + + +#### `delchan`: Channel deletion confirmation +Informs the client that the channel they attempted to delete has been successfully deleted. + +##### Arguments + - `string`: Name of the target channel. + + +#### `cpwdchan`: Channel password update confirmation +Informs the client that they've successfully changed the password of a channel. + + +#### `cprivchan`: Channel rank update confirmation +Informs the client that they've successfully changed the minimum required rank to join a channel. + + +#### `ipaddr`: IP address +Shows the IP address of another user to a user with moderation privileges. + +##### Arguments + - `string`: Name of the target user. + - `string`: IP address. + + +### Errors + +#### `generr`: Generic Error +Informs the client that Something went Wrong. + + +#### `nocmd`: Command not found +Informs the client that the command they tried to run does not exist. + +##### Arguments + - `string`: Name of the command. + + +#### `cmdna`: Command not allowed +Informs the client that they are not allowed to use a command. + +##### Arguments + - `string`: Name of the command. + + +#### `cmderr`: Command format error +Informs the client that the command they tried to run was incorrectly formatted. + +##### Arguments + - `string`: Name of the command. + + +#### `usernf`: User not found +Informs the client that the user argument of a command contains a user that is not known by the server. + +##### Arguments + - `string`: Name of the target user. + + +#### `rankerr`: Rank error +Informs the client that they are not allowed to do something because their ranking is too low. + + +#### `nameinuse`: Name in use +Informs the the client that the name they attempted to choose is already in use by another user. + +##### Arguments + - `string`: Name that is in use. + + +#### `whoerr`: User listing error +Informs the client that they do not have access to the channel they tried to query. + +##### Arguments + - `string`: Name of the channel. + + +#### `kickna`: Kick or ban not allowed +Informs the client that they are not allowed to kick a user. + +##### Arguments + - `string`: Username of the user in question. + + +#### `notban`: Not banned +Informs the client that the ban they tried to revoke was not in place. + +##### Arguments + - `string`: Username or IP address in question. + + +#### `nochan`: Channel not found +Informs the client that the channel they tried to join does not exist. + +##### Arguments + - `string`: Name of the channel. + + +#### `samechan`: Already in channel +Informs the client that they attempted to join a channel they are already in. + +##### Arguments + - `string`: Name of the channel. + + +#### `ipchan`: Channel join not allowed +Informs the client that they do not have sufficient rank or permissions to join a channel. + +##### Arguments + - `string`: Name of the channel. + + +#### `nopwchan`: No password provided +Informs the client that they must specify a password to join a channel. + +##### Arguments + - `string`: Name of the channel. + + +#### `ipwchan`: No password provided +Informs the client that the password they provided to join a channel was invalid. + +##### Arguments + - `string`: Name of the channel. + + +#### `inchan`: Invalid channel name +Informs the client that the name they tried to give to a channel contains invalid characters. + + +#### `nischan`: Channel name in use +Informs the client that the name they tried to give to a channel is already used by another channel. + +The first argument contains the name of the channel. + + +#### `ndchan`: Channel deletion error +Informs the client that they are not allowed to delete a channel. + +The first argument contains the name of the channel. + + +#### `namchan`: Channel edit error +Informs the client that they are not allowed to edit a channel. + +The first argument contains the name of the channel. + + +#### `delerr`: Message deletion error +Informs the client that they are not allowed to delete a message. + + +#### `silerr`: Already silenced +Informs the client that the user they attempted to silence has already been silenced. + + +#### `usilerr`: Not silenced +Informs the client that the user whose silence they attempted to revoke has not been silenced. + + +#### `silperr`: Silence permission error +Informs the client that they are not allowed to silence the other user. + + +#### `usilperr`: Silence revocation permission error +Informs the client that they are not allowed to revoke the silence on the other user. + + +#### `silself`: Self silence +Informs the client that they are not allowed to silence themselves. + ## Commands -Actions sent through messages prefixed with `/`. Arguments are described as `[name]`, optional arguments as `[name?]`. +Actions sent through messages prefixed with `/`. Arguments are described as `[name]`, optional arguments as `[name?]`. The `.` character is ignored in command names (replaced by nothing). - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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.
  • -
-
+### `/afk`: Setting status to away +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. + +#### Format +``` +/afk [reason?] +``` + + +### `/nick`: Change nickname +Temporarily changes the user's nickname, generally with a prefix such as `~` to avoid name clashing with real users. 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. + +#### Format +``` +/nick [name?] +``` + +#### Responses + - `cmdna`: User is not allowed to change their own nickname. + - `nameinuse`: The specified nickname is already in use by another user. + - `nick`: Username has changed. + + +### `/msg`: Sending a Private Message +Sends a private message to another user. + +#### Format +``` +/msg [username] [message] +``` + +#### Aliases + - `/whisper` + +#### Responses + - `cmderr`: Missing username and/or message arguments. + - `usernf`: Target user could not be found by the server. + + +### `/me`: Describing an action +Sends a message but with flags `11000` instead of the regular `10010`, used to describe an action. + +#### Format +``` +/me [message] +``` + +#### Aliases + - `/action` + + +### `/who`: Requesting a user list +Requests a list of users either currently online on the server in general or in a 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. + +#### Format +``` +/who [channel?] +``` + +#### Responses + - `nochan`: The given channel does not exist. + - `whoerr`: The user does not have access to the channel. + - `whochan`: Listing of users in the channel. + - `who`: Listing of users in the server. + + +### `/delete`: Deleting a message or channel +Due to an oversight in the original implementation, this command was specified to be both the command for deleting messages and for channels. Fortunately messages always have numeric IDs and channels must start with an alphabetic character. Thus if the argument is entirely numeric this function acts as an alias for `/delmsg`, otherwise `/delchan`. + +#### Format +``` +/delete [channel name or message id] +``` + +#### Responses +Inherits the responses of whichever command is forwarded to. + + +### `/join`: Joining a channel +Switches or joins the current user to a different channel. + +#### Format +``` +/join [channel] [password?] +``` + +#### Responses + - `nochan`: The given channel does not exist. + - `ipchan`: The client does not have the required rank to enter the given channel. + - `nopwchan`: A password is required to enter the given channel. + - `ipwchan`: The password provided was invalid. + + +### `/leave`: Leaving a channel +Leave a specified channel. + +#### Format +``` +/leave [channel] +``` + +#### Responses + - `nocmd`: The client tried to run this command without specifying the `MCHAN` capability. + + +### `/create`: Creating a channel +Creates a new channel. + +#### Format +``` +/create [rank?] [name...] +``` + +If the first argument is numeric, it is taken as the minimum required rank to join the channel. All further arguments are glued with underscores to create the channel name. + +#### Responses + - `cmdna`: The client is not allowed to create channels. + - `cmderr`: The command is formatted incorrectly. + - `rankerr`: The specified rank is higher than the client's own rank. + - `inchan`: The given channel name contains invalid characters. + - `nischan`: A channel with this name already exists. + - `crchan`: The channel has been created successfully. + + +### `/delchan`: Deleting a channel +Deletes an existing channel. + +#### Format +``` +/delchan [name] +``` + +#### Responses + - `cmderr`: The command is formatted incorrectly. + - `nochan`: No channel exists with this name. + - `ndchan`: The client is not allowed to delete this channel. + - `delchan`: The target channel has been deleted. + + +### `/password`: Update channel password +Changes the password for a channel. Removes the password if no argument is given. + +#### Format +``` +/password [password?] +``` + +#### Aliases + - `/pwd` + +#### Responses + - `cmdna`: The client is not allowed to change the password for this channel. + - `cpwdchan`: The password of the channel has been successfully updated. + + +### `/rank`: Update channel minimum rank +Changes what user rank is required to enter a channel. + +#### Format +``` +/rank [rank] +``` + +#### Aliases + - `/privilege` + - `/priv` + +#### Responses + - `cmdna`: The client is not allowed to change the rank of the target channel. + - `rankerr`: Missing rank argument or the given rank is higher than the client's own rank. + - `cprivchan`: The minimum rank of the channel has been successfully updated. + + +### `/say`: Broadcast a message +Broadcasts a message as the server/chatbot to all users in all channels. + +#### Format +``` +/say [message] +``` + +#### Responses + - `cmdna`: The client is not allowed to broadcast messages. + - `say`: The broadcasted message. + + +### `/delmsg`: Deleting a message +Deletes a given message. + +#### Format +``` +/delmsg [message id] +``` + +#### Responses + - `cmdna`: The client is not allowed to delete messages. + - `cmderr`: The given message ID was invalid. + - `delerr`: The target message does not exist or the client is not allowed to delete this message. + + +### `/kick`: Kick a user +Kicks a user from the serer. If not time is specified, then kick expires immediately. + +#### Format +``` +/kick [username] [time?] +``` + +#### Responses + - `cmdna`: The client is not allowed to kick others. + - `usernf`: The target user could not be found on the server. + - `kickna`: The client is trying to kick someone who they are not allowed to kick, or someone that is currently banned. + - `cmderr`: The provided time is invalid. + + +### `/ban`: Bans a user +Bans a user and their IP addresses from the server. If no time is specified the ban will never expire. + +#### Format +``` +/ban [user] [time?] +``` + +#### Responses + - `cmdna`: The client is not allowed to kick others. + - `usernf`: The target user could not be found on the server. + - `kickna`: The client is trying to kick someone who they are not allowed to kick, or someone that is currently banned. + - `cmderr`: The provided time is invalid. + + +### `/pardon`: Revokes a user ban +Revokes a ban currently placed on a user. + +#### Format +``` +/pardon [user] +``` + +### Aliases + - `/unban` + +#### Responses + - `cmdna`: The client is not allowed to revoke user bans. + - `notban`: The target user is not banned. + - `unban`: The ban on the target user has been successfully revoked. + + +### `/pardonip`: Revokes an IP address ban +Revokes a ban currently placed on an IP address. + +#### Format +``` +/pardonip [address] +``` + +#### Aliases + - `/unbanip` + +#### Responses + - `cmdna`: The client is not allowed to revoke IP bans. + - `notban`: The target address is not banned. + - `unban`: The ban on the target address has been successfully revoked. + + +### `/bans`: List of bans +Retrieves a list of banned users and IP addresses. + +#### Format +``` +/bans +``` + +#### Aliases + - `/banned` + +#### Responses + - `cmdna`: Not allowed to view banned users and IP addresses. + - `banlist`: The list of banned users and IP addresses. + + +### `/silence`: Silence a user +Silences a user. If the time argument is not specified, the silence is indefinite. + +#### Format +``` +/silence [username] [time?] +``` + +#### Responses + - `cmdna`: The client is not allowed to silence users. + - `usernf`: The target user could not be found on the server. + - `silself`: The client tried to silence themselves. + - `silperr`: The target user has a higher rank that the client. + - `silerr`: The target user is already silenced. + - `cmderr`: The time argument is formatted incorrectly. + - `silence`: Informs the target user that they have been silenced. + - `silok`: The target has been successfully silenced. + + +### `/unsilence`: Revokes a user silence +Revokes a user's silenced status. + +#### Format +``` +/unsilence [username] +``` + +#### Responses + - `cmdna`: The client is not allowed to revoke silences. + - `usernf`: The target user could not be found. + - `usilperr`: The target user has a higher rank than the client. + - `usilerr`: The target user isn't silenced. + - `unsil`: Informs the target user that their silenced status has been revoked. + - `usilok`: The silenced status placed on the target has been successfully revoked. + + +### `/ip`: Retrieve IP addresses +Retrieves a user's IP addresses. If the user has multiple connections, multiple `ipaddr` responses may be sent. + +#### Format +``` +/ip [username] +``` + +#### Aliases + - `whois` + +#### Responses + - `cmdna`: The client is not allowed to view IP addresses. + - `usernf`: The target user is not connected to the server. + - `ipaddr`: (One of) The target user's IP address(es). diff --git a/Protocol.md b/Protocol.md index 0e97508..6d70d06 100644 --- a/Protocol.md +++ b/Protocol.md @@ -1010,7 +1010,7 @@ Actions sent through messages prefixed with `/` in Version 1 of the protocol. Ar - /join [channel] + /join [channel] [password?] Switches the current user to a different channel. diff --git a/README.md b/README.md index 156636f..a628299 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,6 @@ /_/ ``` -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#. +Welcome to the repository of the temporary Flashii chat server. SharpChat is an event based chat server supporting multiple protocols (currently Sock Chat and IRC). + +> Formerly [PHP Sock Chat](https://github.com/flashwave/mahou-chat/) but without PHP but with C# but also with multiple sessions diff --git a/SharpChat.Common/Bans/BanManager.cs b/SharpChat.Common/Bans/BanManager.cs new file mode 100644 index 0000000..8da3b84 --- /dev/null +++ b/SharpChat.Common/Bans/BanManager.cs @@ -0,0 +1,344 @@ +using SharpChat.Events; +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.Net; + +namespace SharpChat.Bans { + public class BanManager { + private UserManager Users { get; } + private IBanClient BanClient { get; } + private IRemoteUserClient RemoteUserClient { get; } + private IEventDispatcher Dispatcher { get; } + private readonly object Sync = new(); + + public BanManager( + UserManager users, + IBanClient banClient, + IRemoteUserClient remoteUserClient, + IEventDispatcher dispatcher + ) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + BanClient = banClient ?? throw new ArgumentNullException(nameof(banClient)); + RemoteUserClient = remoteUserClient ?? throw new ArgumentNullException(nameof(remoteUserClient)); + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + public void GetBanList( + Action> onSuccess, + Action onFailure + ) { + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + BanClient.GetBanList(onSuccess, onFailure); + } + + public void CheckBan( + long userId, + IPAddress ipAddress, + Action onSuccess, + Action onFailure + ) { + if(ipAddress == null) + throw new ArgumentNullException(nameof(ipAddress)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + userId, + rui => { + if(rui == null) + onSuccess(null); + else + CheckBan(rui, ipAddress, onSuccess, onFailure); + }, + onFailure + ); + } + + public void CheckBan( + IUser localUserInfo, + IPAddress ipAddress, + Action onSuccess, + Action onFailure + ) { + if(localUserInfo == null) + throw new ArgumentNullException(nameof(localUserInfo)); + if(ipAddress == null) + throw new ArgumentNullException(nameof(ipAddress)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + localUserInfo, + rui => { + if(rui == null) + onSuccess(null); + else + CheckBan(rui, ipAddress, onSuccess, onFailure); + }, + onFailure + ); + } + + public void CheckBan( + IRemoteUser remoteUserInfo, + IPAddress ipAddress, + Action onSuccess, + Action onFailure + ) { + if(remoteUserInfo == null) + throw new ArgumentNullException(nameof(remoteUserInfo)); + if(ipAddress == null) + throw new ArgumentNullException(nameof(ipAddress)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + BanClient.CheckBan(remoteUserInfo, ipAddress, onSuccess, onFailure); + } + + public void CreateBan( + string subjectName, + IUser localModerator, + bool permanent, + TimeSpan duration, + string reason, + Action onSuccess, + Action onFailure + ) { + if(subjectName == null) + throw new ArgumentNullException(nameof(subjectName)); + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + subjectName, + remoteSubject => { + if(remoteSubject == null) + onSuccess(false); + else + CreateBan(remoteSubject, localModerator, permanent, duration, reason, onSuccess, onFailure); + }, + onFailure + ); + } + + public void CreateBan( + IUser localSubject, + IUser localModerator, + bool permanent, + TimeSpan duration, + string reason, + Action onSuccess, + Action onFailure + ) { + if(localSubject == null) + throw new ArgumentNullException(nameof(localSubject)); + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + localSubject, + remoteSubject => { + if(remoteSubject == null) + onSuccess(false); + else + CreateBan(remoteSubject, localModerator, permanent, duration, reason, onSuccess, onFailure); + }, + onFailure + ); + } + + public void CreateBan( + IRemoteUser remoteSubject, + IUser localModerator, + bool permanent, + TimeSpan duration, + string reason, + Action onSuccess, + Action onFailure + ) { + if(remoteSubject == null) + throw new ArgumentNullException(nameof(remoteSubject)); + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + localModerator, + remoteModerator => CreateBan(remoteSubject, remoteModerator, permanent, duration, reason, onSuccess, onFailure), + onFailure + ); + } + + public void CreateBan( + IRemoteUser remoteSubject, + IRemoteUser remoteModerator, + bool permanent, + TimeSpan duration, + string reason, + Action onSuccess, + Action onFailure + ) { + if(remoteSubject == null) + throw new ArgumentNullException(nameof(remoteSubject)); + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + BanClient.CreateBan(remoteSubject, remoteModerator, permanent, duration, reason, success => { + Dispatcher.DispatchEvent(this, new UserBanCreatedEvent(remoteSubject, remoteModerator, permanent, duration, reason)); + Users.Disconnect( + remoteSubject, + remoteModerator == null + ? UserDisconnectReason.Flood + : UserDisconnectReason.Kicked + ); + onSuccess.Invoke(success); + }, onFailure); + } + + public void RemoveBan( + long userId, + Action onSuccess, + Action onFailure + ) { + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + userId, + remoteUser => { + if(remoteUser == null) + onSuccess(false); + else + RemoveBan(remoteUser, onSuccess, onFailure); + }, + onFailure + ); + } + + public void RemoveBan( + string userName, + Action onSuccess, + Action onFailure + ) { + if(userName == null) + throw new ArgumentNullException(nameof(userName)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + userName, + remoteUser => { + if(remoteUser == null) + onSuccess(false); + else + RemoveBan(remoteUser, onSuccess, onFailure); + }, + onFailure + ); + } + + public void RemoveBan( + IUser localUser, + Action onSuccess, + Action onFailure + ) { + if(localUser == null) + throw new ArgumentNullException(nameof(localUser)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + RemoteUserClient.ResolveUser( + localUser, + remoteUser => { + if(remoteUser == null) + onSuccess(false); + else + RemoveBan(remoteUser, onSuccess, onFailure); + }, + onFailure + ); + } + + public void RemoveBan( + IRemoteUser remoteUser, + Action onSuccess, + Action onFailure + ) { + if(remoteUser == null) + throw new ArgumentNullException(nameof(remoteUser)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + BanClient.RemoveBan(remoteUser, success => { + Dispatcher.DispatchEvent(this, new UserBanRemovedEvent(remoteUser)); + onSuccess.Invoke(success); + }, onFailure); + } + + public void RemoveBan( + IPAddress ipAddress, + Action onSuccess, + Action onFailure + ) { + if(ipAddress == null) + throw new ArgumentNullException(nameof(ipAddress)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + lock(Sync) + BanClient.RemoveBan(ipAddress, success => { + Dispatcher.DispatchEvent(this, new IPBanRemovedEvent(ipAddress)); + onSuccess.Invoke(success); + }, onFailure); + } + } +} diff --git a/SharpChat.Common/Bans/IBanClient.cs b/SharpChat.Common/Bans/IBanClient.cs new file mode 100644 index 0000000..67a1601 --- /dev/null +++ b/SharpChat.Common/Bans/IBanClient.cs @@ -0,0 +1,14 @@ +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.Net; + +namespace SharpChat.Bans { + public interface IBanClient { + void GetBanList(Action> onSuccess, Action onFailure); + void CheckBan(IRemoteUser subject, IPAddress ipAddress, Action onSuccess, Action onFailure); + void CreateBan(IRemoteUser subject, IRemoteUser moderator, bool perma, TimeSpan duration, string reason, Action onSuccess, Action onFailure); + void RemoveBan(IRemoteUser subject, Action onSuccess, Action onFailure); + void RemoveBan(IPAddress ipAddress, Action onSuccess, Action onFailure); + } +} diff --git a/SharpChat.Common/Bans/IBanRecord.cs b/SharpChat.Common/Bans/IBanRecord.cs new file mode 100644 index 0000000..40bf2ef --- /dev/null +++ b/SharpChat.Common/Bans/IBanRecord.cs @@ -0,0 +1,11 @@ +using SharpChat.Users.Remote; +using System; +using System.Net; + +namespace SharpChat.Bans { + public interface IBanRecord : IRemoteUser { + IPAddress UserIP { get; } + DateTimeOffset Expires { get; } + bool IsPermanent { get; } + } +} diff --git a/SharpChat.Common/Channels/Channel.cs b/SharpChat.Common/Channels/Channel.cs new file mode 100644 index 0000000..c32548e --- /dev/null +++ b/SharpChat.Common/Channels/Channel.cs @@ -0,0 +1,159 @@ +using SharpChat.Events; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Channels { + public class Channel : IChannel, IEventHandler { + public const int ID_LENGTH = 8; + + public string ChannelId { get; } + public string Name { get; private set; } + public string Topic { get; private set; } + public bool IsTemporary { get; private set; } + public int MinimumRank { get; private set; } + public bool AutoJoin { get; private set; } + public uint MaxCapacity { get; private set; } + public int Order { get; private set; } + public long OwnerId { get; private set; } + + private readonly object Sync = new(); + private HashSet Users { get; } = new(); + private Dictionary Sessions { get; } = new(); + + public bool HasTopic + => !string.IsNullOrWhiteSpace(Topic); + + public string Password { get; private set; } = string.Empty; + public bool HasPassword + => !string.IsNullOrWhiteSpace(Password); + + public Channel( + string channelId, + string name, + string topic, + bool temp, + int minimumRank, + string password, + bool autoJoin, + uint maxCapacity, + long ownerId, + int order + ) { + ChannelId = channelId ?? throw new ArgumentNullException(nameof(channelId)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + Topic = topic; + IsTemporary = temp; + MinimumRank = minimumRank; + Password = password ?? string.Empty; + AutoJoin = autoJoin; + MaxCapacity = maxCapacity; + OwnerId = ownerId; + Order = order; + } + + public bool VerifyPassword(string password) { + if(password == null) + throw new ArgumentNullException(nameof(password)); + lock(Sync) + return !HasPassword || Password.Equals(password); + } + + public bool HasUser(IUser user) { + if(user == null) + return false; + lock(Sync) + return Users.Contains(user.UserId); + } + + public bool HasSession(ISession session) { + if(session == null) + return false; + lock(Sync) + return Sessions.ContainsKey(session.SessionId); + } + + public void GetUserIds(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Users); + } + + public void GetSessionIds(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Sessions.Keys); + } + + public int CountUsers() { + lock(Sync) + return Users.Count; + } + + public int CountUserSessions(IUser user) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + lock(Sync) + return Sessions.Values.Count(u => u == user.UserId); + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case ChannelUpdateEvent update: // Owner? + lock(Sync) { + if(update.HasName) + Name = update.Name; + if(update.HasTopic) + Topic = update.Topic; + if(update.IsTemporary.HasValue) + IsTemporary = update.IsTemporary.Value; + if(update.MinimumRank.HasValue) + MinimumRank = update.MinimumRank.Value; + if(update.HasPassword) + Password = update.Password; + if(update.AutoJoin.HasValue) + AutoJoin = update.AutoJoin.Value; + if(update.MaxCapacity.HasValue) + MaxCapacity = update.MaxCapacity.Value; + if(update.Order.HasValue) + Order = update.Order.Value; + } + break; + + case ChannelUserJoinEvent cuje: + lock(Sync) { + Sessions.Add(cuje.SessionId, cuje.UserId); + Users.Add(cuje.UserId); + } + break; + case ChannelSessionJoinEvent csje: + lock(Sync) + Sessions.Add(csje.SessionId, csje.UserId); + break; + + case ChannelUserLeaveEvent cule: + lock(Sync) { + Users.Remove(cule.UserId); + Queue delete = new(Sessions.Where(s => s.Value == cule.UserId).Select(s => s.Key)); + while(delete.TryDequeue(out string sessionId)) + Sessions.Remove(sessionId); + } + break; + case ChannelSessionLeaveEvent csle: + lock(Sync) + Sessions.Remove(csle.SessionId); + break; + } + } + + public bool Equals(IChannel other) + => other != null && ChannelId.Equals(other.ChannelId); + + public override string ToString() + => $@""; + } +} diff --git a/SharpChat.Common/Channels/ChannelManager.cs b/SharpChat.Common/Channels/ChannelManager.cs new file mode 100644 index 0000000..dc64877 --- /dev/null +++ b/SharpChat.Common/Channels/ChannelManager.cs @@ -0,0 +1,447 @@ +using SharpChat.Configuration; +using SharpChat.Events; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Channels { + public class ChannelException : Exception { } + public class ChannelExistException : ChannelException { } + public class ChannelInvalidNameException : ChannelException { } + + public class ChannelManager : IEventHandler { + private Dictionary Channels { get; } = new(); + + private IConfig Config { get; } + private CachedValue ChannelIds { get; } + + private IEventDispatcher Dispatcher { get; } + private ChatBot Bot { get; } + private object Sync { get; } = new(); + + public ChannelManager(IEventDispatcher dispatcher, IConfig config, ChatBot bot) { + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + Config = config ?? throw new ArgumentNullException(nameof(config)); + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + ChannelIds = Config.ReadCached(@"channels", new[] { @"lounge" }); + } + + public void UpdateChannels() { + lock(Sync) { + string[] channelIds = ChannelIds.Value.Clone() as string[]; + + foreach(IChannel channel in Channels.Values) { + if(channelIds.Contains(channel.ChannelId)) { + using IConfig config = Config.ScopeTo($@"channels:{channel.ChannelId}"); + string name = config.ReadValue(@"name", channel.ChannelId); + string topic = config.ReadValue(@"topic"); + bool autoJoin = config.ReadValue(@"autoJoin", false); + string password = null; + int? minRank = null; + uint? maxCapacity = null; + + if(!autoJoin) { + password = config.ReadValue(@"password", string.Empty); + if(string.IsNullOrEmpty(password)) + password = null; + + minRank = config.SafeReadValue(@"minRank", 0); + maxCapacity = config.SafeReadValue(@"maxCapacity", 0u); + } + + Update(channel, name, topic, false, minRank, password, autoJoin, maxCapacity); + } else if(!channel.IsTemporary) // Not in config == temporary + Update(channel, temporary: true); + } + + foreach(string channelId in channelIds) { + if(Channels.ContainsKey(channelId)) + continue; + using IConfig config = Config.ScopeTo($@"channels:{channelId}"); + string name = config.ReadValue(@"name", channelId); + string topic = config.ReadValue(@"topic"); + bool autoJoin = config.ReadValue(@"autoJoin", false); + string password = null; + int minRank = 0; + uint maxCapacity = 0; + + if(!autoJoin) { + password = config.ReadValue(@"password", string.Empty); + if(string.IsNullOrEmpty(password)) + password = null; + + minRank = config.SafeReadValue(@"minRank", 0); + maxCapacity = config.SafeReadValue(@"maxCapacity", 0u); + } + + Create(channelId, Bot.UserId, name, topic, false, minRank, password, autoJoin, maxCapacity); + } + } + } + + public void Remove(IChannel channel, IUser user = null) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + + lock(Sync) { + Channel chan = null; + if(channel is Channel c && Channels.ContainsValue(c)) + chan = c; + else if(Channels.TryGetValue(channel.ChannelId, out Channel c2)) + chan = c2; + + if(chan == null) + return; // exception? + + // Remove channel from the listing + Channels.Remove(chan.ChannelId); + + // Broadcast death + Dispatcher.DispatchEvent(this, new ChannelDeleteEvent(user ?? Bot, chan)); + + // 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. + // Could be handled by the user/session itself? + //foreach(ChatUser user in channel.GetUsers()) { + // Context.SwitchChannel(user, DefaultChannel); + //} + + // Broadcast deletion of channel (deprecated) + /*foreach(IUser u in Users.OfRank(chan.MinimumRank)) + u.SendPacket(new ChannelDeletePacket(chan));*/ + } + } + + private bool Exists(string name) { + if(name == null) + throw new ArgumentNullException(nameof(name)); + lock(Sync) + return Channels.Values.Any(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); + } + + private void ValidateName(string name) { + if(!name.All(c => char.IsLetter(c) || char.IsNumber(c) || c == '-')) + throw new ChannelInvalidNameException(); + if(Exists(name)) + throw new ChannelExistException(); + } + + public IChannel Create( + IUser user, + string name, + string topic = null, + bool temp = true, + int minRank = 0, + string password = null, + bool autoJoin = false, + uint maxCapacity = 0 + ) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + return Create(user.UserId, name, topic, temp, minRank, password, autoJoin, maxCapacity); + } + + public IChannel Create( + long ownerId, + string name, + string topic = null, + bool temp = true, + int minRank = 0, + string password = null, + bool autoJoin = false, + uint maxCapacity = 0 + ) => Create(RNG.NextString(Channel.ID_LENGTH), ownerId, name, topic, temp, minRank, password, autoJoin, maxCapacity); + + public IChannel Create( + string channelId, + long ownerId, + string name, + string topic = null, + bool temp = true, + int minRank = 0, + string password = null, + bool autoJoin = false, + uint maxCapacity = 0, + int order = 0 + ) { + if(name == null) + throw new ArgumentNullException(nameof(name)); + ValidateName(name); + + lock(Sync) { + Channel channel = new(channelId, name, topic, temp, minRank, password, autoJoin, maxCapacity, ownerId, order); + Channels.Add(channel.ChannelId, channel); + + Dispatcher.DispatchEvent(this, new ChannelCreateEvent(channel)); + + // Broadcast creation of channel (deprecated) + /*if(Users != null) + foreach(IUser user in Users.OfRank(channel.MinimumRank)) + user.SendPacket(new ChannelCreatePacket(channel));*/ + + return channel; + } + } + + public void Update( + IChannel channel, + string name = null, + string topic = null, + bool? temporary = null, + int? minRank = null, + string password = null, + bool? autoJoin = null, + uint? maxCapacity = null, + int? order = null + ) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + + if(!(channel is Channel c && Channels.ContainsValue(c))) { + if(Channels.TryGetValue(channel.ChannelId, out Channel c2)) + channel = c2; + else + throw new ArgumentException(@"Provided channel is not registered with this manager.", nameof(channel)); + } + + lock(Sync) { + string prevName = channel.Name; + bool nameUpdated = !string.IsNullOrWhiteSpace(name) && name != prevName; + + if(nameUpdated) + ValidateName(name); + + if(topic != null && channel.Topic.Equals(topic)) + topic = null; + + if(temporary.HasValue && channel.IsTemporary == temporary.Value) + temporary = null; + + if(minRank.HasValue && channel.MinimumRank == minRank.Value) + minRank = null; + + if(password != null && channel.Password == password) + password = null; + + if(autoJoin.HasValue && channel.AutoJoin == autoJoin.Value) + autoJoin = null; + + if(maxCapacity.HasValue && channel.MaxCapacity == maxCapacity.Value) + maxCapacity = null; + + if(order.HasValue && channel.Order == order.Value) + order = null; + + Dispatcher.DispatchEvent(this, new ChannelUpdateEvent(channel, Bot, name, topic, temporary, minRank, password, autoJoin, maxCapacity, order)); + + // 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 + // TODO: should be moved to the usermanager probably + /*foreach(IUser user in Users.OfRank(channel.MinimumRank)) { + user.SendPacket(new ChannelUpdatePacket(prevName, channel)); + + if(nameUpdated) + user.ForceChannel(); + }*/ + } + } + + public void GetChannel(Func predicate, Action callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Channels.Values.FirstOrDefault(predicate)); + } + + public void GetChannelById(string channelId, Action callback) { + if(channelId == null) + throw new ArgumentNullException(nameof(channelId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(channelId)) { + callback(null); + return; + } + lock(Sync) + callback(Channels.TryGetValue(channelId, out Channel channel) ? channel : null); + } + + public void GetChannelByName(string name, Action callback) { + if(name == null) + throw new ArgumentNullException(nameof(name)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(name)) { + callback(null); + return; + } + GetChannel(c => name.Equals(c.Name, StringComparison.InvariantCultureIgnoreCase), callback); + } + + public void GetChannel(IChannel channel, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + if(channel is Channel c && Channels.ContainsValue(c)) { + callback(c); + return; + } + + GetChannel(channel.Equals, callback); + } + } + + public void GetChannels(Action> callback, bool ordered = false) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + IEnumerable channels = Channels.Values; + if(ordered) + channels = channels.OrderBy(c => c.Order); + callback(channels); + } + } + + public void GetChannels(Func predicate, Action> callback, bool ordered = false) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + IEnumerable channels = Channels.Values.Where(predicate); + if(ordered) + channels = channels.OrderBy(c => c.Order); + callback(channels); + } + } + + public void GetDefaultChannels(Action> callback, bool ordered = true) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + // it doesn't really make sense for a channel to be temporary and autojoin + // maybe reconsider this in the future if the temp channel nuking strategy has adjusted + GetChannels(c => c.AutoJoin && !c.IsTemporary, callback, ordered); + } + + public void GetChannelsById(IEnumerable channelIds, Action> callback, bool ordered = false) { + if(channelIds == null) + throw new ArgumentNullException(nameof(channelIds)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetChannels(c => channelIds.Contains(c.ChannelId), callback, ordered); + } + + public void GetChannelsByName(IEnumerable names, Action> callback, bool ordered = false) { + if(names == null) + throw new ArgumentNullException(nameof(names)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetChannels(c => names.Contains(c.Name), callback, ordered); + } + + public void GetChannels(IEnumerable channels, Action> callback, bool ordered = false) { + if(channels == null) + throw new ArgumentNullException(nameof(channels)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetChannels(c1 => channels.Any(c2 => c2.Equals(c1)), callback, ordered); + } + + public void GetChannels(int minRank, Action> callback, bool ordered = false) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetChannels(c => c.MinimumRank <= minRank, callback, ordered); + } + + public void GetChannels(IUser user, Action> callback, bool ordered = false) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetChannels(c => c is Channel channel && channel.HasUser(user), callback, ordered); + } + + public void VerifyPassword(IChannel channel, string password, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(password == null) + throw new ArgumentNullException(nameof(password)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + GetChannel(channel, c => { + if(c is not Channel channel) { + callback(false); + return; + } + + if(!channel.HasPassword) { + callback(true); + return; + } + + callback(channel.VerifyPassword(password)); + }); + } + + private void OnCreate(object sender, ChannelCreateEvent cce) { + if(sender == this) + return; + + lock(Sync) { + if(Exists(cce.Name)) + throw new ArgumentException(@"Channel already registered??????", nameof(cce)); + + Channels.Add(cce.ChannelId, new Channel( + cce.ChannelId, + cce.Name, + cce.Topic, + cce.IsTemporary, + cce.MinimumRank, + cce.Password, + cce.AutoJoin, + cce.MaxCapacity, + cce.UserId, + cce.Order + )); + } + } + + private void OnDelete(object sender, ChannelDeleteEvent cde) { + if(sender == this) + return; + + lock(Sync) + Channels.Remove(cde.ChannelId); + } + + private void OnEvent(object sender, IEvent evt) { + Channel channel; + lock(Sync) + if(!Channels.TryGetValue(evt.ChannelId, out channel)) + channel = null; + channel?.HandleEvent(sender, evt); + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case ChannelCreateEvent cce: + OnCreate(sender, cce); + break; + case ChannelDeleteEvent cde: + OnDelete(sender, cde); + break; + + case ChannelUpdateEvent _: + case ChannelUserJoinEvent _: + case ChannelUserLeaveEvent _: + OnEvent(sender, evt); + break; + } + } + } +} diff --git a/SharpChat.Common/Channels/ChannelUserRelations.cs b/SharpChat.Common/Channels/ChannelUserRelations.cs new file mode 100644 index 0000000..3778587 --- /dev/null +++ b/SharpChat.Common/Channels/ChannelUserRelations.cs @@ -0,0 +1,421 @@ +using SharpChat.Events; +using SharpChat.Messages; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Channels { + public class ChannelUserRelations : IEventHandler { + private IEventDispatcher Dispatcher { get; } + private ChannelManager Channels { get; } + private UserManager Users { get; } + private SessionManager Sessions { get; } + private MessageManager Messages { get; } + + public ChannelUserRelations( + IEventDispatcher dispatcher, + ChannelManager channels, + UserManager users, + SessionManager sessions, + MessageManager messages + ) { + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + } + + public void HasUser(IChannel channel, IUser user, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + Channels.GetChannel(channel, c => { + if(c is not Channel channel) { + callback(false); + return; + } + + callback(channel.HasUser(user)); + }); + } + + public void HasSession(IChannel channel, ISession session, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(session == null) + throw new ArgumentNullException(nameof(session)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + Channels.GetChannel(channel, c => { + if(c is not Channel channel) { + callback(false); + return; + } + + callback(channel.HasSession(session)); + }); + } + + public void CountUsers(IChannel channel, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + Channels.GetChannel(channel, c => { + if(c is not Channel channel) { + callback(-1); + return; + } + + callback(channel.CountUsers()); + }); + } + + public void CountUserSessions(IChannel channel, IUser user, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + Channels.GetChannel(channel, c => { + if(c is not Channel channel) { + callback(-1); + return; + } + + callback(channel.CountUserSessions(user)); + }); + } + + public void CheckOverCapacity(IChannel channel, IUser user, Action callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + Channels.GetChannel(channel, channel => { + if(channel == null) { + callback(true); + return; + } + + if(!channel.HasMaxCapacity() || user.UserId == channel.OwnerId) { + callback(false); + return; + } + + CountUsers(channel, userCount => callback(channel == null || userCount >= channel.MaxCapacity)); + }); + } + + public void GetUsersByChannelId(string channelId, Action> callback) { + if(channelId == null) + throw new ArgumentNullException(nameof(channelId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(channelId)) { + callback(Enumerable.Empty()); + return; + } + Channels.GetChannelById(channelId, c => GetUsersWithChannelCallback(c, callback)); + } + + public void GetUsersByChannelName(string channelName, Action> callback) { + if(channelName == null) + throw new ArgumentNullException(nameof(channelName)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(channelName)) { + callback(Enumerable.Empty()); + return; + } + Channels.GetChannelByName(channelName, c => GetUsersWithChannelCallback(c, callback)); + } + + public void GetUsers(IChannel channel, Action> callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + Channels.GetChannel(channel, c => GetUsersWithChannelCallback(c, callback)); + } + + private void GetUsersWithChannelCallback(IChannel c, Action> callback) { + if(c is not Channel channel) { + callback(Enumerable.Empty()); + return; + } + + channel.GetUserIds(ids => Users.GetUsers(ids, callback)); + } + + public void GetUsers(IEnumerable channels, Action> callback) { + if(channels == null) + throw new ArgumentNullException(nameof(channels)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + // this is pretty disgusting + Channels.GetChannels(channels, channels => { + HashSet ids = new(); + + foreach(IChannel c in channels) { + if(c is not Channel channel) + continue; + + channel.GetUserIds(u => { + foreach(long id in u) + ids.Add(id); + }); + } + + Users.GetUsers(ids, callback); + }); + } + + // this makes me cry + public void GetUsers(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + HashSet all = new(); + + Channels.GetChannels(channels => { + foreach(IChannel channel in channels) { + GetUsers(channel, users => { + foreach(ILocalUser user in users) + all.Add(user); + }); + } + }); + + callback(all); + } + + public void GetLocalSessionsByChannelId(string channelId, Action> callback) { + if(channelId == null) + throw new ArgumentNullException(nameof(channelId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(channelId)) { + callback(Enumerable.Empty()); + return; + } + Channels.GetChannelById(channelId, c => GetLocalSessionsChannelCallback(c, callback)); + } + + public void GetLocalSessionsByChannelName(string channelName, Action> callback) { + if(channelName == null) + throw new ArgumentNullException(nameof(channelName)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(channelName)) { + callback(Enumerable.Empty()); + return; + } + Channels.GetChannelByName(channelName, c => GetLocalSessionsChannelCallback(c, callback)); + } + + public void GetLocalSessions(IChannel channel, Action> callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + Channels.GetChannel(channel, c => GetLocalSessionsChannelCallback(c, callback)); + } + + private void GetLocalSessionsChannelCallback(IChannel c, Action> callback) { + if(c is not Channel channel) { + callback(Enumerable.Empty()); + return; + } + + channel.GetSessionIds(ids => Sessions.GetLocalSessions(ids, callback)); + } + + public void GetLocalSessions(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetChannels(user, channels => GetLocalSessionsUserCallback(channels, callback)); + } + + public void GetLocalSessionsByUserId(long userId, Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(userId < 1) { + callback(null, Enumerable.Empty()); + return; + } + GetChannelsByUserId(userId, (user, channels) => GetLocalSessionsUserCallback(channels, sessions => callback(user, sessions))); + } + + private void GetLocalSessionsUserCallback(IEnumerable channels, Action> callback) { + if(!channels.Any()) { + callback(Enumerable.Empty()); + return; + } + + Channels.GetChannels(channels, channels => { + HashSet sessionIds = new(); + + foreach(IChannel c in channels) { + if(c is not Channel channel) + continue; + channel.GetSessionIds(ids => { + foreach(string id in ids) + sessionIds.Add(id); + }); + } + + Sessions.GetLocalSessions(sessionIds, callback); + }); + } + + public void GetChannelsByUserId(long userId, Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(userId < 1) { + callback(null, Enumerable.Empty()); + return; + } + Users.GetUser(userId, u => GetChannelsUserCallback(u, channels => callback(u, channels))); + } + + public void GetChannels(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + Users.GetUser(user, u => GetChannelsUserCallback(u, callback)); + } + + private void GetChannelsUserCallback(IUser u, Action> callback) { + if(u is not User user) { + callback(Enumerable.Empty()); + return; + } + + user.GetChannels(c => Channels.GetChannelsByName(c, callback)); + } + + public void JoinChannel(IChannel channel, ISession session) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(session == null) + throw new ArgumentNullException(nameof(session)); + + HasSession(channel, session, hasSession => { + if(hasSession) + return; + + // SessionJoin and UserJoin should be combined + HasUser(channel, session.User, HasUser => { + Dispatcher.DispatchEvent( + this, + HasUser + ? new ChannelSessionJoinEvent(channel, session) + : new ChannelUserJoinEvent(channel, session) + ); + }); + }); + } + + public void LeaveChannel(IChannel channel, IUser user, UserDisconnectReason reason = UserDisconnectReason.Unknown) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(user == null) + throw new ArgumentNullException(nameof(user)); + + HasUser(channel, user, hasUser => { + if(hasUser) + Dispatcher.DispatchEvent(this, new ChannelUserLeaveEvent(user, channel, reason)); + }); + } + + public void LeaveChannel(IChannel channel, ISession session) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(session == null) + throw new ArgumentNullException(nameof(session)); + + HasSession(channel, session, hasSession => { + // UserLeave and SessionLeave should be combined + CountUserSessions(channel, session.User, sessionCount => { + Dispatcher.DispatchEvent( + this, + sessionCount <= 1 + ? new ChannelUserLeaveEvent(session.User, channel, UserDisconnectReason.Leave) + : new ChannelSessionLeaveEvent(channel, session) + ); + }); + }); + } + + public void LeaveChannels(ISession session) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + + Channels.GetChannels(channels => { + foreach(IChannel channel in channels) + LeaveChannel(channel, session); + }); + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case UserUpdateEvent uue: // fetch up to date user info + GetChannelsByUserId(evt.UserId, (user, channels) => GetUsers(channels, users => { + foreach(ILocalUser user in users) + GetLocalSessions(user, sessions => { + foreach(ISession session in sessions) + session.HandleEvent(sender, new UserUpdateEvent(user, uue)); + }); + })); + break; + + case ChannelUserJoinEvent cje: + // THIS DOES NOT DO WHAT YOU WANT IT TO DO + // I THINK + // it really doesn't, figure out how to leave channels when MCHAN isn't active for the session + //if((Sessions.GetCapabilities(cje.User) & ClientCapability.MCHAN) == 0) + // LeaveChannel(cje.Channel, cje.User, UserDisconnectReason.Leave); + break; + + case ChannelUserLeaveEvent cle: // Should ownership just be passed on to another user instead of Destruction? + Channels.GetChannelById(evt.ChannelId, channel => { + if(channel.IsTemporary && evt.UserId == channel.OwnerId) + Channels.Remove(channel); + }); + break; + + case SessionDestroyEvent sde: + Users.GetUser(sde.UserId, user => { + if(user == null) + return; + Sessions.GetSessionCount(user, sessionCount => { + if(sessionCount < 1) + Users.Disconnect(user, UserDisconnectReason.TimeOut); + }); + }); + break; + } + } + } +} diff --git a/SharpChat.Common/Channels/IChannel.cs b/SharpChat.Common/Channels/IChannel.cs new file mode 100644 index 0000000..5f26991 --- /dev/null +++ b/SharpChat.Common/Channels/IChannel.cs @@ -0,0 +1,18 @@ +using System; + +namespace SharpChat.Channels { + public interface IChannel : IEquatable { + string ChannelId { get; } + string Name { get; } + string Topic { get; } + bool IsTemporary { get; } + int MinimumRank { get; } + bool AutoJoin { get; } + uint MaxCapacity { get; } + int Order { get; } + long OwnerId { get; } + + string Password { get; } + bool HasPassword { get; } + } +} diff --git a/SharpChat.Common/Channels/IChannelExtensions.cs b/SharpChat.Common/Channels/IChannelExtensions.cs new file mode 100644 index 0000000..f4d2e92 --- /dev/null +++ b/SharpChat.Common/Channels/IChannelExtensions.cs @@ -0,0 +1,11 @@ +using SharpChat.Users; + +namespace SharpChat.Channels { + public static class IChannelExtensions { + public static bool HasMaxCapacity(this IChannel channel) + => channel.MaxCapacity != 0; + + public static bool IsOwner(this IChannel channel, IUser user) + => channel != null && user != null && channel.OwnerId == user.UserId; + } +} diff --git a/SharpChat.Common/Colour.cs b/SharpChat.Common/Colour.cs new file mode 100644 index 0000000..c4d8823 --- /dev/null +++ b/SharpChat.Common/Colour.cs @@ -0,0 +1,29 @@ +using System; + +namespace SharpChat { + public readonly struct Colour : IEquatable { + public const int INHERIT = 0x40000000; + + public int Raw { get; } + + public Colour(int argb) { + Raw = argb; + } + + public static implicit operator Colour(int argb) => new(argb); + + public bool Equals(Colour? other) + => other.HasValue && other.Value.Raw == Raw; + + public bool Inherit => (Raw & INHERIT) > 0; + public int Red => (Raw >> 16) & 0xFF; + public int Green => (Raw >> 8) & 0xFF; + public int Blue => Raw & 0xFF; + + public override string ToString() { + if (Inherit) + return @"inherit"; + return string.Format(@"#{0:X6}", Raw); + } + } +} diff --git a/SharpChat.Common/Configuration/CachedValue.cs b/SharpChat.Common/Configuration/CachedValue.cs new file mode 100644 index 0000000..c5be6c4 --- /dev/null +++ b/SharpChat.Common/Configuration/CachedValue.cs @@ -0,0 +1,45 @@ +using System; + +namespace SharpChat.Configuration { + public class CachedValue { + private IConfig Config { get; } + private string Name { get; } + private TimeSpan Lifetime { get; } + private T Fallback { get; } + private object Sync { get; } = new(); + + private object CurrentValue { get; set; } + private DateTimeOffset LastRead { get; set; } + + public T Value { + get { + lock(Sync) { + DateTimeOffset now = DateTimeOffset.Now; + if((now - LastRead) >= Lifetime) { + LastRead = now; + CurrentValue = Config.ReadValue(Name, Fallback); + Logger.Debug($@"Read {Name} ({CurrentValue})"); + } + } + return (T)CurrentValue; + } + } + + public static implicit operator T(CachedValue val) => val.Value; + + public CachedValue(IConfig config, string name, TimeSpan lifetime, T fallback) { + Config = config ?? throw new ArgumentNullException(nameof(config)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + Lifetime = lifetime; + Fallback = fallback; + if(string.IsNullOrWhiteSpace(name)) + throw new ArgumentException(@"Name cannot be empty.", nameof(name)); + } + + public void Refresh() { + lock(Sync) { + LastRead = DateTimeOffset.MinValue; + } + } + } +} diff --git a/SharpChat.Common/Configuration/ConfigException.cs b/SharpChat.Common/Configuration/ConfigException.cs new file mode 100644 index 0000000..0cbf263 --- /dev/null +++ b/SharpChat.Common/Configuration/ConfigException.cs @@ -0,0 +1,16 @@ +using System; + +namespace SharpChat.Configuration { + public abstract class ConfigException : Exception { + public ConfigException(string message) : base(message) { } + public ConfigException(string message, Exception ex) : base(message, ex) { } + } + + public class ConfigLockException : ConfigException { + public ConfigLockException() : base(@"Unable to acquire lock for reading configuration.") { } + } + + public class ConfigTypeException : ConfigException { + public ConfigTypeException(Exception ex) : base(@"Given type does not match the value in the configuration.", ex) { } + } +} diff --git a/SharpChat.Common/Configuration/IConfig.cs b/SharpChat.Common/Configuration/IConfig.cs new file mode 100644 index 0000000..b012479 --- /dev/null +++ b/SharpChat.Common/Configuration/IConfig.cs @@ -0,0 +1,31 @@ +using System; + +namespace SharpChat.Configuration { + public interface IConfig : IDisposable { + /// + /// Creates a proxy object that forces all names to start with the given prefix. + /// + IConfig ScopeTo(string prefix); + + /// + /// Reads a raw (string) value from the config. + /// + string ReadValue(string name, string fallback = null); + + /// + /// Reads and casts value from the config. + /// + /// Type conversion failed. + T ReadValue(string name, T fallback = default); + + /// + /// Reads and casts a value from the config. Returns fallback when type conversion fails. + /// + T SafeReadValue(string name, T fallback); + + /// + /// Creates an object that caches the read value for a certain amount of time, avoiding disk reads for frequently used non-static values. + /// + CachedValue ReadCached(string name, T fallback = default, TimeSpan? lifetime = null); + } +} diff --git a/SharpChat.Common/Configuration/ScopedConfig.cs b/SharpChat.Common/Configuration/ScopedConfig.cs new file mode 100644 index 0000000..bc2a937 --- /dev/null +++ b/SharpChat.Common/Configuration/ScopedConfig.cs @@ -0,0 +1,45 @@ +using System; + +namespace SharpChat.Configuration { + public class ScopedConfig : IConfig { + private IConfig Config { get; } + private string Prefix { get; } + + public ScopedConfig(IConfig config, string prefix) { + Config = config ?? throw new ArgumentNullException(nameof(config)); + Prefix = prefix ?? throw new ArgumentNullException(nameof(prefix)); + if(string.IsNullOrWhiteSpace(prefix)) + throw new ArgumentException(@"Prefix must exist.", nameof(prefix)); + if(Prefix[^1] != ':') + Prefix += ':'; + } + + private string GetName(string name) { + return Prefix + name; + } + + public string ReadValue(string name, string fallback = null) { + return Config.ReadValue(GetName(name), fallback); + } + + public T ReadValue(string name, T fallback = default) { + return Config.ReadValue(GetName(name), fallback); + } + + public T SafeReadValue(string name, T fallback) { + return Config.SafeReadValue(GetName(name), fallback); + } + + public IConfig ScopeTo(string prefix) { + return Config.ScopeTo(GetName(prefix)); + } + + public CachedValue ReadCached(string name, T fallback = default, TimeSpan? lifetime = null) { + return Config.ReadCached(GetName(name), fallback, lifetime); + } + + public void Dispose() { + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat.Common/Configuration/StreamConfig.cs b/SharpChat.Common/Configuration/StreamConfig.cs new file mode 100644 index 0000000..72998f5 --- /dev/null +++ b/SharpChat.Common/Configuration/StreamConfig.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; + +namespace SharpChat.Configuration { + public class StreamConfig : IConfig { + private Stream Stream { get; } + private StreamReader StreamReader { get; } + private Mutex Lock { get; } + + private const int LOCK_TIMEOUT = 10000; + + private static readonly TimeSpan CACHE_LIFETIME = TimeSpan.FromMinutes(15); + + public StreamConfig(string fileName) + : this(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite)) {} + + public StreamConfig(Stream stream) { + Stream = stream ?? throw new ArgumentNullException(nameof(stream)); + if(!Stream.CanRead) + throw new ArgumentException(@"Provided stream must be readable.", nameof(stream)); + if(!Stream.CanSeek) + throw new ArgumentException(@"Provided stream must be seekable.", nameof(stream)); + StreamReader = new StreamReader(stream, new UTF8Encoding(false), false); + Lock = new Mutex(); + } + + public string ReadValue(string name, string fallback = null) { + if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong + throw new ConfigLockException(); + + try { + Stream.Seek(0, SeekOrigin.Begin); + + string line; + while((line = StreamReader.ReadLine()) != null) { + if(string.IsNullOrWhiteSpace(line)) + continue; + + line = line.TrimStart(); + if(line.StartsWith(@";") || line.StartsWith(@"#")) + continue; + + string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if(parts.Length < 2 || !string.Equals(parts[0], name)) + continue; + + return parts[1]; + } + } finally { + Lock.ReleaseMutex(); + } + + return fallback; + } + + public T ReadValue(string name, T fallback = default) { + object value = ReadValue(name); + if(value == null) + return fallback; + + Type type = typeof(T); + if(value is string strVal) { + if(type == typeof(bool)) + value = !string.Equals(strVal, @"0", StringComparison.InvariantCultureIgnoreCase) + && !string.Equals(strVal, @"false", StringComparison.InvariantCultureIgnoreCase); + else if(type == typeof(string[])) + value = strVal.Split(' '); + } + + try { + return (T)Convert.ChangeType(value, type); + } catch(InvalidCastException ex) { + throw new ConfigTypeException(ex); + } + } + + public T SafeReadValue(string name, T fallback) { + try { + return ReadValue(name, fallback); + } catch(ConfigTypeException) { + return fallback; + } + } + + public IConfig ScopeTo(string prefix) { + return new ScopedConfig(this, prefix); + } + + public CachedValue ReadCached(string name, T fallback = default, TimeSpan? lifetime = null) { + return new CachedValue(this, name, lifetime ?? CACHE_LIFETIME, fallback); + } + + private bool IsDisposed; + ~StreamConfig() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + StreamReader.Dispose(); + Stream.Dispose(); + Lock.Dispose(); + } + } +} diff --git a/SharpChat.Common/Context.cs b/SharpChat.Common/Context.cs new file mode 100644 index 0000000..5879add --- /dev/null +++ b/SharpChat.Common/Context.cs @@ -0,0 +1,145 @@ +using SharpChat.Bans; +using SharpChat.Channels; +using SharpChat.Configuration; +using SharpChat.Database; +using SharpChat.DataProvider; +using SharpChat.Events; +using SharpChat.Messages; +using SharpChat.Messages.Storage; +using SharpChat.RateLimiting; +using SharpChat.Sessions; +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace SharpChat { + public class Context : IDisposable { + public const int ID_LENGTH = 8; + public string ServerId { get; } + + public EventDispatcher Events { get; } + public SessionManager Sessions { get; } + public UserManager Users { get; } + public ChannelManager Channels { get; } + public ChannelUserRelations ChannelUsers { get; } + public MessageManager Messages { get; } + public BanManager Bans { get; } + + public IDataProvider DataProvider { get; } + public RateLimitManager RateLimiting { get; } + + public WelcomeMessage WelcomeMessage { get; } + + public ChatBot Bot { get; } = new(); + + private Timer BumpTimer { get; } + + public DateTimeOffset Created { get; } + + public Context(IConfig config, IDatabaseBackend databaseBackend, IDataProvider dataProvider) { + if(config == null) + throw new ArgumentNullException(nameof(config)); + + ServerId = RNG.NextString(ID_LENGTH); // maybe read this from the cfg instead + Created = DateTimeOffset.Now; // read this from config definitely + + DatabaseWrapper db = new(databaseBackend ?? throw new ArgumentNullException(nameof(databaseBackend))); + IMessageStorage msgStore = db.IsNullBackend + ? new MemoryMessageStorage() + : new ADOMessageStorage(db); + + Events = new EventDispatcher(); + DataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider)); + Users = new UserManager(Events); + Sessions = new SessionManager(Events, Users, config.ScopeTo(@"sessions"), ServerId); + Messages = new MessageManager(Events, msgStore, config.ScopeTo(@"messages")); + Channels = new ChannelManager(Events, config, Bot); + ChannelUsers = new ChannelUserRelations(Events, Channels, Users, Sessions, Messages); + Bans = new BanManager(Users, DataProvider.BanClient, DataProvider.UserClient, Events); + RateLimiting = new RateLimitManager(config.ScopeTo(@"rateLimit")); + + WelcomeMessage = new WelcomeMessage(config.ScopeTo(@"welcome")); + + Events.AddEventHandler(Sessions); + Events.ProtectEventHandler(Sessions); + Events.AddEventHandler(Users); + Events.ProtectEventHandler(Users); + Events.AddEventHandler(Channels); + Events.ProtectEventHandler(Channels); + Events.AddEventHandler(ChannelUsers); + Events.ProtectEventHandler(ChannelUsers); + Events.AddEventHandler(Messages); + Events.ProtectEventHandler(Messages); + + Events.StartProcessing(); + + Channels.UpdateChannels(); + + // Should probably not rely on Timers in the future + BumpTimer = new Timer(e => { + Logger.Write(@"Nuking dead sessions and bumping remote online status..."); + Sessions.CheckTimeOut(); + + Sessions.GetActiveLocalSessions(sessions => { + Dictionary> data = new(); + + foreach(ISession session in sessions) { + if(!data.ContainsKey(session.User)) + data.Add(session.User, new()); + data[session.User].Add(session); + } + + DataProvider.UserClient.BumpUsers( + data.Select(kvp => new UserBumpInfo(kvp.Key, kvp.Value)), + () => Logger.Debug(@"Successfully bumped remote online status!"), + ex => { Logger.Write(@"Failed to bump remote online status."); Logger.Debug(ex); } + ); + }); + }, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); + } + + public void BroadcastMessage(string text) { + Events.DispatchEvent(this, new BroadcastMessageEvent(Bot, text)); + } + + [Obsolete(@"Use ChannelUsers.JoinChannel")] + public void JoinChannel(IUser user, IChannel channel) { + // handle in channelusers + //channel.SendPacket(new UserChannelJoinPacket(user)); + + // send after join packet for v1 + //user.SendPacket(new ContextClearPacket(channel, ContextClearMode.MessagesUsers)); + + // send after join + //ChannelUsers.GetUsers(channel, u => user.SendPacket(new ContextUsersPacket(u.Except(new[] { user }).OrderByDescending(u => u.Rank)))); + + // send after join, maybe add a capability that makes this implicit? + /*Messages.GetMessages(channel, m => { + foreach(IMessage msg in m) + user.SendPacket(new ContextMessagePacket(msg)); + });*/ + + // should happen implicitly for v1 clients + //user.ForceChannel(channel); + } + + private bool IsDisposed; + ~Context() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if (IsDisposed) + return; + IsDisposed = true; + + BumpTimer.Dispose(); + Events.FinishProcessing(); + } + } +} diff --git a/SharpChat.Common/DataProvider/DataProviderAttribute.cs b/SharpChat.Common/DataProvider/DataProviderAttribute.cs new file mode 100644 index 0000000..cec93de --- /dev/null +++ b/SharpChat.Common/DataProvider/DataProviderAttribute.cs @@ -0,0 +1,8 @@ +using SharpChat.Reflection; + +namespace SharpChat.DataProvider { + public class DataProviderAttribute : ObjectConstructorAttribute { + public DataProviderAttribute(string name) : base(name) { + } + } +} diff --git a/SharpChat.Common/DataProvider/IDataProvider.cs b/SharpChat.Common/DataProvider/IDataProvider.cs new file mode 100644 index 0000000..1133f6f --- /dev/null +++ b/SharpChat.Common/DataProvider/IDataProvider.cs @@ -0,0 +1,9 @@ +using SharpChat.Bans; +using SharpChat.Users.Remote; + +namespace SharpChat.DataProvider { + public interface IDataProvider { + IBanClient BanClient { get; } + IRemoteUserClient UserClient { get; } + } +} diff --git a/SharpChat.Common/DataProvider/Null/NullBanClient.cs b/SharpChat.Common/DataProvider/Null/NullBanClient.cs new file mode 100644 index 0000000..d3f0279 --- /dev/null +++ b/SharpChat.Common/DataProvider/Null/NullBanClient.cs @@ -0,0 +1,30 @@ +using SharpChat.Bans; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace SharpChat.DataProvider.Null { + public class NullBanClient : IBanClient { + public void CheckBan(IRemoteUser subject, IPAddress ipAddress, Action onSuccess, Action onFailure) { + onSuccess(null); + } + + public void CreateBan(IRemoteUser subject, IRemoteUser moderator, bool perma, TimeSpan duration, string reason, Action onSuccess, Action onFailure) { + onSuccess(true); + } + + public void GetBanList(Action> onSuccess, Action onFailure) { + onSuccess(Enumerable.Empty()); + } + + public void RemoveBan(IRemoteUser subject, Action onSuccess, Action onFailure) { + onSuccess(false); + } + + public void RemoveBan(IPAddress ipAddress, Action onSuccess, Action onFailure) { + onSuccess(false); + } + } +} diff --git a/SharpChat.Common/DataProvider/Null/NullDataProvider.cs b/SharpChat.Common/DataProvider/Null/NullDataProvider.cs new file mode 100644 index 0000000..3fcbdee --- /dev/null +++ b/SharpChat.Common/DataProvider/Null/NullDataProvider.cs @@ -0,0 +1,15 @@ +using SharpChat.Bans; +using SharpChat.Users.Remote; + +namespace SharpChat.DataProvider.Null { + [DataProvider(@"null")] + public class NullDataProvider : IDataProvider { + public IBanClient BanClient { get; } + public IRemoteUserClient UserClient { get; } + + public NullDataProvider() { + BanClient = new NullBanClient(); + UserClient = new NullUserClient(); + } + } +} diff --git a/SharpChat.Common/DataProvider/Null/NullUserAuthResponse.cs b/SharpChat.Common/DataProvider/Null/NullUserAuthResponse.cs new file mode 100644 index 0000000..625f8ca --- /dev/null +++ b/SharpChat.Common/DataProvider/Null/NullUserAuthResponse.cs @@ -0,0 +1,28 @@ +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; + +namespace SharpChat.DataProvider.Null { + public class NullUserAuthResponse : IUserAuthResponse { + public long UserId { get; } + public string UserName { get; } + public int Rank { get; } + public Colour Colour { get; } + public UserPermissions Permissions { get; } + public DateTimeOffset SilencedUntil => DateTimeOffset.MinValue; + + public NullUserAuthResponse(UserAuthRequest uar) { + UserId = uar.UserId; + UserName = $@"Misaka-{uar.UserId}"; + Rank = (int)(uar.UserId % 10); + Random rng = new((int)uar.UserId); + Colour = new(rng.Next()); + Permissions = (UserPermissions)rng.Next(); + } + + public bool Equals(IUser other) + => other is NullUserAuthResponse && other.UserId == UserId; + public bool Equals(IRemoteUser other) + => other is NullUserAuthResponse && other.UserId == UserId; + } +} diff --git a/SharpChat.Common/DataProvider/Null/NullUserClient.cs b/SharpChat.Common/DataProvider/Null/NullUserClient.cs new file mode 100644 index 0000000..bcbf2ab --- /dev/null +++ b/SharpChat.Common/DataProvider/Null/NullUserClient.cs @@ -0,0 +1,33 @@ +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; + +namespace SharpChat.DataProvider.Null { + public class NullUserClient : IRemoteUserClient { + public void AuthenticateUser(UserAuthRequest request, Action onSuccess, Action onFailure) { + if(request.Token.StartsWith(@"FAIL:")) { + onFailure(new UserAuthFailedException(request.Token[5..])); + return; + } + + onSuccess(new NullUserAuthResponse(request)); + } + + public void BumpUsers(IEnumerable users, Action onSuccess, Action onFailure) { + onSuccess(); + } + + public void ResolveUser(long userId, Action onSuccess, Action onFailure) { + onSuccess(null); + } + + public void ResolveUser(string userName, Action onSuccess, Action onFailure) { + onSuccess(null); + } + + public void ResolveUser(IUser localUser, Action onSuccess, Action onFailure) { + onSuccess(null); + } + } +} diff --git a/SharpChat.Common/Database/ADODatabaseReader.cs b/SharpChat.Common/Database/ADODatabaseReader.cs new file mode 100644 index 0000000..8f6b696 --- /dev/null +++ b/SharpChat.Common/Database/ADODatabaseReader.cs @@ -0,0 +1,81 @@ +using System; +using System.Data.Common; + +namespace SharpChat.Database { + public class ADODatabaseReader : IDatabaseReader { + private DbDataReader Reader { get; } + + public ADODatabaseReader(DbDataReader reader) { + Reader = reader; + } + + public bool Next() + => Reader.Read(); + + public string GetName(int ordinal) + => Reader.GetName(ordinal); + public int GetOrdinal(string name) + => Reader.GetOrdinal(name); + + public bool IsNull(int ordinal) + => Reader.IsDBNull(ordinal); + public bool IsNull(string name) + => Reader.IsDBNull(GetOrdinal(name)); + + public object GetValue(int ordinal) + => Reader.GetValue(ordinal); + public object GetValue(string name) + => Reader.GetValue(GetOrdinal(name)); + + public string ReadString(int ordinal) + => Reader.GetString(ordinal); + public string ReadString(string name) + => Reader.GetString(GetOrdinal(name)); + + public byte ReadU8(int ordinal) + => Reader.GetByte(ordinal); + public byte ReadU8(string name) + => Reader.GetByte(GetOrdinal(name)); + + public short ReadI16(int ordinal) + => Reader.GetInt16(ordinal); + public short ReadI16(string name) + => Reader.GetInt16(GetOrdinal(name)); + + public int ReadI32(int ordinal) + => Reader.GetInt32(ordinal); + public int ReadI32(string name) + => Reader.GetInt32(GetOrdinal(name)); + + public long ReadI64(int ordinal) + => Reader.GetInt64(ordinal); + public long ReadI64(string name) + => Reader.GetInt64(GetOrdinal(name)); + + public float ReadF32(int ordinal) + => Reader.GetFloat(ordinal); + public float ReadF32(string name) + => Reader.GetFloat(GetOrdinal(name)); + + public double ReadF64(int ordinal) + => Reader.GetDouble(ordinal); + public double ReadF64(string name) + => Reader.GetDouble(GetOrdinal(name)); + + private bool IsDisposed; + ~ADODatabaseReader() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + if(Reader is IDisposable disposable) + disposable.Dispose(); + } + } +} diff --git a/SharpChat.Common/Database/DatabaseBackendAttribute.cs b/SharpChat.Common/Database/DatabaseBackendAttribute.cs new file mode 100644 index 0000000..48ae637 --- /dev/null +++ b/SharpChat.Common/Database/DatabaseBackendAttribute.cs @@ -0,0 +1,8 @@ +using SharpChat.Reflection; + +namespace SharpChat.Database { + public class DatabaseBackendAttribute : ObjectConstructorAttribute { + public DatabaseBackendAttribute(string name) : base(name) { + } + } +} diff --git a/SharpChat.Common/Database/DatabaseException.cs b/SharpChat.Common/Database/DatabaseException.cs new file mode 100644 index 0000000..d65698c --- /dev/null +++ b/SharpChat.Common/Database/DatabaseException.cs @@ -0,0 +1,7 @@ +using System; + +namespace SharpChat.Database { + public class DatabaseException : Exception {} + + public class InvalidParameterClassTypeException : DatabaseException { } +} diff --git a/SharpChat.Common/Database/DatabaseType.cs b/SharpChat.Common/Database/DatabaseType.cs new file mode 100644 index 0000000..8cb957a --- /dev/null +++ b/SharpChat.Common/Database/DatabaseType.cs @@ -0,0 +1,14 @@ +namespace SharpChat.Database { + public enum DatabaseType { + AsciiString, + UnicodeString, + Int8, + Int16, + Int32, + Int64, + UInt8, + UInt16, + UInt32, + UInt64, + } +} diff --git a/SharpChat.Common/Database/DatabaseWrapper.cs b/SharpChat.Common/Database/DatabaseWrapper.cs new file mode 100644 index 0000000..ba96f42 --- /dev/null +++ b/SharpChat.Common/Database/DatabaseWrapper.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Database { + public class DatabaseWrapper { + private IDatabaseBackend Backend { get; } + + public bool IsNullBackend + => Backend is Null.NullDatabaseBackend; + + public DatabaseWrapper(IDatabaseBackend backend) { + Backend = backend ?? throw new ArgumentNullException(nameof(backend)); + } + + public IDatabaseParameter CreateParam(string name, object value) + => Backend.CreateParameter(name, value); + + public string TimestampType + => Backend.TimestampType; + public string TextType + => Backend.TextType; + public string BlobType + => Backend.BlobType; + public string VarCharType(int size) + => Backend.VarCharType(size); + public string VarBinaryType(int size) + => Backend.VarBinaryType(size); + public string BigIntType(int length) + => Backend.BigIntType(length); + public string BigUIntType(int length) + => Backend.BigUIntType(length); + public string IntType(int length) + => Backend.IntType(length); + public string UIntType(int length) + => Backend.UIntType(length); + public string TinyIntType(int length) + => Backend.TinyIntType(length); + public string TinyUIntType(int length) + => Backend.TinyUIntType(length); + + public string ToUnixTime(string param) + => Backend.ToUnixTime(param); + public string FromUnixTime(string param) + => Backend.FromUnixTime(param); + public string DateTimeNow() + => Backend.DateTimeNow(); + + public string Concat(params string[] args) + => Backend.Concat(args); + public string ToLower(string param) + => Backend.ToLower(param); + + public bool SupportsJson + => Backend.SupportsJson; + public string JsonValue(string field, string path) + => Backend.JsonValue(field, path); + + public bool SupportsAlterTableCollate + => Backend.SupportsAlterTableCollate; + + public string AsciiCollation + => Backend.AsciiCollation; + public string UnicodeCollation + => Backend.UnicodeCollation; + + public void RunCommand(object query, int timeout, Action action, params IDatabaseParameter[] @params) { +#if LOG_SQL + Logger.Debug(query); +#endif + using IDatabaseConnection conn = Backend.CreateConnection(); + using IDatabaseCommand comm = conn.CreateCommand(query); + comm.CommandTimeout = timeout; + if(@params.Any()) { + comm.AddParameters(@params); + comm.Prepare(); + } + action.Invoke(comm); + } + + public void RunCommand(object query, Action action, params IDatabaseParameter[] @params) + => RunCommand(query, 30, action, @params); + + public int RunCommand(object query, params IDatabaseParameter[] @params) { + int affected = 0; + RunCommand(query, comm => affected = comm.Execute(), @params); + return affected; + } + + public int RunCommand(object query, int timeout, params IDatabaseParameter[] @params) { + int affected = 0; + RunCommand(query, timeout, comm => affected = comm.Execute(), @params); + return affected; + } + + public object RunQueryValue(object query, params IDatabaseParameter[] @params) { + object value = null; + RunCommand(query, comm => value = comm.ExecuteScalar(), @params); + return value; + } + + public void RunQuery(object query, Action action, params IDatabaseParameter[] @params) { + RunCommand(query, comm => { + using IDatabaseReader reader = comm.ExecuteReader(); + action.Invoke(reader); + }, @params); + } + } +} diff --git a/SharpChat.Common/Database/IDatabaseBackend.cs b/SharpChat.Common/Database/IDatabaseBackend.cs new file mode 100644 index 0000000..9706c41 --- /dev/null +++ b/SharpChat.Common/Database/IDatabaseBackend.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace SharpChat.Database { + public interface IDatabaseBackend { + IDatabaseConnection CreateConnection(); + + IDatabaseParameter CreateParameter(string name, object value); + IDatabaseParameter CreateParameter(string name, DatabaseType type); + + string TimestampType { get; } + string TextType { get; } + string BlobType { get; } + string VarCharType(int length); + string VarBinaryType(int length); + string BigIntType(int length); + string BigUIntType(int length); + string IntType(int length); + string UIntType(int length); + string TinyIntType(int length); + string TinyUIntType(int length); + + string FromUnixTime(string param); + string ToUnixTime(string param); + string DateTimeNow(); + + string Concat(params string[] args); + string ToLower(string param); + + bool SupportsJson { get; } + string JsonValue(string field, string path); + + bool SupportsAlterTableCollate { get; } + + string AsciiCollation { get; } + string UnicodeCollation { get; } + } +} diff --git a/SharpChat.Common/Database/IDatabaseCommand.cs b/SharpChat.Common/Database/IDatabaseCommand.cs new file mode 100644 index 0000000..d52e16c --- /dev/null +++ b/SharpChat.Common/Database/IDatabaseCommand.cs @@ -0,0 +1,21 @@ +using System; + +namespace SharpChat.Database { + public interface IDatabaseCommand : IDisposable { + IDatabaseConnection Connection { get; } + + string CommandString { get; } + int CommandTimeout { get; set; } + + IDatabaseParameter AddParameter(string name, object value); + IDatabaseParameter AddParameter(string name, DatabaseType type); + IDatabaseParameter AddParameter(IDatabaseParameter param); + void AddParameters(IDatabaseParameter[] @params); + void ClearParameters(); + void Prepare(); + + int Execute(); + IDatabaseReader ExecuteReader(); + object ExecuteScalar(); + } +} diff --git a/SharpChat.Common/Database/IDatabaseConnection.cs b/SharpChat.Common/Database/IDatabaseConnection.cs new file mode 100644 index 0000000..c5a767d --- /dev/null +++ b/SharpChat.Common/Database/IDatabaseConnection.cs @@ -0,0 +1,7 @@ +using System; + +namespace SharpChat.Database { + public interface IDatabaseConnection : IDisposable { + IDatabaseCommand CreateCommand(object query); + } +} diff --git a/SharpChat.Common/Database/IDatabaseParameter.cs b/SharpChat.Common/Database/IDatabaseParameter.cs new file mode 100644 index 0000000..9e0b363 --- /dev/null +++ b/SharpChat.Common/Database/IDatabaseParameter.cs @@ -0,0 +1,6 @@ +namespace SharpChat.Database { + public interface IDatabaseParameter { + string Name { get; } + object Value { get; set; } + } +} diff --git a/SharpChat.Common/Database/IDatabaseReader.cs b/SharpChat.Common/Database/IDatabaseReader.cs new file mode 100644 index 0000000..dd844bd --- /dev/null +++ b/SharpChat.Common/Database/IDatabaseReader.cs @@ -0,0 +1,37 @@ +using System; + +namespace SharpChat.Database { + public interface IDatabaseReader : IDisposable { + bool Next(); + + object GetValue(int ordinal); + object GetValue(string name); + + bool IsNull(int ordinal); + bool IsNull(string name); + + string GetName(int ordinal); + int GetOrdinal(string name); + + string ReadString(int ordinal); + string ReadString(string name); + + byte ReadU8(int ordinal); + byte ReadU8(string name); + + short ReadI16(int ordinal); + short ReadI16(string name); + + int ReadI32(int ordinal); + int ReadI32(string name); + + long ReadI64(int ordinal); + long ReadI64(string name); + + float ReadF32(int ordinal); + float ReadF32(string name); + + double ReadF64(int ordinal); + double ReadF64(string name); + } +} diff --git a/SharpChat.Common/Database/Null/NullDatabaseBackend.cs b/SharpChat.Common/Database/Null/NullDatabaseBackend.cs new file mode 100644 index 0000000..c5fcdde --- /dev/null +++ b/SharpChat.Common/Database/Null/NullDatabaseBackend.cs @@ -0,0 +1,63 @@ +using SharpChat.Configuration; +using System.Collections.Generic; + +namespace SharpChat.Database.Null { + [DatabaseBackend(@"null")] + public class NullDatabaseBackend : IDatabaseBackend { + public NullDatabaseBackend(IConfig _ = null) { } + + public IDatabaseConnection CreateConnection() + => new NullDatabaseConnection(); + + public IDatabaseParameter CreateParameter(string name, object value) + => new NullDatabaseParameter(); + + public IDatabaseParameter CreateParameter(string name, DatabaseType type) + => new NullDatabaseParameter(); + + public string TimestampType + => string.Empty; + public string TextType + => string.Empty; + public string BlobType + => string.Empty; + + public string VarCharType(int size) + => string.Empty; + public string VarBinaryType(int size) + => string.Empty; + public string BigIntType(int length) + => string.Empty; + public string BigUIntType(int length) + => string.Empty; + public string IntType(int length) + => string.Empty; + public string UIntType(int length) + => string.Empty; + public string TinyIntType(int length) + => string.Empty; + public string TinyUIntType(int length) + => string.Empty; + + public string FromUnixTime(string param) + => string.Empty; + public string ToUnixTime(string param) + => string.Empty; + public string DateTimeNow() + => string.Empty; + + public string Concat(params string[] args) + => string.Empty; + public string ToLower(string param) + => string.Empty; + + public bool SupportsJson => false; + public string JsonValue(string field, string path) + => string.Empty; + + public bool SupportsAlterTableCollate => true; + + public string AsciiCollation => string.Empty; + public string UnicodeCollation => string.Empty; + } +} diff --git a/SharpChat.Common/Database/Null/NullDatabaseCommand.cs b/SharpChat.Common/Database/Null/NullDatabaseCommand.cs new file mode 100644 index 0000000..b0abac6 --- /dev/null +++ b/SharpChat.Common/Database/Null/NullDatabaseCommand.cs @@ -0,0 +1,47 @@ +using System; + +namespace SharpChat.Database.Null { + public class NullDatabaseCommand : IDatabaseCommand { + public IDatabaseConnection Connection { get; } + + public string CommandString => string.Empty; + public int CommandTimeout { get => -1; set { } } + + public NullDatabaseCommand(NullDatabaseConnection conn) { + Connection = conn ?? throw new ArgumentNullException(nameof(conn)); + } + + public IDatabaseParameter AddParameter(string name, object value) + => new NullDatabaseParameter(); + + public IDatabaseParameter AddParameter(string name, DatabaseType type) + => new NullDatabaseParameter(); + + public IDatabaseParameter AddParameter(IDatabaseParameter param) { + if(param is not NullDatabaseParameter) + throw new InvalidParameterClassTypeException(); + return param; + } + + public void AddParameters(IDatabaseParameter[] @params) {} + public void ClearParameters() {} + + public void Dispose() { + GC.SuppressFinalize(this); + } + + public int Execute() { + return 0; + } + + public IDatabaseReader ExecuteReader() { + return new NullDatabaseReader(); + } + + public object ExecuteScalar() { + return null; + } + + public void Prepare() {} + } +} diff --git a/SharpChat.Common/Database/Null/NullDatabaseConnection.cs b/SharpChat.Common/Database/Null/NullDatabaseConnection.cs new file mode 100644 index 0000000..33e996c --- /dev/null +++ b/SharpChat.Common/Database/Null/NullDatabaseConnection.cs @@ -0,0 +1,13 @@ +using System; + +namespace SharpChat.Database.Null { + public class NullDatabaseConnection : IDatabaseConnection { + public IDatabaseCommand CreateCommand(object query) { + return new NullDatabaseCommand(this); + } + + public void Dispose() { + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat.Common/Database/Null/NullDatabaseParameter.cs b/SharpChat.Common/Database/Null/NullDatabaseParameter.cs new file mode 100644 index 0000000..8627977 --- /dev/null +++ b/SharpChat.Common/Database/Null/NullDatabaseParameter.cs @@ -0,0 +1,6 @@ +namespace SharpChat.Database.Null { + public class NullDatabaseParameter : IDatabaseParameter { + public string Name => string.Empty; + public object Value { get => null; set { } } + } +} diff --git a/SharpChat.Common/Database/Null/NullDatabaseReader.cs b/SharpChat.Common/Database/Null/NullDatabaseReader.cs new file mode 100644 index 0000000..4c8e7c9 --- /dev/null +++ b/SharpChat.Common/Database/Null/NullDatabaseReader.cs @@ -0,0 +1,92 @@ +using System; + +namespace SharpChat.Database.Null { + public class NullDatabaseReader : IDatabaseReader { + public void Dispose() { + GC.SuppressFinalize(this); + } + + public string GetName(int ordinal) { + return string.Empty; + } + + public int GetOrdinal(string name) { + return 0; + } + + public object GetValue(int ordinal) { + return null; + } + + public object GetValue(string name) { + return null; + } + + public bool IsNull(int ordinal) { + return true; + } + public bool IsNull(string name) { + return true; + } + + public bool Next() { + return false; + } + + public float ReadF32(int ordinal) { + return 0f; + } + + public float ReadF32(string name) { + return 0f; + } + + public double ReadF64(int ordinal) { + return 0d; + } + + public double ReadF64(string name) { + return 0d; + } + + public short ReadI16(int ordinal) { + return 0; + } + + public short ReadI16(string name) { + return 0; + } + + public int ReadI32(int ordinal) { + return 0; + } + + public int ReadI32(string name) { + return 0; + } + + public long ReadI64(int ordinal) { + return 0; + } + + public long ReadI64(string name) { + return 0; + } + + public string ReadString(int ordinal) { + return string.Empty; + } + + public string ReadString(string name) { + return string.Empty; + } + + public byte ReadU8(int ordinal) { + return 0; + } + + public byte ReadU8(string name) { + return 0; + } + } +} diff --git a/SharpChat.Common/Events/BroadcastMessageEvent.cs b/SharpChat.Common/Events/BroadcastMessageEvent.cs new file mode 100644 index 0000000..7ae0503 --- /dev/null +++ b/SharpChat.Common/Events/BroadcastMessageEvent.cs @@ -0,0 +1,15 @@ +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class BroadcastMessageEvent : Event { + public const string TYPE = @"broadcast:message"; + + public string Text { get; } + + public BroadcastMessageEvent(ChatBot chatBot, string text) : base(chatBot) { + Text = text ?? throw new ArgumentNullException(nameof(text)); + } + } +} diff --git a/SharpChat.Common/Events/ChannelCreateEvent.cs b/SharpChat.Common/Events/ChannelCreateEvent.cs new file mode 100644 index 0000000..9d10d2c --- /dev/null +++ b/SharpChat.Common/Events/ChannelCreateEvent.cs @@ -0,0 +1,28 @@ +using SharpChat.Channels; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelCreateEvent : Event { + public const string TYPE = @"channel:create"; + + public string Name { get; } + public string Topic { get; } + public bool IsTemporary { get; } + public int MinimumRank { get; } + public string Password { get; } + public bool AutoJoin { get; } + public uint MaxCapacity { get; } + public int Order { get; } + + public ChannelCreateEvent(IChannel channel) : base(channel) { + Name = channel.Name; + Topic = channel.Topic; + IsTemporary = channel.IsTemporary; + MinimumRank = channel.MinimumRank; + Password = channel.Password; + AutoJoin = channel.AutoJoin; + MaxCapacity = channel.MaxCapacity; + Order = channel.Order; + } + } +} diff --git a/SharpChat.Common/Events/ChannelDeleteEvent.cs b/SharpChat.Common/Events/ChannelDeleteEvent.cs new file mode 100644 index 0000000..82f822a --- /dev/null +++ b/SharpChat.Common/Events/ChannelDeleteEvent.cs @@ -0,0 +1,13 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelDeleteEvent : Event { + public const string TYPE = @"channel:delete"; + + public ChannelDeleteEvent(IChannel channel) : base(channel) { } + + public ChannelDeleteEvent(IUser user, IChannel channel) : base(user, channel) { } + } +} diff --git a/SharpChat.Common/Events/ChannelSessionJoinEvent.cs b/SharpChat.Common/Events/ChannelSessionJoinEvent.cs new file mode 100644 index 0000000..5275c75 --- /dev/null +++ b/SharpChat.Common/Events/ChannelSessionJoinEvent.cs @@ -0,0 +1,11 @@ +using SharpChat.Channels; +using SharpChat.Sessions; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelSessionJoinEvent : Event { + public const string TYPE = @"channel:session:join"; + + public ChannelSessionJoinEvent(IChannel channel, ISession session) : base(channel, session) { } + } +} diff --git a/SharpChat.Common/Events/ChannelSessionLeaveEvent.cs b/SharpChat.Common/Events/ChannelSessionLeaveEvent.cs new file mode 100644 index 0000000..d76243d --- /dev/null +++ b/SharpChat.Common/Events/ChannelSessionLeaveEvent.cs @@ -0,0 +1,11 @@ +using SharpChat.Channels; +using SharpChat.Sessions; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelSessionLeaveEvent : Event { + public const string TYPE = @"channel:session:leave"; + + public ChannelSessionLeaveEvent(IChannel channel, ISession session) : base(channel, session) { } + } +} diff --git a/SharpChat.Common/Events/ChannelUpdateEvent.cs b/SharpChat.Common/Events/ChannelUpdateEvent.cs new file mode 100644 index 0000000..378de3d --- /dev/null +++ b/SharpChat.Common/Events/ChannelUpdateEvent.cs @@ -0,0 +1,47 @@ +using SharpChat.Channels; +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelUpdateEvent : Event { + public const string TYPE = @"channel:update"; + + public string PreviousName { get; } + public string Name { get; } + public string Topic { get; } + public bool? IsTemporary { get; } + public int? MinimumRank { get; } + public string Password { get; } + public bool? AutoJoin { get; } + public uint? MaxCapacity { get; } + public int? Order { get; } + + public bool HasName => Name != null; + public bool HasTopic => Topic != null; + public bool HasPassword => Password != null; + + public ChannelUpdateEvent( + IChannel channel, + IUser owner, + string name, + string topic, + bool? temp, + int? minRank, + string password, + bool? autoJoin, + uint? maxCapacity, + int? order + ) : base(owner, channel ?? throw new ArgumentNullException(nameof(channel))) { + PreviousName = channel.Name; + Name = name; + Topic = topic; + IsTemporary = temp; + MinimumRank = minRank; + Password = password; + AutoJoin = autoJoin; + MaxCapacity = maxCapacity; + Order = order; + } + } +} diff --git a/SharpChat.Common/Events/ChannelUserJoinEvent.cs b/SharpChat.Common/Events/ChannelUserJoinEvent.cs new file mode 100644 index 0000000..9ce0153 --- /dev/null +++ b/SharpChat.Common/Events/ChannelUserJoinEvent.cs @@ -0,0 +1,14 @@ +using SharpChat.Channels; +using SharpChat.Sessions; +using SharpChat.Users; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelUserJoinEvent : Event { + public const string TYPE = @"channel:user:join"; + + public ChannelUserJoinEvent(IUser user, IChannel channel) : base(user, channel) { } + + public ChannelUserJoinEvent(IChannel channel, ISession session) : base(channel, session) { } + } +} diff --git a/SharpChat.Common/Events/ChannelUserLeaveEvent.cs b/SharpChat.Common/Events/ChannelUserLeaveEvent.cs new file mode 100644 index 0000000..c8185b0 --- /dev/null +++ b/SharpChat.Common/Events/ChannelUserLeaveEvent.cs @@ -0,0 +1,17 @@ +using SharpChat.Channels; +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class ChannelUserLeaveEvent : Event { + public const string TYPE = @"channel:user:leave"; + + public UserDisconnectReason Reason { get; } + + public ChannelUserLeaveEvent(IUser user, IChannel channel, UserDisconnectReason reason) + : base(user, channel) { + Reason = reason; + } + } +} diff --git a/SharpChat.Common/Events/Event.cs b/SharpChat.Common/Events/Event.cs new file mode 100644 index 0000000..c0302ff --- /dev/null +++ b/SharpChat.Common/Events/Event.cs @@ -0,0 +1,83 @@ +using SharpChat.Channels; +using SharpChat.Protocol; +using SharpChat.Sessions; +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + public abstract class Event : IEvent { + public long EventId { get; } + public DateTimeOffset DateTime { get; } + public long UserId { get; } + public string ChannelId { get; } + public string SessionId { get; } + public string ConnectionId { get; } + + public Event( + long eventId, + DateTimeOffset dateTime, + long userId, + string channelId, + string sessionId, + string connectionId + ) { + EventId = eventId; + DateTime = dateTime; + UserId = userId; + ChannelId = channelId ?? string.Empty; + SessionId = sessionId ?? string.Empty; + ConnectionId = connectionId ?? string.Empty; + } + + public Event(DateTimeOffset dateTime, long userId, string channelId, string sessionId, string connectionId) + : this(SharpId.Next(), dateTime, userId, channelId, sessionId, connectionId) { } + + public Event(long userId, string channelId, string sessionId, string connectionId) + : this(DateTimeOffset.Now, userId, channelId, sessionId, connectionId) { } + + public Event(string channelName, string sessionId, string connectionId) + : this(-1L, channelName, sessionId, connectionId) { } + + public Event(IUser user, IChannel channel, ISession session, IConnection connection) + : this(user?.UserId ?? -1L, channel?.ChannelId, session?.SessionId, connection?.ConnectionId) { } + + public Event(IUser user, ISession session, IConnection connection) + : this(user, null, session, connection) { } + + public Event(IUser user, IChannel channel, ISession session) + : this(user, channel, session, session?.Connection) { } + + public Event(IUser user, IChannel channel) + : this(user, channel, null, null) { } + + public Event(long userId, IChannel channel) + : this(userId, channel.ChannelId, null, null) { } + + public Event(IChannel channel, ISession session) + : this(session?.User, channel, session, session?.Connection) { } + + public Event(ISession session, IConnection connection) + : this(session?.User, null, session, connection) { } + + public Event(IUser user) + : this(user, null, null, null) { } + + public Event(long userId) + : this(userId, null, null, null) { } + + public Event(IChannel channel) + : this(null, channel, null, null) { } + + public Event(ISession session) + : this(session?.User, null, session, session?.Connection) { } + + public Event(IConnection connection) + : this(connection?.Session?.User, null, connection?.Session, connection) { } + + public Event() + : this(-1L, null, null, null) { } + + public override string ToString() + => $@"[{EventId}:{GetType().Name}] U:{UserId} Ch:{ChannelId} S:{SessionId} Co:{ConnectionId}"; + } +} diff --git a/SharpChat.Common/Events/EventAttribute.cs b/SharpChat.Common/Events/EventAttribute.cs new file mode 100644 index 0000000..065e3cf --- /dev/null +++ b/SharpChat.Common/Events/EventAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace SharpChat.Events { + [AttributeUsage(AttributeTargets.Class)] + public class EventAttribute : Attribute { + public string Type { get; } + + public EventAttribute(string type) { + Type = type ?? throw new ArgumentNullException(nameof(type)); + } + } +} diff --git a/SharpChat.Common/Events/EventDispatcher.cs b/SharpChat.Common/Events/EventDispatcher.cs new file mode 100644 index 0000000..9650fd1 --- /dev/null +++ b/SharpChat.Common/Events/EventDispatcher.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; + +namespace SharpChat.Events { + public class EventDispatcher : IEventDispatcher { + private Queue<(object sender, IEvent evt)> EventQueue { get; } = new(); + private object SyncQueue { get; } = new(); + + private HashSet EventHandlers { get; } = new(); + private object SyncHandlers { get; } = new(); + + private HashSet PreventDelete { get; } = new(); + private object SyncPrevent { get; } = new(); + + private bool IsRunning = false; + private bool RunUntilEmpty = false; + private Thread ProcessThread = null; + + [Conditional(@"DEBUG")] + private static void WithDebugColour(string str, ConsoleColor colour) { + ConsoleColor prev = Console.ForegroundColor; + Console.ForegroundColor = colour; + Logger.Debug(str); + Console.ForegroundColor = prev; + } + + public void DispatchEvent(object sender, IEvent evt) { + lock(SyncQueue) { + WithDebugColour($@"+ {evt} <- {sender}.", ConsoleColor.Red); + EventQueue.Enqueue((sender, evt)); + } + } + + public void AddEventHandler(IEventHandler handler) { + if(handler == null) + throw new ArgumentNullException(nameof(handler)); + lock(SyncHandlers) + EventHandlers.Add(handler); + } + + internal void ProtectEventHandler(IEventHandler handler) { + lock(SyncPrevent) + PreventDelete.Add(handler); + } + + public void RemoveEventHandler(IEventHandler handler) { + if(handler == null) + throw new ArgumentNullException(nameof(handler)); + // prevent asshattery + lock(SyncPrevent) + if(PreventDelete.Contains(handler)) + return; + lock(SyncHandlers) + EventHandlers.Remove(handler); + } + + public bool ProcessNextQueue() { + (object sender, IEvent evt) queued; + + lock(SyncQueue) { + if(!EventQueue.TryDequeue(out queued)) + return false; + WithDebugColour($@"~ {queued.evt} <- {queued.sender}.", ConsoleColor.Green); + } + + lock(SyncHandlers) + foreach(IEventHandler handler in EventHandlers) + handler.HandleEvent(queued.sender, queued.evt); + + return true; + } + + public void StartProcessing() { + if(IsRunning) + return; + IsRunning = true; + + ProcessThread = new Thread(() => { + bool hadEvent; + do { + hadEvent = ProcessNextQueue(); + if(RunUntilEmpty && !hadEvent) + StopProcessing(); + else + Thread.Sleep(1); + } while(IsRunning); + }); + ProcessThread.Start(); + } + + public void FinishProcessing() { + RunUntilEmpty = true; + ProcessThread.Join(); + } + + public void StopProcessing() { + IsRunning = false; + RunUntilEmpty = false; + } + } +} diff --git a/SharpChat.Common/Events/IEvent.cs b/SharpChat.Common/Events/IEvent.cs new file mode 100644 index 0000000..42d2d25 --- /dev/null +++ b/SharpChat.Common/Events/IEvent.cs @@ -0,0 +1,12 @@ +using System; + +namespace SharpChat.Events { + public interface IEvent { + long EventId { get; } + DateTimeOffset DateTime { get; } + long UserId { get; } + string ChannelId { get; } + string SessionId { get; } + string ConnectionId { get; } + } +} diff --git a/SharpChat.Common/Events/IEventDispatcher.cs b/SharpChat.Common/Events/IEventDispatcher.cs new file mode 100644 index 0000000..f4ebc07 --- /dev/null +++ b/SharpChat.Common/Events/IEventDispatcher.cs @@ -0,0 +1,7 @@ +namespace SharpChat.Events { + public interface IEventDispatcher { + void AddEventHandler(IEventHandler handler); + void RemoveEventHandler(IEventHandler handler); + void DispatchEvent(object sender, IEvent evt); + } +} diff --git a/SharpChat.Common/Events/IEventExtensions.cs b/SharpChat.Common/Events/IEventExtensions.cs new file mode 100644 index 0000000..618b00a --- /dev/null +++ b/SharpChat.Common/Events/IEventExtensions.cs @@ -0,0 +1,6 @@ +namespace SharpChat.Events { + public static class IEventExtensions { + public static bool IsBroadcast(this IEvent evt) + => evt.ChannelId == null; + } +} diff --git a/SharpChat.Common/Events/IEventHandler.cs b/SharpChat.Common/Events/IEventHandler.cs new file mode 100644 index 0000000..3b15507 --- /dev/null +++ b/SharpChat.Common/Events/IEventHandler.cs @@ -0,0 +1,5 @@ +namespace SharpChat.Events { + public interface IEventHandler { + void HandleEvent(object sender, IEvent evt); + } +} diff --git a/SharpChat.Common/Events/IPBanRemovedEvent.cs b/SharpChat.Common/Events/IPBanRemovedEvent.cs new file mode 100644 index 0000000..62b5bb0 --- /dev/null +++ b/SharpChat.Common/Events/IPBanRemovedEvent.cs @@ -0,0 +1,15 @@ +using System; +using System.Net; + +namespace SharpChat.Events { + [Event(TYPE)] + public class IPBanRemovedEvent : Event { + public const string TYPE = @"ban:remove:ip"; + + public IPAddress IPAddress { get; } + + public IPBanRemovedEvent(IPAddress ipAddress) : base() { + IPAddress = ipAddress ?? throw new ArgumentNullException(nameof(ipAddress)); + } + } +} diff --git a/SharpChat.Common/Events/MessageCreateEvent.cs b/SharpChat.Common/Events/MessageCreateEvent.cs new file mode 100644 index 0000000..76cf1a8 --- /dev/null +++ b/SharpChat.Common/Events/MessageCreateEvent.cs @@ -0,0 +1,34 @@ +using SharpChat.Messages; +using SharpChat.Sessions; +using SharpChat.Users; + +namespace SharpChat.Events { + [Event(TYPE)] + public class MessageCreateEvent : Event { + public const string TYPE = @"message:create"; + + public long MessageId { get; } + public string Text { get; } + public bool IsAction { get; } + + public string UserName { get; } + public Colour UserColour { get; } + public int UserRank { get; } + public string UserNickName { get; } + public UserPermissions UserPermissions { get; } + + public MessageCreateEvent(ISession session, IMessage message) + : base(message.Channel, session) { + MessageId = message.MessageId; + Text = message.Text; + IsAction = message.IsAction; + UserName = message.Sender.UserName; + UserColour = message.Sender.Colour; + UserRank = message.Sender.Rank; + UserNickName = message.Sender is ILocalUser localUser && !string.IsNullOrWhiteSpace(localUser.NickName) + ? localUser.NickName + : null; + UserPermissions = message.Sender.Permissions; + } + } +} diff --git a/SharpChat.Common/Events/MessageDeleteEvent.cs b/SharpChat.Common/Events/MessageDeleteEvent.cs new file mode 100644 index 0000000..7fbc152 --- /dev/null +++ b/SharpChat.Common/Events/MessageDeleteEvent.cs @@ -0,0 +1,21 @@ +using SharpChat.Messages; +using SharpChat.Users; + +namespace SharpChat.Events { + [Event(TYPE)] + public class MessageDeleteEvent : Event { + public const string TYPE = @"message:delete"; + + public long MessageId { get; } + + public MessageDeleteEvent(IUser actor, IMessage message) + : base(actor, message.Channel) { + MessageId = message.MessageId; + } + + public MessageDeleteEvent(MessageUpdateEvent mue) + : base(mue.UserId, mue.ChannelId, null, null) { + MessageId = mue.MessageId; + } + } +} diff --git a/SharpChat.Common/Events/MessageUpdateEvent.cs b/SharpChat.Common/Events/MessageUpdateEvent.cs new file mode 100644 index 0000000..25268bc --- /dev/null +++ b/SharpChat.Common/Events/MessageUpdateEvent.cs @@ -0,0 +1,22 @@ +using SharpChat.Messages; +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class MessageUpdateEvent : Event { + public const string TYPE = @"message:update"; + + public long MessageId { get; } + public string Text { get; } + + public bool HasText + => !string.IsNullOrEmpty(Text); + + public MessageUpdateEvent(IMessage message, IUser editor, string text) + : base(editor, message.Channel) { + MessageId = message.MessageId; + Text = text ?? throw new ArgumentNullException(nameof(text)); + } + } +} diff --git a/SharpChat.Common/Events/SessionChannelSwitchEvent.cs b/SharpChat.Common/Events/SessionChannelSwitchEvent.cs new file mode 100644 index 0000000..6a2d63c --- /dev/null +++ b/SharpChat.Common/Events/SessionChannelSwitchEvent.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Sessions; + +namespace SharpChat.Events { + [Event(TYPE)] + public class SessionChannelSwitchEvent : Event { + public const string TYPE = @"session:channel:switch"; + + public SessionChannelSwitchEvent(IChannel channel, ISession session) + : base(channel, session) { } + } +} diff --git a/SharpChat.Common/Events/SessionCreatedEvent.cs b/SharpChat.Common/Events/SessionCreatedEvent.cs new file mode 100644 index 0000000..dd2f012 --- /dev/null +++ b/SharpChat.Common/Events/SessionCreatedEvent.cs @@ -0,0 +1,24 @@ +using SharpChat.Sessions; +using System; +using System.Net; + +namespace SharpChat.Events { + [Event(TYPE)] + public class SessionCreatedEvent : Event { + public const string TYPE = @"session:create"; + + public string ServerId { get; } + public DateTimeOffset LastPing { get; } + public bool IsSecure { get; } + public bool IsConnected { get; } + public IPAddress RemoteAddress { get; } + + public SessionCreatedEvent(ISession session) : base(session) { + ServerId = session.ServerId; + LastPing = session.LastPing; + IsSecure = session.IsSecure; + IsConnected = session.IsConnected; + RemoteAddress = session.RemoteAddress; + } + } +} diff --git a/SharpChat.Common/Events/SessionDestroyEvent.cs b/SharpChat.Common/Events/SessionDestroyEvent.cs new file mode 100644 index 0000000..69463b2 --- /dev/null +++ b/SharpChat.Common/Events/SessionDestroyEvent.cs @@ -0,0 +1,11 @@ +using SharpChat.Sessions; + +namespace SharpChat.Events { + [Event(TYPE)] + public class SessionDestroyEvent : Event { + public const string TYPE = @"session:destroy"; + + public SessionDestroyEvent(ISession session) + : base(session) {} + } +} diff --git a/SharpChat.Common/Events/SessionPingEvent.cs b/SharpChat.Common/Events/SessionPingEvent.cs new file mode 100644 index 0000000..3b5772e --- /dev/null +++ b/SharpChat.Common/Events/SessionPingEvent.cs @@ -0,0 +1,11 @@ +using SharpChat.Sessions; + +namespace SharpChat.Events { + [Event(TYPE)] + public class SessionPingEvent : Event { + public const string TYPE = @"session:ping"; + + public SessionPingEvent(ISession session) + : base(session) { } + } +} diff --git a/SharpChat.Common/Events/SessionResumeEvent.cs b/SharpChat.Common/Events/SessionResumeEvent.cs new file mode 100644 index 0000000..60bffb9 --- /dev/null +++ b/SharpChat.Common/Events/SessionResumeEvent.cs @@ -0,0 +1,29 @@ +using SharpChat.Protocol; +using SharpChat.Sessions; +using System; +using System.Net; + +namespace SharpChat.Events { + [Event(TYPE)] + public class SessionResumeEvent : Event { + public const string TYPE = @"session:resume"; + + public string ServerId { get; } + public IPAddress RemoteAddress { get; } + + public bool HasConnection + => ConnectionId != null; + + public SessionResumeEvent(ISession session, string serverId, IPAddress remoteAddress) + : base(session) { + ServerId = serverId ?? throw new ArgumentNullException(nameof(serverId)); + RemoteAddress = remoteAddress ?? throw new ArgumentNullException(nameof(remoteAddress)); + } + + public SessionResumeEvent(ISession session, IConnection connection, string serverId) + : base(session, connection) { + ServerId = serverId ?? throw new ArgumentNullException(nameof(serverId)); + RemoteAddress = connection?.RemoteAddress ?? throw new ArgumentNullException(nameof(connection)); + } + } +} diff --git a/SharpChat.Common/Events/SessionSuspendEvent.cs b/SharpChat.Common/Events/SessionSuspendEvent.cs new file mode 100644 index 0000000..e42fa17 --- /dev/null +++ b/SharpChat.Common/Events/SessionSuspendEvent.cs @@ -0,0 +1,11 @@ +using SharpChat.Sessions; + +namespace SharpChat.Events { + [Event(TYPE)] + public class SessionSuspendEvent : Event { + public const string TYPE = @"session:suspend"; + + public SessionSuspendEvent(ISession session) + : base(session) { } + } +} diff --git a/SharpChat.Common/Events/UserBanCreatedEvent.cs b/SharpChat.Common/Events/UserBanCreatedEvent.cs new file mode 100644 index 0000000..e6531ec --- /dev/null +++ b/SharpChat.Common/Events/UserBanCreatedEvent.cs @@ -0,0 +1,29 @@ +using SharpChat.Users.Remote; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class UserBanCreatedEvent : Event { + public const string TYPE = @"ban:create"; + + public long ModeratorUserId { get; } + public bool IsPermanent { get; } + public long Duration { get; } + public string Reason { get; } + + public UserBanCreatedEvent( + IRemoteUser subject, + IRemoteUser moderator, + bool permanent, + TimeSpan duration, + string reason + ) : base( + (subject ?? throw new ArgumentNullException(nameof(subject))).UserId + ) { + ModeratorUserId = moderator?.UserId ?? -1; + IsPermanent = permanent; + Duration = (long)duration.TotalSeconds; + Reason = reason ?? throw new ArgumentNullException(nameof(reason)); + } + } +} diff --git a/SharpChat.Common/Events/UserBanRemovedEvent.cs b/SharpChat.Common/Events/UserBanRemovedEvent.cs new file mode 100644 index 0000000..d823641 --- /dev/null +++ b/SharpChat.Common/Events/UserBanRemovedEvent.cs @@ -0,0 +1,14 @@ +using SharpChat.Users.Remote; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class UserBanRemovedEvent : Event { + public const string TYPE = @"ban:remove:user"; + + public UserBanRemovedEvent(IRemoteUser remoteUser) + : base( + (remoteUser ?? throw new ArgumentNullException(nameof(remoteUser))).UserId + ) { } + } +} diff --git a/SharpChat.Common/Events/UserConnectEvent.cs b/SharpChat.Common/Events/UserConnectEvent.cs new file mode 100644 index 0000000..ef51000 --- /dev/null +++ b/SharpChat.Common/Events/UserConnectEvent.cs @@ -0,0 +1,28 @@ +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class UserConnectEvent : Event { + public const string TYPE = @"user:connect"; + + public string Name { get; } + public Colour Colour { get; } + public int Rank { get; } + public UserPermissions Permissions { get; } + public string NickName { get; } + public UserStatus Status { get; } + public string StatusMessage { get; } + + public UserConnectEvent(ILocalUser user) + : base(user ?? throw new ArgumentNullException(nameof(user))) { + Name = user.UserName; + Colour = user.Colour; + Rank = user.Rank; + Permissions = user.Permissions; + NickName = string.IsNullOrWhiteSpace(user.NickName) ? null : user.NickName; + Status = user.Status; + StatusMessage = user.StatusMessage; + } + } +} diff --git a/SharpChat.Common/Events/UserDisconnectEvent.cs b/SharpChat.Common/Events/UserDisconnectEvent.cs new file mode 100644 index 0000000..eca69d0 --- /dev/null +++ b/SharpChat.Common/Events/UserDisconnectEvent.cs @@ -0,0 +1,16 @@ +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class UserDisconnectEvent : Event { + public const string TYPE = @"user:disconnect"; + + public UserDisconnectReason Reason { get; } + + public UserDisconnectEvent(IUser user, UserDisconnectReason reason) + : base(user ?? throw new ArgumentNullException(nameof(user))) { + Reason = reason; + } + } +} diff --git a/SharpChat.Common/Events/UserUpdateEvent.cs b/SharpChat.Common/Events/UserUpdateEvent.cs new file mode 100644 index 0000000..545084e --- /dev/null +++ b/SharpChat.Common/Events/UserUpdateEvent.cs @@ -0,0 +1,85 @@ +using SharpChat.Users; +using System; + +namespace SharpChat.Events { + [Event(TYPE)] + public class UserUpdateEvent : Event { + public const string TYPE = @"user:update"; + + public string OldUserName { get; } + public string NewUserName { get; } + + public Colour OldColour { get; } + public Colour? NewColour { get; } + + public int? OldRank { get; } + public int? NewRank { get; } + + public string OldNickName { get; } + public string NewNickName { get; } + + public UserPermissions OldPerms { get; } + public UserPermissions? NewPerms { get; } + + public UserStatus OldStatus { get; } + public UserStatus? NewStatus { get; } + + public string OldStatusMessage { get; } + public string NewStatusMessage { get; } + + public bool HasUserName => NewUserName != null; + public bool HasNickName => NewNickName != null; + public bool HasStatusMessage => NewStatusMessage != null; + + public UserUpdateEvent( + ILocalUser user, + string userName = null, + Colour? colour = null, + int? rank = null, + string nickName = null, + UserPermissions? perms = null, + UserStatus? status = null, + string statusMessage = null + ) : base(user ?? throw new ArgumentNullException(nameof(user))) { + OldUserName = user.UserName; + if(!OldUserName.Equals(userName)) + NewUserName = userName; + + OldColour = user.Colour; + if(!OldColour.Equals(colour)) + NewColour = colour; + + OldRank = user.Rank; + if(OldRank != rank) + NewRank = rank; + + OldNickName = user.NickName; + if(!OldNickName.Equals(nickName)) + NewNickName = nickName; + + OldPerms = user.Permissions; + if(OldPerms != perms) + NewPerms = perms; + + OldStatus = user.Status; + if(OldStatus != status) + NewStatus = status; + + OldStatusMessage = user.StatusMessage; + if(!OldStatusMessage.Equals(statusMessage)) + NewStatusMessage = statusMessage; + } + + public UserUpdateEvent(ILocalUser user, UserUpdateEvent uue) + : this( + user, + uue.NewUserName, + uue.NewColour, + uue.NewRank, + uue.NewNickName, + uue.NewPerms, + uue.NewStatus, + uue.NewStatusMessage + ) { } + } +} diff --git a/SharpChat/Logger.cs b/SharpChat.Common/Logger.cs similarity index 64% rename from SharpChat/Logger.cs rename to SharpChat.Common/Logger.cs index b8334e1..c2a2fea 100644 --- a/SharpChat/Logger.cs +++ b/SharpChat.Common/Logger.cs @@ -1,28 +1,43 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Text; namespace SharpChat { public static class Logger { + public static void Write() + => Console.WriteLine(); + + [Conditional(@"DEBUG")] + public static void Debug() + => Write(); + 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); + public static void Write(byte[] bytes) + => Write(Encoding.UTF8.GetString(bytes)); + [Conditional(@"DEBUG")] public static void Debug(byte[] bytes) => Write(bytes); + public static void Write(object obj) + => Write(obj?.ToString() ?? string.Empty); + [Conditional(@"DEBUG")] public static void Debug(object obj) => Write(obj); + + public static void Write(IEnumerable objs) + => Write(string.Join(@", ", objs)); + + [Conditional(@"DEBUG")] + public static void Debug(IEnumerable objs) + => Write(objs); } } diff --git a/SharpChat.Common/Messages/IMessage.cs b/SharpChat.Common/Messages/IMessage.cs new file mode 100644 index 0000000..c60b5b7 --- /dev/null +++ b/SharpChat.Common/Messages/IMessage.cs @@ -0,0 +1,16 @@ +using SharpChat.Channels; +using SharpChat.Users; +using System; + +namespace SharpChat.Messages { + public interface IMessage { + long MessageId { get; } + IChannel Channel { get; } + IUser Sender { get; } + string Text { get; } + bool IsAction { get; } + DateTimeOffset Created { get; } + DateTimeOffset? Edited { get; } + bool IsEdited { get; } + } +} diff --git a/SharpChat.Common/Messages/IMessageExtensions.cs b/SharpChat.Common/Messages/IMessageExtensions.cs new file mode 100644 index 0000000..800f194 --- /dev/null +++ b/SharpChat.Common/Messages/IMessageExtensions.cs @@ -0,0 +1,10 @@ +namespace SharpChat.Messages { + public static class IMessageExtensions { + public static string GetSanitisedText(this IMessage msg) + => msg.Text + .Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
") + .Replace("\t", @" "); + } +} diff --git a/SharpChat.Common/Messages/Message.cs b/SharpChat.Common/Messages/Message.cs new file mode 100644 index 0000000..59de749 --- /dev/null +++ b/SharpChat.Common/Messages/Message.cs @@ -0,0 +1,34 @@ +using SharpChat.Channels; +using SharpChat.Users; +using System; + +namespace SharpChat.Messages { + public class Message : IMessage { + public long MessageId { get; } + public IChannel Channel { get; } + public IUser Sender { get; } + public string Text { get; } + public bool IsAction { get; } + public DateTimeOffset Created { get; } + public DateTimeOffset? Edited { get; } + + public bool IsEdited => Edited.HasValue; + + public Message( + IChannel channel, + IUser sender, + string text, + bool isAction = false, + DateTimeOffset? created = null, + DateTimeOffset? edited = null + ) { + MessageId = SharpId.Next(); + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + Text = text ?? throw new ArgumentNullException(nameof(text)); + IsAction = isAction; + Created = created ?? DateTimeOffset.Now; + Edited = edited; + } + } +} diff --git a/SharpChat.Common/Messages/MessageManager.cs b/SharpChat.Common/Messages/MessageManager.cs new file mode 100644 index 0000000..5a72cf4 --- /dev/null +++ b/SharpChat.Common/Messages/MessageManager.cs @@ -0,0 +1,99 @@ +using SharpChat.Channels; +using SharpChat.Configuration; +using SharpChat.Events; +using SharpChat.Messages.Storage; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Messages { + public class MessageManager : IEventHandler { + private IEventDispatcher Dispatcher { get; } + private IMessageStorage Storage { get; } + private IConfig Config { get; } + + public const int DEFAULT_LENGTH_MAX = 2100; + private CachedValue TextMaxLengthValue { get; } + public int TextMaxLength => TextMaxLengthValue; + + public MessageManager(IEventDispatcher dispatcher, IMessageStorage storage, IConfig config) { + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + Storage = storage ?? throw new ArgumentNullException(nameof(storage)); + Config = config ?? throw new ArgumentNullException(nameof(config)); + + TextMaxLengthValue = Config.ReadCached(@"maxLength", DEFAULT_LENGTH_MAX); + } + + public Message Create(ISession session, IChannel channel, string text, bool isAction = false) + => Create(session, session.User, channel, text, isAction); + + public Message Create(ISession session, IUser sender, IChannel channel, string text, bool isAction = false) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + if(sender == null) + throw new ArgumentNullException(nameof(sender)); + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(text == null) + throw new ArgumentNullException(nameof(text)); + + if(string.IsNullOrWhiteSpace(text)) + throw new ArgumentException(@"Provided text is empty.", nameof(text)); + if(text.Length > TextMaxLength) + throw new ArgumentException(@"Provided text is too long.", nameof(text)); + + Message message = new(channel, sender, text, isAction); + Dispatcher.DispatchEvent(this, new MessageCreateEvent(session, message)); + return message; + } + + public void Edit(IUser editor, IMessage message, string text = null) { + if(editor == null) + throw new ArgumentNullException(nameof(editor)); + if(message == null) + throw new ArgumentNullException(nameof(message)); + + if(text == null) + return; + if(string.IsNullOrWhiteSpace(text)) + throw new ArgumentException(@"Provided text is empty.", nameof(text)); + if(text.Length > TextMaxLength) + throw new ArgumentException(@"Provided text is too long.", nameof(text)); + + MessageUpdateEvent mue = new(message, editor, text); + if(message is IEventHandler meh) + meh.HandleEvent(this, mue); + Dispatcher.DispatchEvent(this, mue); + } + + public void Delete(IUser user, IMessage message) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(message == null) + throw new ArgumentNullException(nameof(message)); + + MessageDeleteEvent mde = new(user, message); + if(message is IEventHandler meh) + meh.HandleEvent(this, mde); + Dispatcher.DispatchEvent(this, mde); + } + + public void GetMessage(long messageId, Action callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + Storage.GetMessage(messageId, callback); + } + + public void GetMessages(IChannel channel, Action> callback, int amount = 20, int offset = 0) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + Storage.GetMessages(channel, callback, amount, offset); + } + + public void HandleEvent(object sender, IEvent evt) + => Storage.HandleEvent(sender, evt); + } +} diff --git a/SharpChat.Common/Messages/Storage/ADOMessage.cs b/SharpChat.Common/Messages/Storage/ADOMessage.cs new file mode 100644 index 0000000..bff0d40 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/ADOMessage.cs @@ -0,0 +1,33 @@ +using SharpChat.Channels; +using SharpChat.Database; +using SharpChat.Users; +using System; + +namespace SharpChat.Messages.Storage { + public class ADOMessage : IMessage { + public long MessageId { get; } + public IChannel Channel { get; } + public IUser Sender { get; } + public string Text { get; } + public DateTimeOffset Created { get; } + public DateTimeOffset? Edited { get; } + + public bool IsAction => (Flags & IS_ACTION) == IS_ACTION; + public bool IsEdited => Edited.HasValue; + + public const byte IS_ACTION = 1; + public byte Flags { get; } + + public ADOMessage(IDatabaseReader reader) { + if(reader == null) + throw new ArgumentNullException(nameof(reader)); + MessageId = reader.ReadI64(@"msg_id"); + Channel = new ADOMessageChannel(reader); + Sender = new ADOMessageUser(reader); + Text = reader.ReadString(@"msg_text"); + Flags = reader.ReadU8(@"msg_flags"); + Created = DateTimeOffset.FromUnixTimeSeconds(reader.ReadI64(@"msg_created")); + Edited = reader.IsNull(@"msg_edited") ? null : DateTimeOffset.FromUnixTimeSeconds(reader.ReadI64(@"msg_edited")); + } + } +} diff --git a/SharpChat.Common/Messages/Storage/ADOMessageChannel.cs b/SharpChat.Common/Messages/Storage/ADOMessageChannel.cs new file mode 100644 index 0000000..c009c08 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/ADOMessageChannel.cs @@ -0,0 +1,31 @@ +using SharpChat.Channels; +using SharpChat.Database; +using System; + +namespace SharpChat.Messages.Storage { + public class ADOMessageChannel : IChannel { + public string ChannelId { get; } + public string Name => string.Empty; + public string Topic => string.Empty; + public bool IsTemporary => true; + public int MinimumRank => 0; + public bool AutoJoin => false; + public uint MaxCapacity => 0; + public long OwnerId => -1; + public string Password => string.Empty; + public bool HasPassword => false; + public int Order => 0; + + public ADOMessageChannel(IDatabaseReader reader) { + if(reader == null) + throw new ArgumentNullException(nameof(reader)); + ChannelId = reader.ReadString(@"msg_channel_id"); + } + + public bool Equals(IChannel other) + => other != null && ChannelId.Equals(other.ChannelId); + + public override string ToString() + => $@""; + } +} diff --git a/SharpChat.Common/Messages/Storage/ADOMessageStorage.cs b/SharpChat.Common/Messages/Storage/ADOMessageStorage.cs new file mode 100644 index 0000000..3704be9 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/ADOMessageStorage.cs @@ -0,0 +1,137 @@ +using SharpChat.Channels; +using SharpChat.Database; +using SharpChat.Events; +using System; +using System.Collections.Generic; + +namespace SharpChat.Messages.Storage { + public partial class ADOMessageStorage : IMessageStorage { + private DatabaseWrapper Wrapper { get; } + + public ADOMessageStorage(DatabaseWrapper wrapper) { + Wrapper = wrapper ?? throw new ArgumentNullException(nameof(wrapper)); + RunMigrations(); + } + + public void GetMessage(long messageId, Action callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + IMessage msg = null; + + Wrapper.RunQuery( + @"SELECT `msg_id`, `msg_channel_id`, `msg_sender_id`, `msg_sender_name`, `msg_sender_colour`, `msg_sender_rank`, `msg_sender_nick`" + + @", `msg_sender_perms`, `msg_text`, `msg_flags`" + + @", " + Wrapper.ToUnixTime(@"`msg_created`") + @" AS `msg_created`" + + @", " + Wrapper.ToUnixTime(@"`msg_edited`") + @" AS `msg_edited`" + + @" FROM `sqc_messages`" + + @" WHERE `msg_id` = @id" + + @" AND `msg_deleted` IS NULL" + + @" LIMIT 1", + reader => { + if(reader.Next()) + msg = new ADOMessage(reader); + }, + Wrapper.CreateParam(@"id", messageId) + ); + + callback(msg); + } + + public void GetMessages(IChannel channel, Action> callback, int amount, int offset) { + List msgs = new(); + + Wrapper.RunQuery( + @"SELECT `msg_id`, `msg_channel_id`, `msg_sender_id`, `msg_sender_name`, `msg_sender_colour`, `msg_sender_rank`, `msg_sender_nick`" + + @", `msg_sender_perms`, `msg_text`, `msg_flags`" + + @", " + Wrapper.ToUnixTime(@"`msg_created`") + @" AS `msg_created`" + + @", " + Wrapper.ToUnixTime(@"`msg_edited`") + @" AS `msg_edited`" + + @" FROM `sqc_messages`" + + @" WHERE `msg_channel_id` = @channelId" + + @" AND `msg_deleted` IS NULL" + + @" ORDER BY `msg_id` DESC" + + @" LIMIT @amount OFFSET @offset", + reader => { + while(reader.Next()) + msgs.Add(new ADOMessage(reader)); + }, + Wrapper.CreateParam(@"channelId", channel.ChannelId), + Wrapper.CreateParam(@"amount", amount), + Wrapper.CreateParam(@"offset", offset) + ); + + msgs.Reverse(); + callback(msgs); + } + + private void StoreMessage(MessageCreateEvent mce) { + byte flags = 0; + if(mce.IsAction) + flags |= ADOMessage.IS_ACTION; + + Wrapper.RunCommand( + @"INSERT INTO `sqc_messages` (" + + @"`msg_id`, `msg_channel_id`, `msg_sender_id`, `msg_sender_name`, `msg_sender_colour`, `msg_sender_rank`" + + @", `msg_sender_nick`, `msg_sender_perms`, `msg_text`, `msg_flags`, `msg_created`" + + @") VALUES (" + + @"@id, @channelId, @senderId, @senderName, @senderColour, @senderRank, @senderNick, @senderPerms" + + @", @text, @flags, " + Wrapper.FromUnixTime(@"@created") + + @");", + Wrapper.CreateParam(@"id", mce.MessageId), + Wrapper.CreateParam(@"channelId", mce.ChannelId), + Wrapper.CreateParam(@"senderId", mce.UserId), + Wrapper.CreateParam(@"senderName", mce.UserName), + Wrapper.CreateParam(@"senderColour", mce.UserColour.Raw), + Wrapper.CreateParam(@"senderRank", mce.UserRank), + Wrapper.CreateParam(@"senderNick", mce.UserNickName), + Wrapper.CreateParam(@"senderPerms", mce.UserPermissions), + Wrapper.CreateParam(@"text", mce.Text), + Wrapper.CreateParam(@"flags", flags), + Wrapper.CreateParam(@"created", mce.DateTime.ToUnixTimeSeconds()) + ); + } + + private void UpdateMessage(MessageUpdateEvent mue) { + Wrapper.RunCommand( + @"UPDATE `sqc_messages` SET `msg_text` = @text, `msg_edited` = " + Wrapper.FromUnixTime(@"@edited") + @" WHERE `msg_id` = @id", + Wrapper.CreateParam(@"text", mue.Text), + Wrapper.CreateParam(@"edited", mue.DateTime.ToUnixTimeSeconds()), + Wrapper.CreateParam(@"id", mue.MessageId) + ); + } + + private void DeleteMessage(MessageDeleteEvent mde) { + Wrapper.RunCommand( + @"UPDATE `sqc_messages` SET `msg_deleted` = " + Wrapper.FromUnixTime(@"@deleted") + @" WHERE `msg_id` = @id", + Wrapper.CreateParam(@"deleted", mde.DateTime.ToUnixTimeSeconds()), + Wrapper.CreateParam(@"id", mde.MessageId) + ); + } + + private void DeleteChannel(ChannelDeleteEvent cde) { + Wrapper.RunCommand( + @"UPDATE `sqc_messages` SET `msg_deleted` = " + Wrapper.FromUnixTime(@"@deleted") + @" WHERE `msg_channel_id` = @channelId AND `msg_deleted` IS NULL", + Wrapper.CreateParam(@"deleted", cde.DateTime.ToUnixTimeSeconds()), + Wrapper.CreateParam(@"channelId", cde.ChannelId) + ); + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case MessageCreateEvent mce: + StoreMessage(mce); + break; + case MessageUpdateEvent mue: + UpdateMessage(mue); + break; + case MessageDeleteEvent mde: + DeleteMessage(mde); + break; + + case ChannelDeleteEvent cde: + DeleteChannel(cde); + break; + } + } + } +} diff --git a/SharpChat.Common/Messages/Storage/ADOMessageStorage_Migrations.cs b/SharpChat.Common/Messages/Storage/ADOMessageStorage_Migrations.cs new file mode 100644 index 0000000..7771501 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/ADOMessageStorage_Migrations.cs @@ -0,0 +1,73 @@ +using System; + +namespace SharpChat.Messages.Storage { + public partial class ADOMessageStorage { + private const string CREATE_MESSAGES_TABLE = @"create_msgs_table"; + private const string LEGACY_CREATE_EVENTS_TABLE = @"create_events_table"; + + public void RunMigrations() { + Wrapper.RunCommand( + @"CREATE TABLE IF NOT EXISTS `sqc_migrations` (" + + @"`migration_name` " + Wrapper.VarCharType(255) + @" PRIMARY KEY," + + @"`migration_completed` " + Wrapper.TimestampType + @" NOT NULL DEFAULT 0" + + @");" + ); + Wrapper.RunCommand(@"CREATE INDEX IF NOT EXISTS `sqc_migrations_completed_index` ON `sqc_migrations` (`migration_completed`);"); + + DoMigration(CREATE_MESSAGES_TABLE, CreateMessagesTable); + } + + private bool CheckMigration(string name) { + return Wrapper.RunQueryValue( + @"SELECT `migration_completed` IS NOT NULL FROM `sqc_migrations` WHERE `migration_name` = @name LIMIT 1", + Wrapper.CreateParam(@"name", name) + ) is not null; + } + + private void DoMigration(string name, Action action) { + if(!CheckMigration(name)) { + Logger.Write($@"Running migration '{name}'..."); + action(); + Wrapper.RunCommand( + @"INSERT INTO `sqc_migrations` (`migration_name`, `migration_completed`) VALUES (@name, " + Wrapper.DateTimeNow() + @")", + Wrapper.CreateParam(@"name", name) + ); + } + } + + private void CreateMessagesTable() { + Wrapper.RunCommand( + @"CREATE TABLE `sqc_messages` (" + + @"`msg_id` " + Wrapper.BigIntType(20) + @" PRIMARY KEY," + + @"`msg_channel_id` " + Wrapper.VarBinaryType(255) + @" NOT NULL," + + @"`msg_sender_id` " + Wrapper.BigUIntType(20) + @" NOT NULL," + + @"`msg_sender_name` " + Wrapper.VarCharType(255) + @" NOT NULL COLLATE " + Wrapper.UnicodeCollation + @"," + + @"`msg_sender_colour` " + Wrapper.IntType(11) + @" NOT NULL," + + @"`msg_sender_rank` " + Wrapper.IntType(11) + @" NOT NULL," + + @"`msg_sender_nick` " + Wrapper.VarCharType(255) + @" NULL DEFAULT NULL COLLATE " + Wrapper.UnicodeCollation + @"," + + @"`msg_sender_perms` " + Wrapper.IntType(11) + @" NOT NULL," + + @"`msg_text` " + Wrapper.TextType + @" NOT NULL COLLATE " + Wrapper.UnicodeCollation + @"," + + @"`msg_flags` " + Wrapper.TinyUIntType(3) + @" NOT NULL," + + @"`msg_created` " + Wrapper.TimestampType + @" NOT NULL DEFAULT 0," + + @"`msg_edited` " + Wrapper.TimestampType + @" NULL DEFAULT NULL," + + @"`msg_deleted` " + Wrapper.TimestampType + @" NULL DEFAULT NULL" + + @");" + ); + Wrapper.RunCommand(@"CREATE INDEX `sqc_messages_channel_index` ON `sqc_messages` (`msg_channel_id`);"); + Wrapper.RunCommand(@"CREATE INDEX `sqc_messages_sender_index` ON `sqc_messages` (`msg_sender_id`);"); + Wrapper.RunCommand(@"CREATE INDEX `sqc_messages_flags_index` ON `sqc_messages` (`msg_flags`);"); + Wrapper.RunCommand(@"CREATE INDEX `sqc_messages_created_index` ON `sqc_messages` (`msg_created`);"); + Wrapper.RunCommand(@"CREATE INDEX `sqc_messages_edited_index` ON `sqc_messages` (`msg_edited`);"); + Wrapper.RunCommand(@"CREATE INDEX `sqc_messages_deleted_index` ON `sqc_messages` (`msg_deleted`);"); + + if(Wrapper.SupportsJson && CheckMigration(LEGACY_CREATE_EVENTS_TABLE)) + Wrapper.RunCommand( + @"INSERT INTO `sqc_messages`" + + @" SELECT `event_id`, " + Wrapper.ToLower(@"`event_target`") + @", `event_sender`, `event_sender_name`" + + @", `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`" + + @", " + Wrapper.JsonValue(@"`event_data`", @"$.text") + @", `event_flags` & 1, `event_created`, NULL, `event_deleted`" + + @" FROM `sqc_events` WHERE `event_type` = 'SharpChat.Events.ChatMessage';", 1800 + ); + } + } +} diff --git a/SharpChat.Common/Messages/Storage/ADOMessageUser.cs b/SharpChat.Common/Messages/Storage/ADOMessageUser.cs new file mode 100644 index 0000000..2d58a21 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/ADOMessageUser.cs @@ -0,0 +1,33 @@ +using SharpChat.Database; +using SharpChat.Users; +using System; + +namespace SharpChat.Messages.Storage { + public class ADOMessageUser : IUser { + public long UserId { get; } + public string UserName { get; } + public Colour Colour { get; } + public int Rank { get; } + public string NickName { get; } + public UserPermissions Permissions { get; } + public UserStatus Status => UserStatus.Unknown; + public string StatusMessage => string.Empty; + + public ADOMessageUser(IDatabaseReader reader) { + if(reader == null) + throw new ArgumentNullException(nameof(reader)); + UserId = reader.ReadI64(@"msg_sender_id"); + UserName = reader.ReadString(@"msg_sender_name"); + Colour = new(reader.ReadI32(@"msg_sender_colour")); + Rank = reader.ReadI32(@"msg_sender_rank"); + NickName = reader.IsNull(@"msg_sender_nick") ? null : reader.ReadString(@"msg_sender_nick"); + Permissions = (UserPermissions)reader.ReadI32(@"msg_sender_perms"); + } + + public bool Equals(IUser other) + => other != null && other.UserId == UserId; + + public override string ToString() + => $@""; + } +} diff --git a/SharpChat.Common/Messages/Storage/IMessageStorage.cs b/SharpChat.Common/Messages/Storage/IMessageStorage.cs new file mode 100644 index 0000000..6a114b9 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/IMessageStorage.cs @@ -0,0 +1,11 @@ +using SharpChat.Channels; +using SharpChat.Events; +using System; +using System.Collections.Generic; + +namespace SharpChat.Messages.Storage { + public interface IMessageStorage : IEventHandler { + void GetMessage(long messageId, Action callback); + void GetMessages(IChannel channel, Action> callback, int amount, int offset); + } +} diff --git a/SharpChat.Common/Messages/Storage/MemoryMessage.cs b/SharpChat.Common/Messages/Storage/MemoryMessage.cs new file mode 100644 index 0000000..7621602 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/MemoryMessage.cs @@ -0,0 +1,42 @@ +using SharpChat.Channels; +using SharpChat.Events; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Messages.Storage { + public class MemoryMessage : IMessage, IEventHandler { + public long MessageId { get; } + public IChannel Channel { get; } + public IUser Sender { get; } + public string Text { get; private set; } + public bool IsAction { get; } + public DateTimeOffset Created { get; } + public DateTimeOffset? Edited { get; private set; } + public bool IsEdited => Edited.HasValue; + + public MemoryMessage(MemoryMessageChannel channel, MessageCreateEvent mce) { + if(mce == null) + throw new ArgumentNullException(nameof(mce)); + MessageId = mce.MessageId; + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Sender = new MemoryMessageUser(mce); + Text = mce.Text; + IsAction = mce.IsAction; + Created = mce.DateTime; + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case MessageUpdateEvent mue: + Edited = mue.DateTime; + if(mue.HasText) + Text = mue.Text; + break; + } + } + } +} diff --git a/SharpChat.Common/Messages/Storage/MemoryMessageChannel.cs b/SharpChat.Common/Messages/Storage/MemoryMessageChannel.cs new file mode 100644 index 0000000..450bbcc --- /dev/null +++ b/SharpChat.Common/Messages/Storage/MemoryMessageChannel.cs @@ -0,0 +1,28 @@ +using SharpChat.Channels; +using SharpChat.Events; + +namespace SharpChat.Messages.Storage { + public class MemoryMessageChannel : IChannel { + public string ChannelId { get; } + public string Name => string.Empty; + public string Topic => string.Empty; + public bool IsTemporary => true; + public int MinimumRank => 0; + public bool AutoJoin => false; + public uint MaxCapacity => 0; + public long OwnerId => -1; + public string Password => string.Empty; + public bool HasPassword => false; + public int Order => 0; + + public MemoryMessageChannel(IEvent evt) { + ChannelId = evt.ChannelId; + } + + public bool Equals(IChannel other) + => other != null && ChannelId.Equals(other.ChannelId); + + public override string ToString() + => $@""; + } +} diff --git a/SharpChat.Common/Messages/Storage/MemoryMessageStorage.cs b/SharpChat.Common/Messages/Storage/MemoryMessageStorage.cs new file mode 100644 index 0000000..bdfd716 --- /dev/null +++ b/SharpChat.Common/Messages/Storage/MemoryMessageStorage.cs @@ -0,0 +1,90 @@ +using SharpChat.Channels; +using SharpChat.Events; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Messages.Storage { + public class MemoryMessageStorage : IMessageStorage { + private List Messages { get; } = new(); + private List Channels { get; } = new(); + private readonly object Sync = new(); + + public void GetMessage(long messageId, Action callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Messages.FirstOrDefault(m => m.MessageId == messageId)); + } + + public void GetMessages(IChannel channel, Action> callback, int amount, int offset) { + lock(Sync) { + IEnumerable subset = Messages.Where(m => m.Channel.Equals(channel)); + + int start = subset.Count() - offset - amount; + + if(start < 0) { + amount += start; + start = 0; + } + + callback(subset.Skip(start).Take(amount)); + } + } + + private void StoreMessage(MessageCreateEvent mce) { + lock(Sync) { + MemoryMessageChannel channel = Channels.FirstOrDefault(c => mce.ChannelId.Equals(mce.ChannelId)); + if(channel == null) + return; // This is basically an invalid state + Messages.Add(new(channel, mce)); + } + } + + private void UpdateMessage(object sender, MessageUpdateEvent mue) { + lock(Sync) + Messages.FirstOrDefault(m => m.MessageId == mue.MessageId)?.HandleEvent(sender, mue); + } + + private void DeleteMessage(MessageDeleteEvent mde) { + lock(Sync) + Messages.RemoveAll(m => m.MessageId == mde.MessageId); + } + + private void CreateChannel(ChannelCreateEvent cce) { + lock(Sync) + Channels.Add(new(cce)); + } + + private void DeleteChannel(ChannelDeleteEvent cde) { + lock(Sync) { + MemoryMessageChannel channel = Channels.FirstOrDefault(c => cde.ChannelId.Equals(c.ChannelId)); + if(channel == null) + return; + Channels.Remove(channel); + Messages.RemoveAll(m => m.Channel.Equals(channel)); + } + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case MessageCreateEvent mce: + StoreMessage(mce); + break; + case MessageUpdateEvent mue: + UpdateMessage(sender, mue); + break; + case MessageDeleteEvent mde: + DeleteMessage(mde); + break; + + case ChannelCreateEvent cce: + CreateChannel(cce); + break; + case ChannelDeleteEvent cde: + DeleteChannel(cde); + break; + } + } + } +} diff --git a/SharpChat.Common/Messages/Storage/MemoryMessageUser.cs b/SharpChat.Common/Messages/Storage/MemoryMessageUser.cs new file mode 100644 index 0000000..3cc795e --- /dev/null +++ b/SharpChat.Common/Messages/Storage/MemoryMessageUser.cs @@ -0,0 +1,30 @@ +using SharpChat.Events; +using SharpChat.Users; + +namespace SharpChat.Messages.Storage { + public class MemoryMessageUser : IUser { + public long UserId { get; } + public string UserName { get; } + public Colour Colour { get; } + public int Rank { get; } + public string NickName { get; } + public UserPermissions Permissions { get; } + public UserStatus Status => UserStatus.Unknown; + public string StatusMessage => string.Empty; + + public MemoryMessageUser(MessageCreateEvent mce) { + UserId = mce.UserId; + UserName = mce.UserName; + Colour = mce.UserColour; + Rank = mce.UserRank; + NickName = mce.UserNickName; + Permissions = mce.UserPermissions; + } + + public bool Equals(IUser other) + => other != null && other.UserId == UserId; + + public override string ToString() + => $@""; + } +} diff --git a/SharpChat.Common/Protocol/ConnectionList.cs b/SharpChat.Common/Protocol/ConnectionList.cs new file mode 100644 index 0000000..69b7263 --- /dev/null +++ b/SharpChat.Common/Protocol/ConnectionList.cs @@ -0,0 +1,170 @@ +using SharpChat.Channels; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol { + public class ConnectionList + where TConnection : IConnection { + private HashSet Connections { get; } = new(); + private readonly object Sync = new(); + + private SessionManager Sessions { get; } + private ChannelUserRelations ChannelUsers { get; } + + public ConnectionList( + SessionManager sessions, + ChannelUserRelations channelUsers + ) { + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + } + + public virtual void AddConnection(TConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + lock(Sync) + Connections.Add(conn); + } + + public virtual void RemoveConnection(TConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + lock(Sync) + Connections.Remove(conn); + } + + public void RemoveConnection(string connId) { + if(connId == null) + throw new ArgumentNullException(nameof(connId)); + GetConnection(connId, c => Connections.Remove(c)); + } + + public void GetConnection(Func predicate, Action callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + TConnection conn = Connections.FirstOrDefault(predicate); + if(conn == null) + return; + callback(conn); + } + } + + public void GetConnection(string connId, Action callback) { + if(connId == null) + throw new ArgumentNullException(nameof(connId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetConnection(c => connId.Equals(c.ConnectionId), callback); + } + + public void GetConnection(ISession session, Action callback) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetConnection(c => session.Equals(c.Session), callback); + } + + public void GetConnectionBySessionId(string sessionId, Action callback) { + if(sessionId == null) + throw new ArgumentNullException(nameof(sessionId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(sessionId)) { + callback(default); + return; + } + GetConnection(c => c.Session != null && sessionId.Equals(c.Session.SessionId), callback); + } + + public void GetConnections(Func predicate, Action> callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Connections.Where(predicate)); + } + + public void GetConnectionsWithSession(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetConnections(c => c.Session != null, callback); + } + + public void GetOwnConnections(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetConnections(c => c.Session != null && user.Equals(c.Session.User), callback); + } + + public void GetConnectionsByChannelId(string channelId, Action> callback) { + if(channelId == null) + throw new ArgumentNullException(nameof(channelId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + ChannelUsers.GetLocalSessionsByChannelId(channelId, sessions => GetConnections(sessions, callback)); + } + + public void GetConnectionsByChannelName(string channelName, Action> callback) { + if(channelName == null) + throw new ArgumentNullException(nameof(channelName)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + ChannelUsers.GetLocalSessionsByChannelName(channelName, sessions => GetConnections(sessions, callback)); + } + + public void GetConnections(IChannel channel, Action> callback) { + if(channel == null) + throw new ArgumentNullException(nameof(channel)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + ChannelUsers.GetLocalSessions(channel, sessions => GetConnections(sessions, callback)); + } + + public void GetConnections(IEnumerable sessions, Action> callback) { + if(sessions == null) + throw new ArgumentNullException(nameof(sessions)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(!sessions.Any()) { + callback(Enumerable.Empty()); + return; + } + lock(Sync) + callback(sessions.Where(s => s.Connection is TConnection conn && Connections.Contains(conn)).Select(s => (TConnection)s.Connection)); + } + + public void GetAllConnections(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + Sessions.GetLocalSessions(user, sessions => GetConnections(sessions, callback)); + } + + public void GetAllConnectionsByUserId(long userId, Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(userId < 1) { + callback(Enumerable.Empty()); + return; + } + Sessions.GetLocalSessionsByUserId(userId, sessions => GetConnections(sessions, callback)); + } + + public void GetDeadConnections(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetConnections(c => !c.IsAvailable, callback); + } + } +} diff --git a/SharpChat.Common/Protocol/IConnection.cs b/SharpChat.Common/Protocol/IConnection.cs new file mode 100644 index 0000000..0bb58c8 --- /dev/null +++ b/SharpChat.Common/Protocol/IConnection.cs @@ -0,0 +1,15 @@ +using SharpChat.Sessions; +using System; +using System.Net; + +namespace SharpChat.Protocol { + public interface IConnection { + string ConnectionId { get; } + IPAddress RemoteAddress { get; } + bool IsAvailable { get; } + bool IsSecure { get; } + DateTimeOffset LastPing { get; set; } + ISession Session { get; set; } + void Close(); + } +} diff --git a/SharpChat.Common/Protocol/IServer.cs b/SharpChat.Common/Protocol/IServer.cs new file mode 100644 index 0000000..40d5a44 --- /dev/null +++ b/SharpChat.Common/Protocol/IServer.cs @@ -0,0 +1,9 @@ +using SharpChat.Events; +using System; +using System.Net; + +namespace SharpChat.Protocol { + public interface IServer : IEventHandler, IDisposable { + void Listen(EndPoint endPoint); + } +} diff --git a/SharpChat.Common/Protocol/Null/NullServer.cs b/SharpChat.Common/Protocol/Null/NullServer.cs new file mode 100644 index 0000000..8a0cc12 --- /dev/null +++ b/SharpChat.Common/Protocol/Null/NullServer.cs @@ -0,0 +1,16 @@ +using SharpChat.Events; +using System; +using System.Net; + +namespace SharpChat.Protocol.Null { + [Server(@"null")] + public class NullServer : IServer { + public void Listen(EndPoint endPoint) { } + + public void HandleEvent(object sender, IEvent evt) { } + + public void Dispose() { + GC.SuppressFinalize(this); + } + } +} diff --git a/SharpChat.Common/Protocol/ProtocolException.cs b/SharpChat.Common/Protocol/ProtocolException.cs new file mode 100644 index 0000000..4e4d1bb --- /dev/null +++ b/SharpChat.Common/Protocol/ProtocolException.cs @@ -0,0 +1,11 @@ +using System; + +namespace SharpChat.Protocol { + public class ProtocolException : Exception { + public ProtocolException(string message) : base(message) { } + } + + public class ProtocolAlreadyListeningException : ProtocolException { + public ProtocolAlreadyListeningException() : base(@"Protocol is already listening.") { } + } +} diff --git a/SharpChat.Common/Protocol/ServerAttribute.cs b/SharpChat.Common/Protocol/ServerAttribute.cs new file mode 100644 index 0000000..a64aaab --- /dev/null +++ b/SharpChat.Common/Protocol/ServerAttribute.cs @@ -0,0 +1,8 @@ +using SharpChat.Reflection; + +namespace SharpChat.Protocol { + public class ServerAttribute : ObjectConstructorAttribute { + public ServerAttribute(string name) : base(name) { + } + } +} diff --git a/SharpChat/RNG.cs b/SharpChat.Common/RNG.cs similarity index 55% rename from SharpChat/RNG.cs rename to SharpChat.Common/RNG.cs index 8357b7c..6d08d25 100644 --- a/SharpChat/RNG.cs +++ b/SharpChat.Common/RNG.cs @@ -1,10 +1,11 @@ using System; using System.Security.Cryptography; +using System.Text; namespace SharpChat { public static class RNG { - private static object Lock { get; } = new object(); - private static Random NormalRandom { get; } = new Random(); + private static object Lock { get; } = new(); + private static Random NormalRandom { get; } = new(); private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create(); public static int Next() { @@ -26,5 +27,16 @@ namespace SharpChat { lock(Lock) SecureRandom.GetBytes(buffer); } + + public const string ID_CHARS = @"abcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + public static string NextString(int length, string chars = ID_CHARS) { + byte[] buffer = new byte[length]; + NextBytes(buffer); + StringBuilder sb = new(); + foreach(byte b in buffer) + sb.Append(chars[b % chars.Length]); + return sb.ToString(); + } } } diff --git a/SharpChat.Common/RateLimiting/RateLimitManager.cs b/SharpChat.Common/RateLimiting/RateLimitManager.cs new file mode 100644 index 0000000..c147c94 --- /dev/null +++ b/SharpChat.Common/RateLimiting/RateLimitManager.cs @@ -0,0 +1,80 @@ +using SharpChat.Configuration; +using SharpChat.Protocol; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.RateLimiting { + public class RateLimitManager { + public const int DEFAULT_USER_SIZE = 15; + public const int DEFAULT_USER_WARN_SIZE = 10; + public const int DEFAULT_CONN_SIZE = 30; + public const int DEFAULT_MINIMUM_DELAY = 5000; + public const int DEFAULT_KICK_LENGTH = 5; + public const int DEFAULT_KICK_MULTIPLIER = 2; + + private CachedValue UserSizeValue { get; } + private CachedValue UserWarnSizeValue { get; } + private CachedValue ConnSizeValue { get; } + private CachedValue MinimumDelayValue { get; } + private CachedValue KickLengthValue { get; } + private CachedValue KickMultiplierValue { get; } + + private readonly object ConnectionsSync = new(); + private Dictionary Connections { get; } = new(); + + private readonly object UsersSync = new(); + private Dictionary Users { get; } = new(); + + public RateLimitManager(IConfig config) { + UserSizeValue = config.ReadCached(@"userSize", DEFAULT_USER_SIZE); + UserWarnSizeValue = config.ReadCached(@"userWarnSize", DEFAULT_USER_WARN_SIZE); + ConnSizeValue = config.ReadCached(@"connSize", DEFAULT_CONN_SIZE); + MinimumDelayValue = config.ReadCached(@"minDelay", DEFAULT_MINIMUM_DELAY); + KickLengthValue = config.ReadCached(@"kickLength", DEFAULT_KICK_LENGTH); + KickMultiplierValue = config.ReadCached(@"kickMultiplier", DEFAULT_KICK_MULTIPLIER); + } + + private RateLimiter CreateForConnection() { + return new RateLimiter( + ConnSizeValue, + -1, + MinimumDelayValue + ); + } + + private RateLimiter CreateForUser() { + return new RateLimiter( + UserSizeValue, + UserWarnSizeValue, + MinimumDelayValue + ); + } + + public TimeSpan GetKickLength(int kickCount) { + if(kickCount < 1) + kickCount = 1; + return TimeSpan.FromSeconds(KickLengthValue * (KickMultiplierValue * kickCount)); + } + + public bool UpdateConnection(IConnection conn) { + lock(ConnectionsSync) { + string connId = conn.ConnectionId; + if(!Connections.ContainsKey(connId)) + Connections[connId] = CreateForConnection(); + Connections[connId].Update(); + return Connections[connId].ShouldKick; + } + } + + public (bool kick, bool warn) UpdateUser(IUser user) { + lock(UsersSync) { + long userId = user.UserId; + if(!Users.ContainsKey(userId)) + Users[userId] = CreateForUser(); + Users[userId].Update(); + return (Users[userId].ShouldKick, Users[userId].ShouldWarn); + } + } + } +} diff --git a/SharpChat.Common/RateLimiting/RateLimiter.cs b/SharpChat.Common/RateLimiting/RateLimiter.cs new file mode 100644 index 0000000..7ecfe72 --- /dev/null +++ b/SharpChat.Common/RateLimiting/RateLimiter.cs @@ -0,0 +1,38 @@ +using System; + +namespace SharpChat.RateLimiting { + public class RateLimiter { + private int Size { get; } + private int WarnSize { get; } + private int MinimumDelay { get; } + private long[] Times { get; } + + public RateLimiter(int size, int warnSize, int minimumDelay) { + if(size < 3) + throw new ArgumentException(@"Size must be more than 1.", nameof(size)); + if(warnSize >= size && warnSize > 0) + throw new ArgumentException(@"Warning Size must be less than Size, or less than 0 to be disabled.", nameof(warnSize)); + if(minimumDelay < 1000) + throw new ArgumentException(@"Minimum Delay must be more than 999 milliseconds.", nameof(minimumDelay)); + Size = size; + WarnSize = warnSize; + MinimumDelay = minimumDelay; + Times = new long[Size]; + } + + private bool IsSeeding + => (Times[0] < 1 && Times[1] < 1); + + public bool ShouldKick + => !IsSeeding && Times[0] + MinimumDelay >= Times[Size - 1]; + + public bool ShouldWarn + => WarnSize > 0 && !IsSeeding && Times[0] + MinimumDelay >= Times[WarnSize - 1]; + + public void Update() { + for(int i = 0; i < Size - 1; ++i) + Times[i] = Times[i + 1]; + Times[Size - 1] = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + } + } +} diff --git a/SharpChat.Common/Reflection/ObjectConstructor.cs b/SharpChat.Common/Reflection/ObjectConstructor.cs new file mode 100644 index 0000000..b4d6c20 --- /dev/null +++ b/SharpChat.Common/Reflection/ObjectConstructor.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace SharpChat.Reflection { + public class ObjectConstructor + where TAttribute : ObjectConstructorAttribute + where TDefault : TObject { + private Dictionary Types { get; } = new(); + private bool AllowDefault { get; } + + public ObjectConstructor(bool allowDefault = true) { + AllowDefault = allowDefault; + Reload(); + } + + public void Reload() { + Types.Clear(); + IEnumerable asms = AppDomain.CurrentDomain.GetAssemblies(); + foreach(Assembly asm in asms) { + IEnumerable types = asm.GetExportedTypes(); + foreach(Type type in types) { + Attribute attr = type.GetCustomAttribute(typeof(TAttribute)); + if(attr is not null and ObjectConstructorAttribute oca) + Types.Add(oca.Name, type); + } + } + } + + public TObject Construct(string name, params object[] args) { + if(name == null) + throw new ArgumentNullException(name); + + Type type = !Types.ContainsKey(name) + ? (AllowDefault ? typeof(TDefault) : throw new ObjectConstructorObjectNotFoundException(name)) + : Types[name]; + + IEnumerable arguments = args; + IEnumerable types = arguments.Select(a => a.GetType()); + ConstructorInfo[] cis = type.GetConstructors(); + ConstructorInfo constructor = null; + + for(;;) { + foreach(ConstructorInfo ci in cis) { + IEnumerable constTypes = ci.GetParameters().Select(p => p.ParameterType); + if(constTypes.Count() != arguments.Count()) + continue; + + bool isMatch = true; + for(int i = 0; i < constTypes.Count(); ++i) + if(!types.ElementAt(i).IsAssignableTo(constTypes.ElementAt(i))) { + isMatch = false; + break; + } + + if(isMatch) { + constructor = ci; + break; + } + } + + if(constructor != null || !arguments.Any()) + break; + arguments = arguments.Take(arguments.Count() - 1); + types = types.Take(arguments.Count()); + } + + if(constructor == null) + throw new ObjectConstructorConstructorNotFoundException(name); + + return (TObject)constructor.Invoke(arguments.ToArray()); + } + } +} diff --git a/SharpChat.Common/Reflection/ObjectConstructorAttribute.cs b/SharpChat.Common/Reflection/ObjectConstructorAttribute.cs new file mode 100644 index 0000000..cee1cf5 --- /dev/null +++ b/SharpChat.Common/Reflection/ObjectConstructorAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace SharpChat.Reflection { + [AttributeUsage(AttributeTargets.Class)] + public abstract class ObjectConstructorAttribute : Attribute { + public string Name { get; } + + public ObjectConstructorAttribute(string name) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + } +} diff --git a/SharpChat.Common/Reflection/ObjectConstructorException.cs b/SharpChat.Common/Reflection/ObjectConstructorException.cs new file mode 100644 index 0000000..9b492a3 --- /dev/null +++ b/SharpChat.Common/Reflection/ObjectConstructorException.cs @@ -0,0 +1,16 @@ +using System; + +namespace SharpChat.Reflection { + public class ObjectConstructorException : Exception { + public ObjectConstructorException(string message) : base(message) { + } + } + + public class ObjectConstructorObjectNotFoundException : ObjectConstructorException { + public ObjectConstructorObjectNotFoundException(string name) : base($@"Object with name {name} not found.") { } + } + + public class ObjectConstructorConstructorNotFoundException : ObjectConstructorException { + public ObjectConstructorConstructorNotFoundException(string name) : base($@"Proper constructor for object {name} not found.") { } + } +} diff --git a/SharpChat.Common/Reflection/ReflectionUtilities.cs b/SharpChat.Common/Reflection/ReflectionUtilities.cs new file mode 100644 index 0000000..7fac259 --- /dev/null +++ b/SharpChat.Common/Reflection/ReflectionUtilities.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace SharpChat.Reflection { + public static class ReflectionUtilities { + public static void LoadAssemblies(string pattern) { + IEnumerable loaded = AppDomain.CurrentDomain.GetAssemblies().Select(a => Path.GetFullPath(a.Location)); + IEnumerable files = Directory.GetFiles(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), pattern); + foreach(string file in files) { + string fullPath = Path.GetFullPath(file); + if(!loaded.Contains(fullPath)) + Assembly.LoadFile(fullPath); + } + } + } +} diff --git a/SharpChat.Common/Sessions/ISession.cs b/SharpChat.Common/Sessions/ISession.cs new file mode 100644 index 0000000..9d4b526 --- /dev/null +++ b/SharpChat.Common/Sessions/ISession.cs @@ -0,0 +1,21 @@ +using SharpChat.Events; +using SharpChat.Protocol; +using SharpChat.Users; +using System; +using System.Net; + +namespace SharpChat.Sessions { + public interface ISession : IEventHandler, IEquatable { + string SessionId { get; } + string ServerId { get; } + bool IsSecure { get; } + + DateTimeOffset LastPing { get; } + ILocalUser User { get; } + bool IsConnected { get; } + IPAddress RemoteAddress { get; } + + IConnection Connection { get; set; } + bool HasConnection(IConnection conn); + } +} diff --git a/SharpChat.Common/Sessions/ISessionExtensions.cs b/SharpChat.Common/Sessions/ISessionExtensions.cs new file mode 100644 index 0000000..a80e656 --- /dev/null +++ b/SharpChat.Common/Sessions/ISessionExtensions.cs @@ -0,0 +1,8 @@ +using System; + +namespace SharpChat.Sessions { + public static class ISessionExtensions { + public static TimeSpan GetIdleTime(this ISession session) + => session.LastPing - DateTimeOffset.Now; + } +} diff --git a/SharpChat.Common/Sessions/Session.cs b/SharpChat.Common/Sessions/Session.cs new file mode 100644 index 0000000..6912af6 --- /dev/null +++ b/SharpChat.Common/Sessions/Session.cs @@ -0,0 +1,94 @@ +using SharpChat.Events; +using SharpChat.Protocol; +using SharpChat.Users; +using System; +using System.Net; + +namespace SharpChat.Sessions { + public class Session : ISession { + public const int ID_LENGTH = 32; + + public string SessionId { get; } + public string ServerId { get; private set; } + public bool IsSecure { get; } + + public DateTimeOffset LastPing { get; private set; } + public ILocalUser User { get; } + + public bool IsConnected { get; private set; } + public IPAddress RemoteAddress { get; private set; } + + private readonly object Sync = new(); + + public IConnection Connection { get; set; } + + private long LastEvent { get; set; } // use this to get a session back up to speed after reconnection + + public Session( + string serverId, + string sessionId, + bool isSecure, + DateTimeOffset? lastPing, + ILocalUser user, + bool isConnected, + IConnection connection, + IPAddress remoteAddress + ) { + ServerId = serverId ?? throw new ArgumentNullException(nameof(serverId)); + SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + IsSecure = isSecure; + LastPing = lastPing ?? DateTimeOffset.MinValue; + User = user; + IsConnected = isConnected; + Connection = connection; + RemoteAddress = remoteAddress ?? IPAddress.None; + } + + public bool HasConnection(IConnection conn) + => Connection == conn; + + public void BumpPing() + => LastPing = DateTimeOffset.Now; + + public bool Equals(ISession other) + => other != null && SessionId.Equals(other.SessionId); + + public override string ToString() + => $@"S#{ServerId}#{SessionId}"; + + public void HandleEvent(object sender, IEvent evt) { + lock(Sync) { + switch(evt) { + case SessionPingEvent spe: + LastPing = spe.DateTime; + break; + case SessionSuspendEvent _: + IsConnected = false; + Connection = null; + RemoteAddress = IPAddress.None; + ServerId = string.Empty; + LastPing = DateTimeOffset.Now; + break; + case SessionResumeEvent sre: + IsConnected = true; + RemoteAddress = sre.RemoteAddress; + ServerId = sre.ServerId; + LastPing = DateTimeOffset.Now; // yes? + break; + case SessionDestroyEvent _: + IsConnected = false; + LastPing = DateTimeOffset.MinValue; + break; + /*case SessionResumeEvent _: + while(PacketQueue.TryDequeue(out IServerPacket packet)) + SendPacket(packet); + PacketQueue = null; + break;*/ + } + + if(Connection != null) + LastEvent = evt.EventId; + } + } + } +} diff --git a/SharpChat.Common/Sessions/SessionManager.cs b/SharpChat.Common/Sessions/SessionManager.cs new file mode 100644 index 0000000..ec5e575 --- /dev/null +++ b/SharpChat.Common/Sessions/SessionManager.cs @@ -0,0 +1,382 @@ +using SharpChat.Channels; +using SharpChat.Configuration; +using SharpChat.Events; +using SharpChat.Protocol; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace SharpChat.Sessions { + public class SessionManager : IEventHandler { + public const short DEFAULT_MAX_COUNT = 5; + public const ushort DEFAULT_TIMEOUT = 5; + + private readonly object Sync = new(); + + private CachedValue MaxPerUser { get; } + private CachedValue TimeOut { get; } + + private IEventDispatcher Dispatcher { get; } + private string ServerId { get; } + + private UserManager Users { get; } + + private List Sessions { get; } = new(); + private List LocalSessions { get; } = new(); + + public SessionManager(IEventDispatcher dispatcher, UserManager users, IConfig config, string serverId) { + if(config == null) + throw new ArgumentNullException(nameof(config)); + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + Users = users ?? throw new ArgumentNullException(nameof(users)); + ServerId = serverId ?? throw new ArgumentNullException(nameof(serverId)); + MaxPerUser = config.ReadCached(@"maxCount", DEFAULT_MAX_COUNT); + TimeOut = config.ReadCached(@"timeOut", DEFAULT_TIMEOUT); + } + + public bool HasTimedOut(ISession session) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + int timeOut = TimeOut; + if(timeOut < 1) // avoid idiocy + timeOut = DEFAULT_TIMEOUT; + return session.GetIdleTime().TotalSeconds >= timeOut; + } + + public void GetSession(Func predicate, Action callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + ISession session = Sessions.FirstOrDefault(predicate); + if(session == null) + return; + callback(session); + } + } + + public void GetSession(string sessionId, Action callback) { + if(sessionId == null) + throw new ArgumentNullException(nameof(sessionId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(sessionId)) { + callback(null); + return; + } + GetSession(s => sessionId.Equals(s.SessionId), callback); + } + + public void GetSession(ISession session, Action callback) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + // Check if we have a local session + if(session is Session && LocalSessions.Contains(session)) { + callback(session); + return; + } + + // Check if we're already an instance + if(Sessions.Contains(session)) { + callback(session); + return; + } + + // Finde + GetSession(session.Equals, callback); + } + } + + public void GetLocalSession(Func predicate, Action callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(LocalSessions.FirstOrDefault(predicate)); + } + + public void GetLocalSession(ISession session, Action callback) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + if(session is Session && LocalSessions.Contains(session)) { + callback(session); + return; + } + + GetLocalSession(session.Equals, callback); + } + } + + public void GetLocalSession(string sessionId, Action callback) { + if(sessionId == null) + throw new ArgumentNullException(nameof(sessionId)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSession(s => sessionId.Equals(s.SessionId), callback); + } + + public void GetLocalSession(IConnection conn, Action callback) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSession(s => s.HasConnection(conn), callback); + } + + public void GetSessions(Func predicate, Action> callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Sessions.Where(predicate)); + } + + public void GetSessions(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetSessions(s => user.Equals(s.User), callback); + } + + public void GetLocalSessions(Func predicate, Action> callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(LocalSessions.Where(predicate)); + } + + public void GetLocalSessions(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSessions(s => user.Equals(s.User), callback); + } + + public void GetLocalSessionsByUserId(long userId, Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSessions(s => s.User.UserId == userId, callback); + } + + public void GetLocalSessions(IEnumerable sessionIds, Action> callback) { + if(sessionIds == null) + throw new ArgumentNullException(nameof(sessionIds)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(!sessionIds.Any()) { + callback(Enumerable.Empty()); + return; + } + GetLocalSessions(s => sessionIds.Contains(s.SessionId), callback); + } + + // i wonder what i'll think about this after sleeping a night on it + // perhaps stick active sessions with the master User implementation again transparently. + // session startups should probably be events as well + public void GetSessions(IEnumerable users, Action> callback) { + if(users == null) + throw new ArgumentNullException(nameof(users)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetSessions(s => users.Any(s.User.Equals), callback); + } + + public void GetLocalSessions(IEnumerable users, Action> callback) { + if(users == null) + throw new ArgumentNullException(nameof(users)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSessions(s => users.Any(s.User.Equals), callback); + } + + public void GetActiveSessions(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetSessions(s => !HasTimedOut(s), callback); + } + + public void GetActiveLocalSessions(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSessions(s => !HasTimedOut(s), callback); + } + + public void GetDeadLocalSessions(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetLocalSessions(HasTimedOut, callback); + } + + public void Create(IConnection conn, ILocalUser user, Action callback) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + if(user == null) + throw new ArgumentNullException(nameof(user)); + + Session sess = null; + + lock(Sync) { + sess = new Session(ServerId, RNG.NextString(Session.ID_LENGTH), conn.IsSecure, null, user, true, conn, conn.RemoteAddress); + LocalSessions.Add(sess); + Sessions.Add(sess); + } + + Dispatcher.DispatchEvent(this, new SessionCreatedEvent(sess)); + callback(sess); + } + + public void DoKeepAlive(ISession session) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + + lock(Sync) + Dispatcher.DispatchEvent(this, new SessionPingEvent(session)); + } + + public void SwitchChannel(ISession session, IChannel channel = null) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + + lock(Sync) + Dispatcher.DispatchEvent(this, new SessionChannelSwitchEvent(channel, session)); + } + + public void Destroy(IConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + + lock(Sync) + GetLocalSession(conn, session => { + if(session == null) + return; + if(session is Session ls) + LocalSessions.Remove(ls); + Dispatcher.DispatchEvent(this, new SessionDestroyEvent(session)); + }); + } + + public void Destroy(ISession session) { + if(session == null) + throw new ArgumentNullException(nameof(session)); + + lock(Sync) + GetSession(session, session => { + if(session is Session ls) + LocalSessions.Remove(ls); + + Dispatcher.DispatchEvent(this, new SessionDestroyEvent(session)); + }); + } + + public void HasSessions(IUser user, Action callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Sessions.Any(s => user.Equals(s.User))); + } + + public void GetSessionCount(IUser user, Action callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Sessions.Count(s => user.Equals(s.User))); + } + + public void GetAvailableSessionCount(IUser user, Action callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetSessionCount(user, sessionCount => callback(MaxPerUser - sessionCount)); + } + + public void HasAvailableSessions(IUser user, Action callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetAvailableSessionCount(user, availableSessionCount => callback(availableSessionCount > 0)); + } + + public void GetRemoteAddresses(IUser user, Action> callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + GetActiveSessions(sessions => { + callback(sessions + .Where(s => user.Equals(s.User)) + .OrderByDescending(s => s.LastPing) + .Select(s => s.RemoteAddress) + .Distinct()); + }); + } + + public void CheckTimeOut() { + GetDeadLocalSessions(sessions => { + if(sessions?.Any() != true) + return; + + Queue murder = new(sessions); + while(murder.TryDequeue(out ISession session)) + Destroy(session); + }); + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case SessionChannelSwitchEvent _: + case SessionPingEvent _: + case SessionResumeEvent _: + case SessionSuspendEvent _: + GetSession(evt.SessionId, session => session?.HandleEvent(sender, evt)); + break; + + case SessionCreatedEvent sce: + if(ServerId.Equals(sce.ServerId)) // we created the session + break; + + lock(Sync) + Users.GetUser(sce.UserId, user => { + if(user != null) // if we get here and there's no user we've either hit a race condition or we're out of sync somehow + Sessions.Add(new Session(sce.ServerId, sce.SessionId, sce.IsSecure, sce.LastPing, user, sce.IsConnected, null, sce.RemoteAddress)); + }); + break; + + case SessionDestroyEvent sde: + GetSession(sde.SessionId, session => { + Sessions.Remove(session); + session.HandleEvent(sender, sde); + }); + break; + + /*case UserDisconnectEvent ude: + GetLocalSessionsByUserId(ude.UserId, sessions => { + foreach(ISession session in sessions) + session.HandleEvent(sender, ude); + }); + break;*/ + } + } + } +} diff --git a/SharpChat.Common/SharpChat.Common.csproj b/SharpChat.Common/SharpChat.Common.csproj new file mode 100644 index 0000000..b4e2895 --- /dev/null +++ b/SharpChat.Common/SharpChat.Common.csproj @@ -0,0 +1,24 @@ + + + + net5.0 + SharpChat + + + + + + + + + + + + + + + + + + + diff --git a/SharpChat.Common/SharpId.cs b/SharpChat.Common/SharpId.cs new file mode 100644 index 0000000..e4b3b44 --- /dev/null +++ b/SharpChat.Common/SharpId.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading; + +namespace SharpChat { + public static class SharpId { + private const long EPOCH = 1588377600000; + private static int Counter = 0; + + public static long Next() + => ((DateTimeOffset.Now.ToUnixTimeMilliseconds() - EPOCH) << 8) + | (ushort)(Interlocked.Increment(ref Counter) & 0xFFFF); + } +} diff --git a/SharpChat.Common/SharpInfo.cs b/SharpChat.Common/SharpInfo.cs new file mode 100644 index 0000000..a7d32c9 --- /dev/null +++ b/SharpChat.Common/SharpInfo.cs @@ -0,0 +1,37 @@ +using System.IO; +using System.Reflection; +using System.Text; + +namespace SharpChat { + public static class SharpInfo { + private const string NAME = @"SharpChat"; + private const string UNKNOWN = @"???????"; + + public static string VersionString { get; } + public static string VersionStringShort { get; } + public static bool IsDebugBuild { get; } + + public static string ProgramName { get; } + + static SharpInfo() { +#if DEBUG + IsDebugBuild = true; +#endif + + try { + using Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(@"SharpChat.version.txt"); + using StreamReader sr = new(s); + VersionString = sr.ReadLine(); + VersionStringShort = IsDebugBuild ? VersionString.Substring(0, 7) : VersionString; + } catch { + VersionStringShort = VersionString = UNKNOWN; + } + + StringBuilder sb = new(); + sb.Append(NAME); + sb.Append('/'); + sb.Append(VersionStringShort); + ProgramName = sb.ToString(); + } + } +} diff --git a/SharpChat.Common/Users/ChatBot.cs b/SharpChat.Common/Users/ChatBot.cs new file mode 100644 index 0000000..2f8fc42 --- /dev/null +++ b/SharpChat.Common/Users/ChatBot.cs @@ -0,0 +1,18 @@ +namespace SharpChat.Users { + public class ChatBot : IUser { + public long UserId { get; } = -1; + public string UserName { get; } = @"ChatBot"; + public Colour Colour { get; } = new(0x9E8DA7); + public int Rank { get; } = 0; + public string NickName => null; + public UserPermissions Permissions { get; } = (UserPermissions)(-1); + public UserStatus Status => UserStatus.Online; + public string StatusMessage => string.Empty; + + public bool Equals(IUser other) + => other != null && (other is ChatBot || other.UserId == UserId); + + public override string ToString() + => @""; + } +} diff --git a/SharpChat.Common/Users/ILocalUser.cs b/SharpChat.Common/Users/ILocalUser.cs new file mode 100644 index 0000000..3e5030a --- /dev/null +++ b/SharpChat.Common/Users/ILocalUser.cs @@ -0,0 +1,20 @@ +using System; + +namespace SharpChat.Users { + public interface ILocalUser : IUser, IEquatable { + /// + /// Temporary alternate display name for the user + /// + string NickName { get; } + + /// + /// Current presence status of the user + /// + UserStatus Status { get; } + + /// + /// Current presence message of the user + /// + string StatusMessage { get; } + } +} diff --git a/SharpChat.Common/Users/IUser.cs b/SharpChat.Common/Users/IUser.cs new file mode 100644 index 0000000..1ce3a16 --- /dev/null +++ b/SharpChat.Common/Users/IUser.cs @@ -0,0 +1,30 @@ +using System; + +namespace SharpChat.Users { + public interface IUser : IEquatable { + /// + /// Unique ID of the user + /// + long UserId { get; } + + /// + /// Default unique name of the user + /// + string UserName { get; } + + /// + /// Display colour of the user + /// + Colour Colour { get; } + + /// + /// Hierarchical rank of the user + /// + int Rank { get; } + + /// + /// Permissions the user has + /// + UserPermissions Permissions { get; } + } +} diff --git a/SharpChat.Common/Users/IUserExtensions.cs b/SharpChat.Common/Users/IUserExtensions.cs new file mode 100644 index 0000000..3fb9468 --- /dev/null +++ b/SharpChat.Common/Users/IUserExtensions.cs @@ -0,0 +1,9 @@ +namespace SharpChat.Users { + public static class IUserExtensions { + public static bool IsBot(this IUser user) + => user is ChatBot || user?.UserId == -1; + + public static bool Can(this IUser user, UserPermissions perm) + => user.IsBot() || (user.Permissions & perm) == perm; + } +} diff --git a/SharpChat.Common/Users/Remote/IRemoteUser.cs b/SharpChat.Common/Users/Remote/IRemoteUser.cs new file mode 100644 index 0000000..de19e1a --- /dev/null +++ b/SharpChat.Common/Users/Remote/IRemoteUser.cs @@ -0,0 +1,6 @@ +using System; + +namespace SharpChat.Users.Remote { + public interface IRemoteUser : IUser, IEquatable { + } +} diff --git a/SharpChat.Common/Users/Remote/IRemoteUserClient.cs b/SharpChat.Common/Users/Remote/IRemoteUserClient.cs new file mode 100644 index 0000000..c88acf0 --- /dev/null +++ b/SharpChat.Common/Users/Remote/IRemoteUserClient.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace SharpChat.Users.Remote { + public interface IRemoteUserClient { + void AuthenticateUser(UserAuthRequest request, Action onSuccess, Action onFailure); + void BumpUsers(IEnumerable users, Action onSuccess, Action onFailure); + void ResolveUser(long userId, Action onSuccess, Action onFailure); + void ResolveUser(string userName, Action onSuccess, Action onFailure); + void ResolveUser(IUser localUser, Action onSuccess, Action onFailure); + } +} diff --git a/SharpChat.Common/Users/Remote/IUserAuthResponse.cs b/SharpChat.Common/Users/Remote/IUserAuthResponse.cs new file mode 100644 index 0000000..1bcdcd2 --- /dev/null +++ b/SharpChat.Common/Users/Remote/IUserAuthResponse.cs @@ -0,0 +1,7 @@ +using System; + +namespace SharpChat.Users.Remote { + public interface IUserAuthResponse : IRemoteUser { + DateTimeOffset SilencedUntil { get; } + } +} diff --git a/SharpChat.Common/Users/Remote/UserAuthFailedException.cs b/SharpChat.Common/Users/Remote/UserAuthFailedException.cs new file mode 100644 index 0000000..0d7f05b --- /dev/null +++ b/SharpChat.Common/Users/Remote/UserAuthFailedException.cs @@ -0,0 +1,7 @@ +using System; + +namespace SharpChat.Users.Remote { + public class UserAuthFailedException : Exception { + public UserAuthFailedException(string reason) : base(reason) { } + } +} diff --git a/SharpChat.Common/Users/Remote/UserAuthRequest.cs b/SharpChat.Common/Users/Remote/UserAuthRequest.cs new file mode 100644 index 0000000..213d7db --- /dev/null +++ b/SharpChat.Common/Users/Remote/UserAuthRequest.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Users.Remote { + public class UserAuthRequest { + public long UserId { get; } + public string Token { get; } + public IPAddress RemoteAddress { get; } + + public UserAuthRequest(long userId, string token, IPAddress remoteAddress) { + UserId = userId; + Token = token; + RemoteAddress = remoteAddress; + } + } +} diff --git a/SharpChat.Common/Users/Remote/UserBumpInfo.cs b/SharpChat.Common/Users/Remote/UserBumpInfo.cs new file mode 100644 index 0000000..ca4e812 --- /dev/null +++ b/SharpChat.Common/Users/Remote/UserBumpInfo.cs @@ -0,0 +1,15 @@ +using SharpChat.Sessions; +using System; +using System.Collections.Generic; + +namespace SharpChat.Users.Remote { + public class UserBumpInfo { + public IUser User { get; } + public IEnumerable Sessions { get; } + + public UserBumpInfo(IUser user, IEnumerable sessions) { + User = user ?? throw new ArgumentNullException(nameof(user)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + } +} diff --git a/SharpChat.Common/Users/User.cs b/SharpChat.Common/Users/User.cs new file mode 100644 index 0000000..f50950d --- /dev/null +++ b/SharpChat.Common/Users/User.cs @@ -0,0 +1,112 @@ +using SharpChat.Channels; +using SharpChat.Events; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; + +namespace SharpChat.Users { + public class User : ILocalUser, IEventHandler { + public long UserId { get; } + public string UserName { get; private set; } + public Colour Colour { get; private set; } + public int Rank { get; private set; } + public string NickName { get; private set; } + public UserPermissions Permissions { get; private set; } + public UserStatus Status { get; private set; } = UserStatus.Online; + public string StatusMessage { get; private set; } + + private readonly object Sync = new(); + private HashSet Channels { get; } = new(); + + public User( + long userId, + string userName, + Colour colour, + int rank, + UserPermissions perms, + UserStatus status, + string statusMessage, + string nickName + ) { + UserId = userId; + UserName = userName ?? throw new ArgumentNullException(nameof(userName)); + Colour = colour; + Rank = rank; + Permissions = perms; + Status = status; + StatusMessage = statusMessage ?? string.Empty; + NickName = nickName ?? string.Empty; + } + + public User(IUserAuthResponse auth) { + UserId = auth.UserId; + ApplyAuth(auth); + } + + public void ApplyAuth(IUserAuthResponse auth) { + UserName = auth.UserName; + + if(Status == UserStatus.Offline) + Status = UserStatus.Online; + + Colour = auth.Colour; + Rank = auth.Rank; + Permissions = auth.Permissions; + } + + public override string ToString() + => $@""; + + public bool Equals(IUser other) + => other != null && other.UserId == UserId; + public bool Equals(ILocalUser other) + => other != null && other.UserId == UserId; + + public bool HasChannel(IChannel channel) { + if(channel == null) + return false; + lock(Sync) + return Channels.Contains(channel.ChannelId); + } + + public void GetChannels(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback.Invoke(Channels); + } + + public void HandleEvent(object sender, IEvent evt) { + lock(Sync) { + switch(evt) { + case ChannelUserJoinEvent cje: + Channels.Add(evt.ChannelId); + break; + case ChannelUserLeaveEvent cle: + Channels.Remove(evt.ChannelId); + break; + + case UserUpdateEvent uue: + if(uue.HasUserName) + UserName = uue.NewUserName; + if(uue.NewColour.HasValue) + Colour = uue.NewColour.Value; + if(uue.NewRank.HasValue) + Rank = uue.NewRank.Value; + if(uue.HasNickName) + NickName = uue.NewNickName; + if(uue.NewPerms.HasValue) + Permissions = uue.NewPerms.Value; + if(uue.NewStatus.HasValue) + Status = uue.NewStatus.Value; + if(uue.HasStatusMessage) + StatusMessage = uue.NewStatusMessage; + break; + case UserDisconnectEvent _: + Status = UserStatus.Offline; + break; + } + } + } + } +} diff --git a/SharpChat.Common/Users/UserDisconnectReason.cs b/SharpChat.Common/Users/UserDisconnectReason.cs new file mode 100644 index 0000000..6dec0fb --- /dev/null +++ b/SharpChat.Common/Users/UserDisconnectReason.cs @@ -0,0 +1,9 @@ +namespace SharpChat.Users { + public enum UserDisconnectReason : int { + Unknown = 0, + Leave = 1, + TimeOut = 2, + Kicked = 3, + Flood = 4, + } +} diff --git a/SharpChat.Common/Users/UserManager.cs b/SharpChat.Common/Users/UserManager.cs new file mode 100644 index 0000000..ce65f3f --- /dev/null +++ b/SharpChat.Common/Users/UserManager.cs @@ -0,0 +1,274 @@ +using SharpChat.Events; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Users { + public class UserManager : IEventHandler { + private HashSet Users { get; } = new(); + private IEventDispatcher Dispatcher { get; } + private readonly object Sync = new(); + + public UserManager(IEventDispatcher dispatcher) { + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + private void OnConnect(object sender, UserConnectEvent uce) { + if(sender == this) + return; + + lock(Sync) { + if(Users.Any(u => u.UserId == uce.UserId)) + throw new ArgumentException(@"User already registered?????", nameof(uce)); + + Users.Add(new User( + uce.UserId, + uce.Name, + uce.Colour, + uce.Rank, + uce.Permissions, + uce.Status, + uce.StatusMessage, + uce.NickName + )); + } + } + + public void Disconnect(IUser user, UserDisconnectReason reason = UserDisconnectReason.Unknown) { + if(user == null) + return; + lock(Sync) + if(Users.Contains(user)) + Dispatcher.DispatchEvent(this, new UserDisconnectEvent(user, reason)); + } + + public void Disconnect(IRemoteUser remoteUser, UserDisconnectReason reason = UserDisconnectReason.Unknown) { + if(remoteUser == null) + return; + GetUser(remoteUser, user => Disconnect(user, reason)); + } + + private void OnDisconnect(object sender, UserDisconnectEvent ude) { + GetUser(ude.UserId, user => { + if(user == null) + return; + if(user is IEventHandler ueh) + ueh.HandleEvent(sender, ude); + }); + } + + private void OnUpdate(object sender, UserUpdateEvent uue) { + GetUser(uue.UserId, user => { + if(user == null) + return; + if(user is IEventHandler ueh) + ueh.HandleEvent(sender, uue); + }); + } + + public void GetUser(Func predicate, Action callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Users.FirstOrDefault(predicate)); + } + + public void GetUser(long userId, Action callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetUser(u => u.UserId == userId, callback); + } + + public void GetUser(IUser user, Action callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetUser(user.Equals, callback); + } + + public void GetUser(IRemoteUser remoteUser, Action callback) { + if(remoteUser == null) + throw new ArgumentNullException(nameof(remoteUser)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetUser(u => u.UserId == remoteUser.UserId, callback); + } + + [Flags] + public enum NameLookUpMode { + UserName = 0x1, + NickName = 0x2, + + UserNameAndNickName = UserName | NickName, + } + + public void GetUser(string userName, Action callback, NameLookUpMode lookUpMode = NameLookUpMode.UserNameAndNickName) { + if(userName == null) + throw new ArgumentNullException(nameof(userName)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + if(string.IsNullOrWhiteSpace(userName)) { + callback(null); + return; + } + + Func predicate = null; + if((lookUpMode & NameLookUpMode.UserNameAndNickName) == NameLookUpMode.UserNameAndNickName) + predicate = new Func(u => userName.Equals(u.UserName, StringComparison.InvariantCultureIgnoreCase) || userName.Equals(u.NickName, StringComparison.InvariantCultureIgnoreCase)); + else if((lookUpMode & NameLookUpMode.UserName) == NameLookUpMode.UserName) + predicate = new Func(u => userName.Equals(u.UserName, StringComparison.InvariantCultureIgnoreCase)); + else if((lookUpMode & NameLookUpMode.NickName) == NameLookUpMode.NickName) + predicate = new Func(u => userName.Equals(u.NickName, StringComparison.InvariantCultureIgnoreCase)); + + if(predicate == null) { + callback(null); + return; + } + + GetUser(predicate, callback); + } + + public void GetUsers(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Users); + } + + public void GetUsers(Func predicate, Action> callback) { + if(predicate == null) + throw new ArgumentNullException(nameof(predicate)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Users.Where(predicate)); + } + + public void GetUsers(int minRank, Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetUsers(u => u.Rank >= minRank, callback); + } + + public void GetUsers(IEnumerable ids, Action> callback) { + if(ids == null) + throw new ArgumentNullException(nameof(ids)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + GetUsers(u => ids.Contains(u.UserId), callback); + } + + public void Connect(IUserAuthResponse uar, Action callback) { + if(uar == null) + throw new ArgumentNullException(nameof(uar)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + + lock(Sync) { + GetUser(uar.UserId, user => { + if(user == null) + Create(uar.UserId, uar.UserName, uar.Colour, uar.Rank, uar.Permissions, callback: callback); + else { + Update(user, uar.UserName, uar.Colour, uar.Rank, uar.Permissions); + callback(user); + } + }); + } + } + + public void Create( + long userId, + string userName, + Colour colour, + int rank, + UserPermissions perms, + UserStatus status = UserStatus.Online, + string statusMessage = null, + string nickName = null, + Action callback = null + ) { + if(userName == null) + throw new ArgumentNullException(nameof(userName)); + + lock(Sync) { + User user = new(userId, userName, colour, rank, perms, status, statusMessage, nickName); + Users.Add(user); + Dispatcher.DispatchEvent(this, new UserConnectEvent(user)); + callback?.Invoke(user); + } + } + + public void Update( + ILocalUser user, + string userName = null, + Colour? colour = null, + int? rank = null, + UserPermissions? perms = null, + UserStatus? status = null, + string statusMessage = null, + string nickName = null + ) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + + lock(Sync) { + if(userName != null && user.UserName == userName) + userName = null; + + if(colour.HasValue && user.Colour.Equals(colour)) + colour = null; + + if(rank.HasValue && user.Rank == rank.Value) + rank = null; + + if(nickName != null) { + string prevNickName = user.NickName ?? string.Empty; + + if(nickName == prevNickName) { + nickName = null; + } else { + string nextUserName = userName ?? user.UserName; + if(nickName == nextUserName) { + nickName = null; + } else { + // cleanup + } + } + } + + if(perms.HasValue && user.Permissions == perms.Value) + perms = null; + + if(status.HasValue && user.Status == status.Value) + status = null; + + if(statusMessage != null && user.StatusMessage == statusMessage) { + statusMessage = null; + } else { + // cleanup + } + + Dispatcher.DispatchEvent(this, new UserUpdateEvent(user, userName, colour, rank, nickName, perms, status, statusMessage)); + } + } + + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case UserConnectEvent uce: + OnConnect(sender, uce); + break; + case UserUpdateEvent uue: + OnUpdate(sender, uue); + break; + case UserDisconnectEvent ude: + OnDisconnect(sender, ude); + break; + } + } + } +} diff --git a/SharpChat/ChatUserPermissions.cs b/SharpChat.Common/Users/UserPermissions.cs similarity index 92% rename from SharpChat/ChatUserPermissions.cs rename to SharpChat.Common/Users/UserPermissions.cs index 49901e1..03bbd8e 100644 --- a/SharpChat/ChatUserPermissions.cs +++ b/SharpChat.Common/Users/UserPermissions.cs @@ -1,8 +1,8 @@ using System; -namespace SharpChat { +namespace SharpChat.Users { [Flags] - public enum ChatUserPermissions : int { + public enum UserPermissions : int { KickUser = 0x00000001, BanUser = 0x00000002, SilenceUser = 0x00000004, diff --git a/SharpChat.Common/Users/UserStatus.cs b/SharpChat.Common/Users/UserStatus.cs new file mode 100644 index 0000000..6e1fe24 --- /dev/null +++ b/SharpChat.Common/Users/UserStatus.cs @@ -0,0 +1,8 @@ +namespace SharpChat.Users { + public enum UserStatus : int { + Unknown = -1, + Offline = 0, + Away = 1, + Online = 2, + } +} diff --git a/SharpChat.Common/WelcomeMessage.cs b/SharpChat.Common/WelcomeMessage.cs new file mode 100644 index 0000000..13b99d2 --- /dev/null +++ b/SharpChat.Common/WelcomeMessage.cs @@ -0,0 +1,60 @@ +using SharpChat.Configuration; +using System; +using System.IO; +using System.Linq; + +namespace SharpChat { + public class WelcomeMessage { + private CachedValue RandomFile { get; } + + private TimeSpan CacheLife { get; } = TimeSpan.FromMinutes(10); + private bool HasRandomValue { get; set; } + private DateTimeOffset LastRandomRead { get; set; } = DateTimeOffset.MinValue; + + private readonly object Sync = new(); + + public bool HasRandom { + get { + lock(Sync) { + if(DateTimeOffset.Now - LastRandomRead >= CacheLife) + ReloadRandomFile(); + + return HasRandomValue; + } + } + } + + private string[] RandomStrings { get; set; } + + public WelcomeMessage(IConfig config) { + if(config == null) + throw new ArgumentNullException(nameof(config)); + + RandomFile = config.ReadCached(@"random", string.Empty, CacheLife); + } + + public void ReloadRandomFile() { + lock(Sync) { + string randomFile = RandomFile; + + if(File.Exists(randomFile)) { + RandomStrings = File.ReadAllLines(randomFile).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); + HasRandomValue = RandomStrings.Length > 0; + } else { + HasRandomValue = false; + RandomStrings = null; + } + + HasRandomValue = RandomStrings?.Length > 0; + LastRandomRead = DateTimeOffset.Now; + } + } + + public string GetRandomString() { + lock(Sync) + return HasRandom + ? RandomStrings.ElementAtOrDefault(RNG.Next(RandomStrings.Length)) + : string.Empty; + } + } +} diff --git a/SharpChat.DataProvider.Misuzu/Bans/MisuzuBanClient.cs b/SharpChat.DataProvider.Misuzu/Bans/MisuzuBanClient.cs new file mode 100644 index 0000000..f7cb4db --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Bans/MisuzuBanClient.cs @@ -0,0 +1,103 @@ +using Hamakaze; +using SharpChat.Bans; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using System.Web; + +namespace SharpChat.DataProvider.Misuzu.Bans { + public class MisuzuBanClient : IBanClient { + private const string STRING = @"givemethebeans"; + + private MisuzuDataProvider DataProvider { get; } + private HttpClient HttpClient { get; } + + private const string URL = @"/bans"; + private const string URL_CHECK = URL + @"/check"; + private const string URL_CREATE = URL + @"/create"; + private const string URL_REMOVE = URL + @"/remove"; + + public MisuzuBanClient(MisuzuDataProvider dataProvider, HttpClient httpClient) { + DataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider)); + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public void GetBanList(Action> onSuccess, Action onFailure) { + HttpRequestMessage req = new(HttpRequestMessage.GET, DataProvider.GetURL(URL)); + req.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(STRING)); + + HttpClient.SendRequest( + req, + onComplete: (t, r) => onSuccess.Invoke(JsonSerializer.Deserialize>(r.GetBodyBytes())), + (t, e) => { Logger.Debug(e); onFailure?.Invoke(e); } + ); + } + + public void CheckBan(IRemoteUser subject, IPAddress ipAddress, Action onSuccess, Action onFailure) { + HttpRequestMessage req = new( + HttpRequestMessage.GET, + string.Format(@"{0}?a={1}&u={2}", DataProvider.GetURL(URL_CHECK), ipAddress, subject.UserId) + ); + req.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(string.Format(@"check#{0}#{1}", ipAddress, subject.UserId))); + + HttpClient.SendRequest( + req, + (t, r) => { + try { + onSuccess.Invoke(JsonSerializer.Deserialize(r.GetBodyBytes())); + } catch(Exception ex) { + Logger.Debug(ex); + onFailure.Invoke(ex); + } + }, + (t, e) => { Logger.Debug(e); onFailure?.Invoke(e); } + ); + } + + public void CreateBan(IRemoteUser subject, IRemoteUser moderator, bool perma, TimeSpan duration, string reason, Action onSuccess, Action onFailure) { + reason ??= string.Empty; + long modId = moderator?.UserId ?? DataProvider.ActorId; + + HttpRequestMessage req = new(HttpRequestMessage.POST, DataProvider.GetURL(URL_CREATE)); + req.SetHeader(@"Content-Type", @"application/x-www-form-urlencoded"); + req.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(string.Format( + @"create#{0}#{1}#{2}#{3}#{4}", + subject.UserId, modId, duration.TotalSeconds, perma ? '1' : '0', reason + ))); + req.SetBody(string.Format( + @"u={0}&m={1}&d={2}&p={3}&r={4}", + subject.UserId, modId, duration.TotalSeconds, perma ? '1' : '0', HttpUtility.UrlEncode(reason) + )); + + HttpClient.SendRequest( + req, + (t, r) => onSuccess?.Invoke(r.StatusCode == 201), + (t, e) => { Logger.Debug(e); onFailure?.Invoke(e); } + ); + } + + public void RemoveBan(IRemoteUser subject, Action onSuccess, Action onFailure) { + RemoveBan(@"user", subject.UserId, onSuccess, onFailure); + } + + public void RemoveBan(IPAddress ipAddress, Action onSuccess, Action onFailure) { + RemoveBan(@"ip", ipAddress, onSuccess, onFailure); + } + + private void RemoveBan(string type, object param, Action onSuccess, Action onFailure) { + HttpRequestMessage req = new( + HttpRequestMessage.DELETE, + string.Format(@"{0}?t={1}&s={2}", DataProvider.GetURL(URL_REMOVE), type, param) + ); + req.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(string.Format(@"remove#{0}#{1}", type, param))); + + HttpClient.SendRequest( + req, + (t, r) => onSuccess.Invoke(r.StatusCode == 204), + (t, e) => { Logger.Debug(e); onFailure?.Invoke(e); } + ); + } + } +} diff --git a/SharpChat.DataProvider.Misuzu/Bans/MisuzuBanRecord.cs b/SharpChat.DataProvider.Misuzu/Bans/MisuzuBanRecord.cs new file mode 100644 index 0000000..77ad795 --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Bans/MisuzuBanRecord.cs @@ -0,0 +1,21 @@ +using SharpChat.Bans; +using SharpChat.DataProvider.Misuzu.Users; +using System; +using System.Net; +using System.Text.Json.Serialization; + +namespace SharpChat.DataProvider.Misuzu.Bans { + public class MisuzuBanRecord : MisuzuUser, IBanRecord { + [JsonIgnore] + public IPAddress UserIP => IPAddress.Parse(UserIPString); + + [JsonPropertyName(@"ip")] + public string UserIPString { get; set; } + + [JsonPropertyName(@"expires")] + public DateTimeOffset Expires { get; set; } + + [JsonPropertyName(@"is_permanent")] + public bool IsPermanent { get; set; } + } +} diff --git a/SharpChat.DataProvider.Misuzu/MisuzuDataProvider.cs b/SharpChat.DataProvider.Misuzu/MisuzuDataProvider.cs new file mode 100644 index 0000000..94e6d55 --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/MisuzuDataProvider.cs @@ -0,0 +1,61 @@ +using Hamakaze; +using SharpChat.Bans; +using SharpChat.Configuration; +using SharpChat.DataProvider.Misuzu.Bans; +using SharpChat.DataProvider.Misuzu.Users; +using SharpChat.Users.Remote; +using System; +using System.Security.Cryptography; +using System.Text; + +namespace SharpChat.DataProvider.Misuzu { + [DataProvider(@"misuzu")] + public class MisuzuDataProvider : IDataProvider { + private HttpClient HttpClient { get; } + private IConfig Config { get; } + + private CachedValue SecretKey { get; } + private CachedValue BaseURL { get; } + private CachedValue ActorIdValue { get; } + + public IBanClient BanClient { get; } + public IRemoteUserClient UserClient { get; } + + private const string DEFAULT_SECRET = @"woomy"; + + public long ActorId => ActorIdValue; + + public MisuzuDataProvider(IConfig config, HttpClient httpClient) { + Config = config ?? throw new ArgumentNullException(nameof(config)); + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + + SecretKey = Config.ReadCached(@"secret", DEFAULT_SECRET, TimeSpan.FromMinutes(1)); + BaseURL = Config.ReadCached(@"endpoint", string.Empty, TimeSpan.FromMinutes(1)); + ActorIdValue = Config.ReadCached(@"userId", 0L); + + BanClient = new MisuzuBanClient(this, HttpClient); + UserClient = new MisuzuUserClient(this, HttpClient); + } + + public string GetURL(string path) + => BaseURL.Value + path; + + public string GetSignedHash(object obj, string key = null) + => GetSignedHash(obj.ToString(), key); + + public string GetSignedHash(string str, string key = null) + => GetSignedHash(Encoding.UTF8.GetBytes(str), key); + + public string GetSignedHash(byte[] bytes, string key = null) { + StringBuilder sb = new(); + + using(HMACSHA256 algo = new(Encoding.UTF8.GetBytes(key ?? SecretKey))) { + byte[] hash = algo.ComputeHash(bytes); + foreach(byte b in hash) + sb.AppendFormat(@"{0:x2}", b); + } + + return sb.ToString(); + } + } +} diff --git a/SharpChat.DataProvider.Misuzu/SharpChat.DataProvider.Misuzu.csproj b/SharpChat.DataProvider.Misuzu/SharpChat.DataProvider.Misuzu.csproj new file mode 100644 index 0000000..05306c2 --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/SharpChat.DataProvider.Misuzu.csproj @@ -0,0 +1,12 @@ + + + + net5.0 + + + + + + + + diff --git a/SharpChat.DataProvider.Misuzu/Users/MisuzuUser.cs b/SharpChat.DataProvider.Misuzu/Users/MisuzuUser.cs new file mode 100644 index 0000000..142d08c --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Users/MisuzuUser.cs @@ -0,0 +1,30 @@ +using SharpChat.Users; +using SharpChat.Users.Remote; +using System.Text.Json.Serialization; + +namespace SharpChat.DataProvider.Misuzu.Users { + public class MisuzuUser : IRemoteUser { + [JsonPropertyName(@"user_id")] + public long UserId { get; set; } + + [JsonPropertyName(@"username")] + public string UserName { get; set; } + + [JsonPropertyName(@"colour_raw")] + public int ColourRaw { get; set; } + + [JsonIgnore] + public Colour Colour => new(ColourRaw); + + [JsonPropertyName(@"rank")] + public int Rank { get; set; } + + [JsonPropertyName(@"perms")] + public UserPermissions Permissions { get; set; } + + public bool Equals(IUser other) + => other is MisuzuUser && other.UserId == UserId; + public bool Equals(IRemoteUser other) + => other is MisuzuUser && other.UserId == UserId; + } +} diff --git a/SharpChat.DataProvider.Misuzu/Users/MisuzuUserAuthRequest.cs b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserAuthRequest.cs new file mode 100644 index 0000000..293f61f --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserAuthRequest.cs @@ -0,0 +1,26 @@ +using SharpChat.Users.Remote; +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.DataProvider.Misuzu.Users { + internal class MisuzuUserAuthRequest { + [JsonPropertyName(@"user_id")] + public long UserId => AuthRequest.UserId; + + [JsonPropertyName(@"token")] + public string Token => AuthRequest.Token; + + [JsonPropertyName(@"ip")] + public string IPAddress => AuthRequest.RemoteAddress.ToString(); + + private UserAuthRequest AuthRequest { get; } + + public MisuzuUserAuthRequest(UserAuthRequest uar) { + AuthRequest = uar ?? throw new ArgumentNullException(nameof(uar)); + } + + public override string ToString() { + return string.Join(@"#", UserId, Token, IPAddress); + } + } +} diff --git a/SharpChat.DataProvider.Misuzu/Users/MisuzuUserAuthResponse.cs b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserAuthResponse.cs new file mode 100644 index 0000000..5c70353 --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserAuthResponse.cs @@ -0,0 +1,16 @@ +using SharpChat.Users.Remote; +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.DataProvider.Misuzu.Users { + public class MisuzuUserAuthResponse : MisuzuUser, IUserAuthResponse { + [JsonPropertyName(@"success")] + public bool Success { get; set; } + + [JsonPropertyName(@"reason")] + public string Reason { get; set; } = @"none"; + + [JsonPropertyName(@"is_silenced")] + public DateTimeOffset SilencedUntil { get; set; } + } +} diff --git a/SharpChat.DataProvider.Misuzu/Users/MisuzuUserBumpInfo.cs b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserBumpInfo.cs new file mode 100644 index 0000000..8a5effe --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserBumpInfo.cs @@ -0,0 +1,18 @@ +using SharpChat.Users.Remote; +using System.Linq; +using System.Text.Json.Serialization; + +namespace SharpChat.DataProvider.Misuzu.Users { + public class MisuzuUserBumpInfo { + [JsonPropertyName(@"id")] + public long UserId { get; } + + [JsonPropertyName(@"ip")] + public string UserIP { get; } + + public MisuzuUserBumpInfo(UserBumpInfo ubi) { + UserId = ubi.User.UserId; + UserIP = ubi.Sessions.First().RemoteAddress.ToString(); + } + } +} diff --git a/SharpChat.DataProvider.Misuzu/Users/MisuzuUserClient.cs b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserClient.cs new file mode 100644 index 0000000..1936e21 --- /dev/null +++ b/SharpChat.DataProvider.Misuzu/Users/MisuzuUserClient.cs @@ -0,0 +1,180 @@ +using Hamakaze; +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace SharpChat.DataProvider.Misuzu.Users { + public class MisuzuUserClient : IRemoteUserClient { + private MisuzuDataProvider DataProvider { get; } + private HttpClient HttpClient { get; } + + private const string AUTH_URL = @"/verify"; + private const string BUMP_URL = @"/bump"; + private const string RESOLVE_URL = @"/resolve?m={0}&p={1}"; + + private Dictionary UserIdCache { get; } = new(); + private readonly object UserIdCacheSync = new(); + private static readonly TimeSpan CacheMaxAge = TimeSpan.FromMinutes(1); + + public MisuzuUserClient(MisuzuDataProvider dataProvider, HttpClient httpClient) { + DataProvider = dataProvider ?? throw new ArgumentNullException(nameof(dataProvider)); + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public void AuthenticateUser(UserAuthRequest request, Action onSuccess, Action onFailure) { + if(request == null) + throw new ArgumentNullException(nameof(request)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + +#if DEBUG + if(request.UserId >= 10000) { + onSuccess.Invoke(new MisuzuUserAuthResponse { + Success = true, + UserId = request.UserId, + UserName = @"Misaka-" + (request.UserId - 10000), + ColourRaw = (RNG.Next(0, 255) << 16) | (RNG.Next(0, 255) << 8) | RNG.Next(0, 255), + Rank = 0, + SilencedUntil = DateTimeOffset.MinValue, + Permissions = UserPermissions.SendMessage | UserPermissions.EditOwnMessage | UserPermissions.DeleteOwnMessage, + }); + return; + } +#endif + MisuzuUserAuthRequest mar = new(request); + + HttpRequestMessage req = new(HttpRequestMessage.POST, DataProvider.GetURL(AUTH_URL)); + req.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(mar)); + req.SetBody(JsonSerializer.SerializeToUtf8Bytes(mar)); + + HttpClient.SendRequest( + req, + onComplete: (t, r) => { + using MemoryStream ms = new(); + r.Body.CopyTo(ms); + MisuzuUserAuthResponse res = JsonSerializer.Deserialize(ms.ToArray()); + if(res.Success) + onSuccess.Invoke(res); + else + onFailure.Invoke(new UserAuthFailedException(res.Reason)); + }, + onError: (t, e) => { + Logger.Write(@"An error occurred during authentication."); + Logger.Debug(e); + onFailure.Invoke(e); + } + ); + } + + public void BumpUsers(IEnumerable users, Action onSuccess, Action onFailure) { + if(users == null) + throw new ArgumentNullException(nameof(users)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + if(!users.Any()) + return; + + byte[] data = JsonSerializer.SerializeToUtf8Bytes(users.Select(ubi => new MisuzuUserBumpInfo(ubi))); + + HttpRequestMessage request = new(HttpRequestMessage.POST, DataProvider.GetURL(BUMP_URL)); + request.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(data)); + request.SetBody(data); + + HttpClient.SendRequest( + request, + disposeRequest: false, + onComplete: (t, r) => { request.Dispose(); onSuccess?.Invoke(); }, + onError: (t, e) => { + Logger.Write(@"User bump request failed. Retrying once..."); + Logger.Debug(e); + + HttpClient.SendRequest( + request, + onComplete: (t, r) => { + Logger.Write(@"Second user bump attempt succeeded!"); + onSuccess?.Invoke(); + }, + onError: (t, e) => { + Logger.Write(@"User bump request failed again."); + Logger.Debug(e); + onFailure?.Invoke(e); + } + ); + } + ); + } + + private const string RESOLVE_ID = @"id"; + private const string RESOLVE_NAME = @"name"; + + public void ResolveUser(long userId, Action onSuccess, Action onFailure) { + ResolveUser(RESOLVE_ID, userId, onSuccess, onFailure); + } + + public void ResolveUser(string userName, Action onSuccess, Action onFailure) { + ResolveUser(RESOLVE_NAME, userName, onSuccess, onFailure); + } + + public void ResolveUser(IUser localUser, Action onSuccess, Action onFailure) { + if(localUser == null) + onSuccess(null); + else + ResolveUser(RESOLVE_ID, localUser.UserId, onSuccess, onFailure); + } + + private void ResolveUser(string method, object param, Action onSuccess, Action onFailure) { + if(method == null) + throw new ArgumentNullException(nameof(method)); + if(param == null) + throw new ArgumentNullException(nameof(param)); + if(onSuccess == null) + throw new ArgumentNullException(nameof(onSuccess)); + if(onFailure == null) + throw new ArgumentNullException(nameof(onFailure)); + + if(method == RESOLVE_ID) { + MisuzuUser mui = null; + lock(UserIdCacheSync) + if(UserIdCache.TryGetValue((long)param, out (DateTimeOffset age, MisuzuUser mui) cache) + && (DateTimeOffset.Now - cache.age) < CacheMaxAge) + mui = cache.mui; + + if(mui != null) { + onSuccess(mui); + return; + } + } + + HttpRequestMessage req = new(HttpRequestMessage.GET, DataProvider.GetURL( + string.Format(RESOLVE_URL, method, param) + )); + req.SetHeader(@"X-SharpChat-Signature", DataProvider.GetSignedHash(string.Format( + @"resolve#{0}#{1}", method, param + ))); + + HttpClient.SendRequest( + req, + (t, r) => { + try { + MisuzuUser mui = JsonSerializer.Deserialize(r.GetBodyBytes()); + lock(UserIdCacheSync) + UserIdCache[mui.UserId] = (DateTimeOffset.Now, mui); + onSuccess.Invoke(mui); + } catch(Exception ex) { + Logger.Debug(ex); + onFailure.Invoke(ex); + } + }, + (t, e) => { Logger.Debug(e); onFailure.Invoke(e); } + ); + } + } +} diff --git a/SharpChat.Database.MariaDB/MariaDBDatabaseBackend.cs b/SharpChat.Database.MariaDB/MariaDBDatabaseBackend.cs new file mode 100644 index 0000000..ccdf87d --- /dev/null +++ b/SharpChat.Database.MariaDB/MariaDBDatabaseBackend.cs @@ -0,0 +1,88 @@ +using MySql.Data.MySqlClient; +using SharpChat.Configuration; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Database.MariaDB { + [DatabaseBackend(@"mariadb")] + public class MariaDBDatabaseBackend : IDatabaseBackend { + private string DSN { get; } + + private const string DEFAULT_CHARSET = @"utf8mb4"; + + public MariaDBDatabaseBackend(IConfig config) : this( + config.ReadValue(@"host", string.Empty), + config.ReadValue(@"user", string.Empty), + config.ReadValue(@"pass", string.Empty), + config.ReadValue(@"db", string.Empty), + config.ReadValue(@"charset", DEFAULT_CHARSET) + ) {} + + public MariaDBDatabaseBackend(string host, string username, string password, string database, string charset = DEFAULT_CHARSET) { + DSN = new MySqlConnectionStringBuilder { + Server = host, + UserID = username, + Password = password, + Database = database, + IgnorePrepare = false, + OldGuids = false, + TreatTinyAsBoolean = false, + CharacterSet = charset, + TreatBlobsAsUTF8 = false, + }.ToString(); + } + + public IDatabaseConnection CreateConnection() + => new MariaDBDatabaseConnection(DSN); + + public IDatabaseParameter CreateParameter(string name, object value) + => new MariaDBDatabaseParameter(name, value); + + public IDatabaseParameter CreateParameter(string name, DatabaseType type) + => new MariaDBDatabaseParameter(name, type); + + public string TimestampType + => @"TIMESTAMP"; + public string TextType + => @"TEXT"; + public string BlobType + => @"BLOB"; + public string VarCharType(int size) + => string.Format(@"VARCHAR({0})", size); + public string VarBinaryType(int size) + => string.Format(@"VARBINARY({0})", size); + public string BigIntType(int length) + => string.Format(@"BIGINT({0})", length); + public string BigUIntType(int length) + => string.Format(@"BIGINT({0}) UNSIGNED", length); + public string IntType(int length) + => string.Format(@"INT({0})", length); + public string UIntType(int length) + => string.Format(@"INT({0}) UNSIGNED", length); + public string TinyIntType(int length) + => string.Format(@"TINYINT({0})", length); + public string TinyUIntType(int length) + => string.Format(@"TINYINT({0}) UNSIGNED", length); + + public string FromUnixTime(string param) + => string.Format(@"FROM_UNIXTIME({0})", param); + public string ToUnixTime(string param) + => string.Format(@"UNIX_TIMESTAMP({0})", param); + public string DateTimeNow() + => @"NOW()"; + + public bool SupportsJson => true; + public string JsonValue(string field, string path) // yes this is fucked, no i don't care + => string.Format(@"JSON_UNQUOTE(JSON_EXTRACT({0}, '{1}'))", field, path); + + public string Concat(params string[] values) + => string.Format(@"CONCAT({0})", string.Join(@", ", values)); + public string ToLower(string param) + => string.Format(@"LOWER({0})", param); + + public bool SupportsAlterTableCollate => true; + + public string AsciiCollation => @"'ascii_general_ci'"; + public string UnicodeCollation => @"'utf8mb4_unicode_520_ci'"; + } +} diff --git a/SharpChat.Database.MariaDB/MariaDBDatabaseCommand.cs b/SharpChat.Database.MariaDB/MariaDBDatabaseCommand.cs new file mode 100644 index 0000000..c796fe9 --- /dev/null +++ b/SharpChat.Database.MariaDB/MariaDBDatabaseCommand.cs @@ -0,0 +1,67 @@ +using MySql.Data.MySqlClient; +using System; +using System.Data; +using System.Linq; + +namespace SharpChat.Database.MariaDB { + public class MariaDBDatabaseCommand : IDatabaseCommand { + public IDatabaseConnection Connection { get; } + private MySqlCommand Command { get; } + + public string CommandString => Command.CommandText; + public int CommandTimeout { get => Command.CommandTimeout; set => Command.CommandTimeout = value; } + + public MariaDBDatabaseCommand(MariaDBDatabaseConnection connection, MySqlCommand command) { + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + Command = command ?? throw new ArgumentNullException(nameof(command)); + } + + public IDatabaseParameter AddParameter(string name, object value) + => new MariaDBDatabaseParameter(Command.Parameters.AddWithValue(name, value)); + + public IDatabaseParameter AddParameter(string name, DatabaseType type) + => new MariaDBDatabaseParameter(Command.Parameters.Add(name, MariaDBDatabaseParameter.MapType(type))); + + public IDatabaseParameter AddParameter(IDatabaseParameter param) { + if(param is not MariaDBDatabaseParameter mdbParam) + throw new InvalidParameterClassTypeException(); + Command.Parameters.Add(mdbParam.Parameter); + return mdbParam; + } + + public void AddParameters(IDatabaseParameter[] @params) + => Command.Parameters.AddRange(@params.OfType().Select(x => x.Parameter).ToArray()); + + public void ClearParameters() + => Command.Parameters.Clear(); + + public void Prepare() + => Command.Prepare(); + + public int Execute() + => Command.ExecuteNonQuery(); + + public IDatabaseReader ExecuteReader() + => new ADODatabaseReader(Command.ExecuteReader()); + + public object ExecuteScalar() + => Command.ExecuteScalar(); + + private bool IsDisposed; + + ~MariaDBDatabaseCommand() + => DoDispose(); + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Command.Dispose(); + } + } +} diff --git a/SharpChat.Database.MariaDB/MariaDBDatabaseConnection.cs b/SharpChat.Database.MariaDB/MariaDBDatabaseConnection.cs new file mode 100644 index 0000000..139f58e --- /dev/null +++ b/SharpChat.Database.MariaDB/MariaDBDatabaseConnection.cs @@ -0,0 +1,36 @@ +using MySql.Data.MySqlClient; +using System; + +namespace SharpChat.Database.MariaDB { + public class MariaDBDatabaseConnection : IDatabaseConnection { + private MySqlConnection Connection { get; } + + public MariaDBDatabaseConnection(string dsn) { + Connection = new MySqlConnection(dsn ?? throw new ArgumentNullException(nameof(dsn))); + Connection.Open(); + } + + public IDatabaseCommand CreateCommand(object query) { + MySqlCommand command = Connection.CreateCommand(); + command.CommandText = query.ToString(); + return new MariaDBDatabaseCommand(this, command); + } + + private bool IsDisposed; + + ~MariaDBDatabaseConnection() + => DoDispose(); + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Connection.Dispose(); + } + } +} diff --git a/SharpChat.Database.MariaDB/MariaDBDatabaseParameter.cs b/SharpChat.Database.MariaDB/MariaDBDatabaseParameter.cs new file mode 100644 index 0000000..fcef643 --- /dev/null +++ b/SharpChat.Database.MariaDB/MariaDBDatabaseParameter.cs @@ -0,0 +1,34 @@ +using MySql.Data.MySqlClient; +using System; + +namespace SharpChat.Database.MariaDB { + public class MariaDBDatabaseParameter : IDatabaseParameter { + public MySqlParameter Parameter { get; } + + public string Name => Parameter.ParameterName; + public object Value { get => Parameter.Value; set => Parameter.Value = value; } + + public MariaDBDatabaseParameter(string name, object value) : this(new MySqlParameter(name, value)) { } + public MariaDBDatabaseParameter(string name, DatabaseType type) : this(new MySqlParameter(name, MapType(type))) { } + + public MariaDBDatabaseParameter(MySqlParameter parameter) { + Parameter = parameter ?? throw new ArgumentNullException(nameof(parameter)); + } + + internal static MySqlDbType MapType(DatabaseType type) { + return type switch { + DatabaseType.AsciiString => MySqlDbType.VarBinary, + DatabaseType.UnicodeString => MySqlDbType.VarString, + DatabaseType.Int8 => MySqlDbType.Byte, + DatabaseType.Int16 => MySqlDbType.Int16, + DatabaseType.Int32 => MySqlDbType.Int32, + DatabaseType.Int64 => MySqlDbType.Int64, + DatabaseType.UInt8 => MySqlDbType.UByte, + DatabaseType.UInt16 => MySqlDbType.UInt16, + DatabaseType.UInt32 => MySqlDbType.UInt32, + DatabaseType.UInt64 => MySqlDbType.UInt64, + _ => throw new ArgumentException($@"Unsupported type {type}.", nameof(type)), + }; + } + } +} diff --git a/SharpChat.Database.MariaDB/SharpChat.Database.MariaDB.csproj b/SharpChat.Database.MariaDB/SharpChat.Database.MariaDB.csproj new file mode 100644 index 0000000..b2af2ab --- /dev/null +++ b/SharpChat.Database.MariaDB/SharpChat.Database.MariaDB.csproj @@ -0,0 +1,15 @@ + + + + net5.0 + + + + + + + + + + + diff --git a/SharpChat.Database.SQLite/SQLiteDatabaseBackend.cs b/SharpChat.Database.SQLite/SQLiteDatabaseBackend.cs new file mode 100644 index 0000000..c5e8aac --- /dev/null +++ b/SharpChat.Database.SQLite/SQLiteDatabaseBackend.cs @@ -0,0 +1,81 @@ +using SharpChat.Configuration; +using System; +using System.Data.SQLite; + +namespace SharpChat.Database.SQLite { + [DatabaseBackend(@"sqlite")] + public class SQLiteDatabaseBackend : IDatabaseBackend { + private string DSN { get; } + + private const string DEFAULT_PATH = @"sharpchat.db"; + + public SQLiteDatabaseBackend(IConfig config) : this( + config.ReadValue(@"path", DEFAULT_PATH) + ) { } + + public SQLiteDatabaseBackend(string path = DEFAULT_PATH) { + DSN = new SQLiteConnectionStringBuilder { + DataSource = path, + DateTimeFormat = SQLiteDateFormats.UnixEpoch, + DateTimeKind = DateTimeKind.Utc, + ForeignKeys = true, + LegacyFormat = false, + Pooling = true, + Version = 3, + }.ToString(); + } + + public IDatabaseConnection CreateConnection() + => new SQLiteDatabaseConnection(DSN); + + public IDatabaseParameter CreateParameter(string name, object value) + => new SQLiteDatabaseParameter(name, value); + + public IDatabaseParameter CreateParameter(string name, DatabaseType type) + => new SQLiteDatabaseParameter(name, type); + + public string TimestampType + => @"INTEGER"; + public string TextType + => @"TEXT"; + public string BlobType + => @"BLOB"; + public string VarCharType(int size) + => @"TEXT"; + public string VarBinaryType(int size) + => @"BLOB"; + public string BigIntType(int length) + => @"INTEGER"; + public string BigUIntType(int length) + => @"INTEGER"; + public string IntType(int length) + => @"INTEGER"; + public string UIntType(int length) + => @"INTEGER"; + public string TinyIntType(int length) + => @"INTEGER"; + public string TinyUIntType(int length) + => @"INTEGER"; + + public string FromUnixTime(string param) + => param; + public string ToUnixTime(string param) + => param; + public string DateTimeNow() + => @"strftime('%s', 'now')"; + + public bool SupportsJson => false; + public string JsonValue(string field, string path) + => string.Empty; + + public string Concat(params string[] args) + => string.Join(@" || ", args); + public string ToLower(string param) + => string.Format(@"LOWER({0})", param); + + public bool SupportsAlterTableCollate => false; + + public string AsciiCollation => @"NOCASE"; + public string UnicodeCollation => @"NOCASE"; + } +} diff --git a/SharpChat.Database.SQLite/SQLiteDatabaseCommand.cs b/SharpChat.Database.SQLite/SQLiteDatabaseCommand.cs new file mode 100644 index 0000000..3c82aad --- /dev/null +++ b/SharpChat.Database.SQLite/SQLiteDatabaseCommand.cs @@ -0,0 +1,73 @@ +using System; +using System.Data.SQLite; +using System.Linq; + +namespace SharpChat.Database.SQLite { + public class SQLiteDatabaseCommand : IDatabaseCommand { + public IDatabaseConnection Connection { get; } + private SQLiteCommand Command { get; } + + public string CommandString => Command.CommandText; + public int CommandTimeout { get => Command.CommandTimeout; set => Command.CommandTimeout = value; } + + public SQLiteDatabaseCommand(SQLiteDatabaseConnection conn, SQLiteCommand comm) { + Connection = conn ?? throw new ArgumentNullException(nameof(conn)); + Command = comm ?? throw new ArgumentNullException(nameof(comm)); + } + + public IDatabaseParameter AddParameter(string name, object value) + => new SQLiteDatabaseParameter(Command.Parameters.AddWithValue(name, value)); + + public IDatabaseParameter AddParameter(string name, DatabaseType type) { + SQLiteParameter param = Command.CreateParameter(); + param.ParameterName = name; + param.DbType = SQLiteDatabaseParameter.MapType(type); + return new SQLiteDatabaseParameter(param); + } + + public IDatabaseParameter AddParameter(IDatabaseParameter param) { + if(param is not SQLiteDatabaseParameter sqlParam) + throw new InvalidParameterClassTypeException(); + Command.Parameters.Add(sqlParam.Parameter); + return sqlParam; + } + + public void AddParameters(IDatabaseParameter[] @params) { + Command.Parameters.AddRange(@params.OfType().Select(x => x.Parameter).ToArray()); + } + + public void ClearParameters() { + Command.Parameters.Clear(); + } + + public void Prepare() { + Command.Prepare(); + } + + public int Execute() + => Command.ExecuteNonQuery(); + + public IDatabaseReader ExecuteReader() + => new ADODatabaseReader(Command.ExecuteReader()); + + public object ExecuteScalar() + => Command.ExecuteScalar(); + + private bool IsDisposed; + + ~SQLiteDatabaseCommand() + => DoDispose(); + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Command.Dispose(); + } + } +} diff --git a/SharpChat.Database.SQLite/SQLiteDatabaseConnection.cs b/SharpChat.Database.SQLite/SQLiteDatabaseConnection.cs new file mode 100644 index 0000000..01b3bc0 --- /dev/null +++ b/SharpChat.Database.SQLite/SQLiteDatabaseConnection.cs @@ -0,0 +1,37 @@ +using System; +using System.Data.SQLite; + +namespace SharpChat.Database.SQLite { + public class SQLiteDatabaseConnection : IDatabaseConnection { + private SQLiteConnection Connection { get; } + + public SQLiteDatabaseConnection(string dsn) { + Connection = new SQLiteConnection(dsn ?? throw new ArgumentNullException(nameof(dsn))); + Connection.Open(); + } + + public IDatabaseCommand CreateCommand(object query) { + SQLiteCommand comm = Connection.CreateCommand(); + comm.CommandText = query.ToString(); + comm.CommandTimeout = 5; + return new SQLiteDatabaseCommand(this, comm); + } + + private bool IsDisposed; + + ~SQLiteDatabaseConnection() + => DoDispose(); + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Connection.Dispose(); + } + } +} diff --git a/SharpChat.Database.SQLite/SQLiteDatabaseParameter.cs b/SharpChat.Database.SQLite/SQLiteDatabaseParameter.cs new file mode 100644 index 0000000..559ad7d --- /dev/null +++ b/SharpChat.Database.SQLite/SQLiteDatabaseParameter.cs @@ -0,0 +1,35 @@ +using System; +using System.Data; +using System.Data.SQLite; + +namespace SharpChat.Database.SQLite { + public class SQLiteDatabaseParameter : IDatabaseParameter { + public SQLiteParameter Parameter { get; } + + public string Name => Parameter.ParameterName; + public object Value { get => Parameter.Value; set => Parameter.Value = value; } + + public SQLiteDatabaseParameter(string name, object value) : this(new SQLiteParameter(name, value)) { } + public SQLiteDatabaseParameter(string name, DatabaseType type) : this(new SQLiteParameter(name, MapType(type))) { } + + public SQLiteDatabaseParameter(SQLiteParameter parameter) { + Parameter = parameter ?? throw new ArgumentNullException(nameof(parameter)); + } + + internal static DbType MapType(DatabaseType type) { + return type switch { + DatabaseType.AsciiString => DbType.AnsiString, + DatabaseType.UnicodeString => DbType.String, + DatabaseType.Int8 => DbType.SByte, + DatabaseType.Int16 => DbType.Int16, + DatabaseType.Int32 => DbType.Int32, + DatabaseType.Int64 => DbType.Int64, + DatabaseType.UInt8 => DbType.Byte, + DatabaseType.UInt16 => DbType.UInt16, + DatabaseType.UInt32 => DbType.UInt32, + DatabaseType.UInt64 => DbType.UInt64, + _ => throw new ArgumentException($@"Unsupported type {type}.", nameof(type)), + }; + } + } +} diff --git a/SharpChat.Database.SQLite/SharpChat.Database.SQLite.csproj b/SharpChat.Database.SQLite/SharpChat.Database.SQLite.csproj new file mode 100644 index 0000000..683c203 --- /dev/null +++ b/SharpChat.Database.SQLite/SharpChat.Database.SQLite.csproj @@ -0,0 +1,15 @@ + + + + net5.0 + + + + + + + + + + + diff --git a/SharpChat.Protocol.IRC/Channels/ChannelManagerExtensions.cs b/SharpChat.Protocol.IRC/Channels/ChannelManagerExtensions.cs new file mode 100644 index 0000000..1b065e6 --- /dev/null +++ b/SharpChat.Protocol.IRC/Channels/ChannelManagerExtensions.cs @@ -0,0 +1,18 @@ +using SharpChat.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Channels { + public static class ChannelManagerExtensions { + public static void GetChannelByIRCName(this ChannelManager channels, string ircName, Action callback) { + if(ircName == null) + throw new ArgumentNullException(nameof(ircName)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + if(string.IsNullOrWhiteSpace(ircName) || ircName == @"#") { + callback(null); + return; + } + channels.GetChannel(c => ircName.Equals(c.GetIRCName()), callback); + } + } +} diff --git a/SharpChat.Protocol.IRC/Channels/IChannelExtensions.cs b/SharpChat.Protocol.IRC/Channels/IChannelExtensions.cs new file mode 100644 index 0000000..ff1bfb7 --- /dev/null +++ b/SharpChat.Protocol.IRC/Channels/IChannelExtensions.cs @@ -0,0 +1,18 @@ +using SharpChat.Channels; + +namespace SharpChat.Protocol.IRC.Channels { + public static class IChannelExtensions { + public static string GetIRCName(this IChannel channel) { + return $@"#{channel.Name.ToLowerInvariant()}"; + } + + public static char GetIRCNamesPrefix(this IChannel channel) { + // maybe? + //if(channel.IsInviteOnly) + // return '*'; + if(channel.HasPassword) + return '@'; + return '='; + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/AdminCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/AdminCommand.cs new file mode 100644 index 0000000..231fcc6 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/AdminCommand.cs @@ -0,0 +1,24 @@ +using SharpChat.Protocol.IRC.Replies; +using System; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class AdminCommand : IClientCommand { + public const string NAME = @"ADMIN"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private IRCServer Server { get; } + + public AdminCommand(IRCServer server) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + } + + public void HandleCommand(ClientCommandContext ctx) { + ctx.Connection.SendReply(new AdminMeReply()); + ctx.Connection.SendReply(new AdminLocation1Reply()); + ctx.Connection.SendReply(new AdminLocation2Reply()); + ctx.Connection.SendReply(new AdminEMailReply()); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/AwayCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/AwayCommand.cs new file mode 100644 index 0000000..7f181d3 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/AwayCommand.cs @@ -0,0 +1,29 @@ +using SharpChat.Users; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class AwayCommand : IClientCommand { + public const string NAME = @"AWAY"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private UserManager Users { get; } + + public AwayCommand(UserManager users) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + } + + public void HandleCommand(ClientCommandContext ctx) { + string line = ctx.Arguments.FirstOrDefault() ?? string.Empty; + bool isAway = !string.IsNullOrEmpty(line); + + Users.Update( + ctx.User, + status: isAway ? UserStatus.Away : UserStatus.Online, + statusMessage: line + ); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/CapabilitiesCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/CapabilitiesCommand.cs new file mode 100644 index 0000000..d9f2184 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/CapabilitiesCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class CapabilitiesCommand : IClientCommand { + public const string NAME = @"CAP"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // capability shit + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ClientCommandContext.cs b/SharpChat.Protocol.IRC/ClientCommands/ClientCommandContext.cs new file mode 100644 index 0000000..f44e28e --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ClientCommandContext.cs @@ -0,0 +1,19 @@ +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ClientCommandContext { + public IRCConnection Connection { get; } + public IEnumerable Arguments { get; } + + public ISession Session => Connection.Session; + public ILocalUser User => Session?.User; + + public ClientCommandContext(IRCConnection connection, IEnumerable args) { + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + Arguments = args ?? throw new ArgumentNullException(nameof(args)); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/IClientCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/IClientCommand.cs new file mode 100644 index 0000000..b9ef67a --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/IClientCommand.cs @@ -0,0 +1,9 @@ +namespace SharpChat.Protocol.IRC.ClientCommands { + public interface IClientCommand { + string CommandName { get; } + + bool RequireSession { get; } + + void HandleCommand(ClientCommandContext ctx); + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/InfoCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/InfoCommand.cs new file mode 100644 index 0000000..c201c17 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/InfoCommand.cs @@ -0,0 +1,15 @@ +using SharpChat.Protocol.IRC.Replies; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class InfoCommand : IClientCommand { + public const string NAME = @"INFO"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext ctx) { + ctx.Connection.SendReply(new InfoReply()); + ctx.Connection.SendReply(new EndOfInfoReply()); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/InviteCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/InviteCommand.cs new file mode 100644 index 0000000..2cd55d3 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/InviteCommand.cs @@ -0,0 +1,57 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Users; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class InviteCommand : IClientCommand { // reintroduce this into Sock Chat + public const string NAME = @"INVITE"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private UserManager Users { get; } + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + + public InviteCommand(UserManager users, ChannelManager channels, ChannelUserRelations channelUsers) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + } + + public void HandleCommand(ClientCommandContext ctx) { + string userName = ctx.Arguments.ElementAtOrDefault(0) ?? string.Empty; + + Users.GetUser(userName, user => { + if(user == null) { + ctx.Connection.SendReply(new NoSuchNickReply(userName)); + return; + } + + string channelName = ctx.Arguments.ElementAtOrDefault(1) ?? string.Empty; + if(string.IsNullOrWhiteSpace(channelName)) { + ctx.Connection.SendReply(new NoSuchChannelReply(channelName)); + return; + } + + Channels.GetChannelByName(channelName, channel => { + if(channel == null) { + ctx.Connection.SendReply(new NoSuchChannelReply(channelName)); + return; + } + + ChannelUsers.HasUser(channel, user, hasUser => { + if(!hasUser) { + ctx.Connection.SendReply(new UserOnChannelReply(user, channel)); + return; + } + + // todo: dispatch invite + }); + }); + }); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/IsOnCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/IsOnCommand.cs new file mode 100644 index 0000000..630a2c5 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/IsOnCommand.cs @@ -0,0 +1,52 @@ +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Protocol.IRC.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class IsOnCommand : IClientCommand { + public const string NAME = @"ISON"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private UserManager Users { get; } + + public IsOnCommand(UserManager users) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + } + + public void HandleCommand(ClientCommandContext ctx) { + IEnumerable userNames = ctx.Arguments.Select(u => u.ToLowerInvariant()); + + const int max_length = 400; // allow for 112 characters of overhead + int length = 0; + List batch = new(); + + void sendBatch() { + if(length < 1) + return; + ctx.Connection.SendReply(new IsOnReply(batch)); + length = 0; + batch.Clear(); + }; + + Users.GetUsers(u => (u.Status == UserStatus.Online || u.Status == UserStatus.Away) && userNames.Contains(u.GetIRCName()), users => { + foreach(IUser user in users) { + string name = user.GetIRCName(); + int nameLength = name.Length + 1; + + if(length + nameLength > max_length) + sendBatch(); + + length += nameLength; + batch.Add(name); + } + + sendBatch(); + }); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/JoinCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/JoinCommand.cs new file mode 100644 index 0000000..d85dc0e --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/JoinCommand.cs @@ -0,0 +1,74 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Replies; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class JoinCommand : IClientCommand { + public const string NAME = @"JOIN"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + + public JoinCommand(ChannelManager channels, ChannelUserRelations channelUsers) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + } + + public void HandleCommand(ClientCommandContext ctx) { + string firstArg = ctx.Arguments.ElementAtOrDefault(0) ?? string.Empty; + + if(firstArg == @"0") { // of course you would leave all channels with the JOIN command + ChannelUsers.LeaveChannels(ctx.Session); + return; + } + + string[] names = firstArg.Split(','); + string[] passwords = (ctx.Arguments.ElementAtOrDefault(1) ?? string.Empty).Split(','); + + for(int i = 0; i < names.Length; ++i) { + string name = names[i]; + + Channels.GetChannel(c => name.Equals(c.GetIRCName()), channel => { + if(channel == null) { // todo: check permissions and allow channel creation + ctx.Connection.SendReply(new BadChannelMaskReply(name)); + return; + } + + ChannelUsers.HasSession(channel, ctx.Session, hasSession => { + // just continue if we're already in the channel + if(hasSession) + return; + + // introduce channel bans at some point + + // introduce invites at some point + + // add rank check + + ChannelUsers.CheckOverCapacity(channel, ctx.User, isOverCapacity => { + if(isOverCapacity) { + ctx.Connection.SendReply(new ChannelIsFullReply(channel)); + return; + } + + string password = passwords.ElementAtOrDefault(i) ?? string.Empty; + Channels.VerifyPassword(channel, password, success => { + if(!success) { + ctx.Connection.SendReply(new BadChannelKeyReply(channel)); + return; + } + + ChannelUsers.JoinChannel(channel, ctx.Session); + }); + }); + }); + }); + } + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/KickCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/KickCommand.cs new file mode 100644 index 0000000..c7e830f --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/KickCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class KickCommand : IClientCommand { + public const string NAME = @"KICK"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // kick a user from a channel + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/KillCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/KillCommand.cs new file mode 100644 index 0000000..07d8ae7 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/KillCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class KillCommand : IClientCommand { + public const string NAME = @"KILL"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // kick a user from the server + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ListCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ListCommand.cs new file mode 100644 index 0000000..b2aaaf6 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ListCommand.cs @@ -0,0 +1,31 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Replies; +using System; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ListCommand : IClientCommand { + public const string NAME = @"LIST"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public ChannelManager Channels { get; } + public ChannelUserRelations ChannelUsers { get; } + + public ListCommand(ChannelManager channels, ChannelUserRelations channelUsers) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + } + + public void HandleCommand(ClientCommandContext ctx) { + // todo: special LIST comments for Modern IRC + + Channels.GetChannels(channels => { // probably needs to check if a user actually has access + foreach(IChannel channel in channels) + ChannelUsers.CountUsers(channel, userCount => ctx.Connection.SendReply(new ListItemReply(channel, userCount))); + + ctx.Connection.SendReply(new ListEndReply()); + }); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ListUsersCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ListUsersCommand.cs new file mode 100644 index 0000000..d63232b --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ListUsersCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ListUsersCommand : IClientCommand { + public const string NAME = @"LUSERS"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns server user stats + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/MessageOfTheDayCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/MessageOfTheDayCommand.cs new file mode 100644 index 0000000..5d3a147 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/MessageOfTheDayCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class MessageOfTheDayCommand : IClientCommand { + public const string NAME = @"MOTD"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns the MOTD + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ModeCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ModeCommand.cs new file mode 100644 index 0000000..99bfd05 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ModeCommand.cs @@ -0,0 +1,121 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Protocol.IRC.Sessions; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ModeCommand : IClientCommand { + public const string NAME = @"MODE"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private ChannelManager Channels { get; } + private UserManager Users { get; } + private SessionManager Sessions { get; } + + public ModeCommand(ChannelManager channels, UserManager users, SessionManager sessions) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + + public void HandleCommand(ClientCommandContext ctx) { + string targetName = ctx.Arguments.ElementAtOrDefault(0); + if(string.IsNullOrWhiteSpace(targetName)) { + ctx.Connection.SendReply(new NeedMoreParamsReply(NAME)); + return; + } + + if(!ctx.Arguments.Any()) { + ctx.Connection.SendReply(new NeedMoreParamsReply(NAME)); + return; + } + + if(targetName.StartsWith('#')) + Channels.GetChannelByIRCName(targetName, channel => { + if(channel == null) { + ctx.Connection.SendReply(new NoSuchChannelReply(targetName)); + return; + } + + if(ctx.Arguments.Count() == 1) { + //ctx.Connection.SendCommand(new ServerModeCommand(channel)); + return; + } + + // owner check + + HandleChannel(ctx, channel); + }); + else + Users.GetUser(targetName, user => { + if(user == null) { + ctx.Connection.SendReply(new NoSuchNickReply(targetName)); + return; + } + + if(ctx.Arguments.Count() == 1) { + //Sessions.CheckIRCSecure(user, isSecure => ctx.Connection.SendCommand(new ServerModeCommand(user, isSecure))); + return; + } + + if(!user.Equals(ctx.User)) { + // admin check probably + ctx.Connection.SendReply(new UsersDoNotMatchReply()); + return; + } + + HandleUser(ctx, user); + }); + } + + private void HandleChannel(ClientCommandContext ctx, IChannel channel) { + Queue args = new(ctx.Arguments); + + while(args.TryDequeue(out string arg)) { + // + } + } + + private void HandleUser(ClientCommandContext ctx, ILocalUser user) { + HashSet processed = new(); + + string modeSet = ctx.Arguments.FirstOrDefault(); + if(modeSet.Length < 2) + return; + + Queue chars = new(modeSet.ToArray()); + + char mode = chars.Dequeue(); + if(mode is not '+' and not '-') + return; + + bool set = mode == '+'; + + while(chars.TryDequeue(out mode)) { + if(processed.Contains(mode)) + continue; + processed.Add(mode); + + switch(mode) { + case 'i': // Invisible (appear offline) + Users.Update(user, status: set ? UserStatus.Offline : UserStatus.Online); + break; + + default: + ctx.Connection.SendReply(new UserModeUnknownFlagReply()); + chars.Clear(); + return; + } + } + + Sessions.CheckIRCSecure(user, isSecure => ctx.Connection.SendReply(new UserModeIsReply(user, isSecure))); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/NamesCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/NamesCommand.cs new file mode 100644 index 0000000..84dcb80 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/NamesCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class NamesCommand : IClientCommand { + public const string NAME = @"NAMES"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // lists users in channel, probably + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/NickCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/NickCommand.cs new file mode 100644 index 0000000..88c03b4 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/NickCommand.cs @@ -0,0 +1,61 @@ +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Users; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class NickCommand : IClientCommand { + public const string NAME = @"NICK"; + + public string CommandName => NAME; + public bool RequireSession => false; + + private UserManager Users { get; } + + public NickCommand(UserManager users) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + } + + public void HandleCommand(ClientCommandContext ctx) { + if(ctx.User == null) // blocking calls to this without an error + return; + + // TODO: check if user is allowed to set a nick + // should prefixes be a thing for IRC? + // should the prefix be nuked in favour of a forced name change? + + string nickName = ctx.Arguments.FirstOrDefault(); + + if(string.IsNullOrWhiteSpace(nickName)) { + ctx.Connection.SendReply(new NoNickNameGivenReply()); + return; + } + + nickName = nickName.Trim(); + + if(nickName.Equals(ctx.User.UserName, StringComparison.InvariantCulture)) // allowing capitalisation changes + nickName = null; + else if(nickName.Length > 15) // should be configurable somewhere, also magic number in Sock Chat's impl + nickName = nickName.Substring(0, 15); // also Flashii's max username length is 16, guessing it was 15 to account for the ~? + else if(string.IsNullOrEmpty(nickName)) + nickName = null; + + if(nickName == null) { + Users.Update(ctx.User, nickName: string.Empty); + return; + } + + // TODO: global name validation routines + //ctx.Connection.SendReply(new ErroneousNickNameReply(nickName)); + + Users.GetUser(nickName, user => { + if(user != null) { + ctx.Connection.SendReply(new NickNameInUseReply(nickName)); + return; + } + + Users.Update(ctx.User, nickName: nickName); + }); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/NoticeCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/NoticeCommand.cs new file mode 100644 index 0000000..d1e2acc --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/NoticeCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class NoticeCommand : IClientCommand { + public const string NAME = @"NOTICE"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // like privmsg but autoreplies should not be sent + // should this be supported? + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/OperCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/OperCommand.cs new file mode 100644 index 0000000..53557aa --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/OperCommand.cs @@ -0,0 +1,19 @@ +using SharpChat.Protocol.IRC.Replies; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class OperCommand : IClientCommand { + public const string NAME = @"OPER"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext ctx) { + ctx.Connection.SendReply( + ctx.Arguments.Count() < 2 + ? new NeedMoreParamsReply(NAME) + : new NoOperatorHostReply() + ); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/PartCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/PartCommand.cs new file mode 100644 index 0000000..be5341b --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/PartCommand.cs @@ -0,0 +1,50 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Replies; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class PartCommand : IClientCommand { + public const string NAME = @"PART"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + + public PartCommand(ChannelManager channels, ChannelUserRelations channelUsers) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + } + + public void HandleCommand(ClientCommandContext ctx) { + IEnumerable channelNames = (ctx.Arguments.FirstOrDefault() ?? string.Empty).Split(',').Select(n => n.ToLowerInvariant()); + + if(!channelNames.Any()) { + ctx.Connection.SendReply(new NeedMoreParamsReply(NAME)); + return; + } + + foreach(string channelName in channelNames) { + Channels.GetChannel(c => channelName.Equals(c.GetIRCName()), channel => { + if(channel == null) { + ctx.Connection.SendReply(new NoSuchChannelReply(channelName)); + return; + } + + ChannelUsers.HasSession(channel, ctx.Session, hasUser => { + if(!hasUser) { + ctx.Connection.SendReply(new NotOnChannelReply(channel)); + return; + } + + ChannelUsers.LeaveChannel(channel, ctx.Session); + }); + }); + } + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/PassCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/PassCommand.cs new file mode 100644 index 0000000..569d23b --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/PassCommand.cs @@ -0,0 +1,26 @@ +using SharpChat.Protocol.IRC.Replies; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class PassCommand : IClientCommand { + public const string NAME = @"PASS"; + + public string CommandName => NAME; + public bool RequireSession => false; + + public void HandleCommand(ClientCommandContext ctx) { + if(ctx.Connection.HasAuthenticated) { + ctx.Connection.SendReply(new AlreadyRegisteredReply()); + return; + } + + string password = ctx.Arguments.FirstOrDefault(); + if(string.IsNullOrWhiteSpace(password)) { + ctx.Connection.SendReply(new NeedMoreParamsReply(NAME)); + return; + } + + ctx.Connection.Password = password; + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/PingCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/PingCommand.cs new file mode 100644 index 0000000..ecc5957 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/PingCommand.cs @@ -0,0 +1,28 @@ +using SharpChat.Protocol.IRC.ServerCommands; +using SharpChat.Sessions; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class PingCommand : IClientCommand { + public const string NAME = @"PING"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private IRCServer Server { get; } + private SessionManager Sessions { get; } + + public PingCommand(IRCServer server, SessionManager sessions) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + + public void HandleCommand(ClientCommandContext ctx) { + if(ctx.Arguments.Any()) { + Sessions.DoKeepAlive(ctx.Session); + ctx.Connection.SendCommand(new ServerPongCommand(Server, ctx.Arguments.FirstOrDefault())); + } + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/PrivateMessageCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/PrivateMessageCommand.cs new file mode 100644 index 0000000..0376036 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/PrivateMessageCommand.cs @@ -0,0 +1,66 @@ +using SharpChat.Channels; +using SharpChat.Messages; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Replies; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class PrivateMessageCommand : IClientCommand { + public const string NAME = @"PRIVMSG"; + + public string CommandName => NAME; + public bool RequireSession => true; + + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private MessageManager Messages { get; } + + public PrivateMessageCommand(ChannelManager channels, ChannelUserRelations channelUsers, MessageManager messages) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + } + + public void HandleCommand(ClientCommandContext ctx) { + string channelName = ctx.Arguments.ElementAtOrDefault(0); + if(string.IsNullOrWhiteSpace(channelName)) { + ctx.Connection.SendReply(new NoRecipientReply(NAME)); + return; + } + + string text = ctx.Arguments.ElementAtOrDefault(1); + if(string.IsNullOrWhiteSpace(text)) { + ctx.Connection.SendReply(new NoTextToSendReply()); + return; + } + + Func predicate = null; + char channelPrefix = channelName.First(); + + if(channelPrefix == '#') + predicate = new Func(c => channelName.Equals(c.GetIRCName())); + + if(predicate == null) { + ctx.Connection.SendReply(new NoSuchNickReply(channelName)); + return; + } + + Channels.GetChannel(predicate, channel => { + if(channel == null) { + ctx.Connection.SendReply(new NoSuchNickReply(channelName)); + return; + } + + ChannelUsers.HasUser(channel, ctx.User, hasUser => { + if(!hasUser) { + ctx.Connection.SendReply(new CannotSendToChannelReply(channel)); + return; + } + + Messages.Create(ctx.Session, channel, text); + }); + }); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/QuitCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/QuitCommand.cs new file mode 100644 index 0000000..3780324 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/QuitCommand.cs @@ -0,0 +1,26 @@ +using SharpChat.Sessions; +using System; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class QuitCommand : IClientCommand { + public const string NAME = @"QUIT"; + + public string CommandName => NAME; + public bool RequireSession => false; + + private SessionManager Sessions { get; } + + public QuitCommand(SessionManager sessions) { + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + + public void HandleCommand(ClientCommandContext ctx) { + //string message = ctx.Arguments.ElementAtOrDefault(0); + + ctx.Connection.Close(); + + if(ctx.Session != null) + Sessions.Destroy(ctx.Connection); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ServerQuitCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ServerQuitCommand.cs new file mode 100644 index 0000000..6037198 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ServerQuitCommand.cs @@ -0,0 +1,14 @@ +using SharpChat.Protocol.IRC.Replies; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ServerQuitCommand : IClientCommand { + public const string NAME = @"SQUIT"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext ctx) { + ctx.Connection.SendReply(new NoPrivilegesReply()); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ServiceCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ServiceCommand.cs new file mode 100644 index 0000000..99dd38d --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ServiceCommand.cs @@ -0,0 +1,14 @@ +using SharpChat.Protocol.IRC.Replies; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ServiceCommand : IClientCommand { + public const string NAME = @"SERVICE"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext ctx) { + ctx.Connection.SendReply(new AlreadyRegisteredReply()); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ServiceListCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ServiceListCommand.cs new file mode 100644 index 0000000..55b7067 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ServiceListCommand.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ServiceListCommand : IClientCommand { + public const string NAME = @"SERVLIST"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // lists services, could be used for authentication but i think i'll just use the PASS field + // not sure how i'm going to tackle auth entirely yet + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/ServiceQueryCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/ServiceQueryCommand.cs new file mode 100644 index 0000000..1cffe6a --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/ServiceQueryCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class ServiceQueryCommand : IClientCommand { + public const string NAME = @"SQUERY"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // identical to PRIVMSG but ensures receiver is a service + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/SilenceCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/SilenceCommand.cs new file mode 100644 index 0000000..86dc6e2 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/SilenceCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class SilenceCommand : IClientCommand { + public const string NAME = @"SILENCE"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // (un)silence people + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/StatsCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/StatsCommand.cs new file mode 100644 index 0000000..56af003 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/StatsCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class StatsCommand : IClientCommand { + public const string NAME = @"STATS"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns server stats + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/SummonCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/SummonCommand.cs new file mode 100644 index 0000000..f8f818a --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/SummonCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class SummonCommand : IClientCommand { + public const string NAME = @"SUMMON"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // should inform that summon is disabled + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/TimeCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/TimeCommand.cs new file mode 100644 index 0000000..56c6d24 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/TimeCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class TimeCommand : IClientCommand { + public const string NAME = @"TIME"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns local time + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/TopicCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/TopicCommand.cs new file mode 100644 index 0000000..1d0b6ff --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/TopicCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class TopicCommand : IClientCommand { + public const string NAME = @"TOPIC"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // gets or sets a channel topic + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/UserCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/UserCommand.cs new file mode 100644 index 0000000..8cf8feb --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/UserCommand.cs @@ -0,0 +1,159 @@ +using SharpChat.Bans; +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Sessions; +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class UserCommand : IClientCommand { + public const string NAME = @"USER"; + + private const byte MODE_W = 0x02; + private const byte MODE_I = 0x04; + + public string CommandName => NAME; + public bool RequireSession => false; + + private IRCServer Server { get; } + private Context Context { get; } + private UserManager Users { get; } + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private SessionManager Sessions { get; } + private IRemoteUserClient RemoteUserClient { get; } + private BanManager Bans { get; } + private WelcomeMessage WelcomeMessage { get; } + + public UserCommand( + IRCServer server, + Context context, + UserManager users, + ChannelManager channels, + ChannelUserRelations channelUsers, + SessionManager sessions, + IRemoteUserClient remoteUserClient, + BanManager bans, + WelcomeMessage welcomeMessage + ) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + Context = context ?? throw new ArgumentNullException(nameof(context)); + Users = users ?? throw new ArgumentNullException(nameof(users)); + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + RemoteUserClient = remoteUserClient ?? throw new ArgumentNullException(nameof(remoteUserClient)); + Bans = bans ?? throw new ArgumentNullException(nameof(bans)); + WelcomeMessage = welcomeMessage ?? throw new ArgumentNullException(nameof(welcomeMessage)); + } + + public void HandleCommand(ClientCommandContext ctx) { + if(ctx.Connection.HasAuthenticated) { + ctx.Connection.SendReply(new AlreadyRegisteredReply()); + return; + } + + // just drop subsequent calls + if(ctx.Connection.IsAuthenticating) + return; + ctx.Connection.IsAuthenticating = true; + + string userName = ctx.Arguments.ElementAtOrDefault(0); + string modeStr = ctx.Arguments.ElementAtOrDefault(1); + //string param3 = ctx.Arguments.ElementAtOrDefault(2); + //string realName = ctx.Arguments.ElementAtOrDefault(3); + + if(string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(modeStr)) { + ctx.Connection.SendReply(new NeedMoreParamsReply(NAME)); + return; + } + + // TODO: should accept normal text username in the future!!!! + if(!long.TryParse(userName, out long userId)) { + ctx.Connection.SendReply(new PasswordMismatchReply()); + ctx.Connection.Close(); + return; + } + + if(!int.TryParse(modeStr, out int mode)) + mode = 0; + + bool isInvisible = (mode & MODE_I) > 0; + bool receiveWallOps = (mode & MODE_W) > 0; + + Action exceptionHandler = new(ex => { + Logger.Debug($@"[{ctx.Connection}] Auth fail: {ex.Message}"); + ctx.Connection.SendReply(new PasswordMismatchReply()); + ctx.Connection.Close(); + }); + + RemoteUserClient.AuthenticateUser( + new UserAuthRequest(userId, ctx.Connection.Password, ctx.Connection.RemoteAddress), + res => { + ctx.Connection.Password = null; + ctx.Connection.HasAuthenticated = true; + + Bans.CheckBan(res, ctx.Connection.RemoteAddress, ban => { + if(ban.IsPermanent || ban.Expires > DateTimeOffset.Now) { + // should probably include the time + + ctx.Connection.SendReply(new YouAreBannedReply(@"You have been banned.")); + ctx.Connection.Close(); + return; + } + + Users.Connect(res, user => { + Sessions.HasAvailableSessions(user, available => { + // Enforce a maximum amount of connections per user + if(!available) { + // map somethign to this + //ctx.Connection.SendPacket(new AuthFailPacket(AuthFailReason.MaxSessions)); + ctx.Connection.Close(); + return; + } + + Sessions.Create(ctx.Connection, user, session => { + // TODO: !!!!!!!!!!!!!!!! + ctx.Connection.Session = session; + session.Connection = ctx.Connection; + + ctx.Connection.SendReply(new WelcomeReply(Server, user)); + ctx.Connection.SendReply(new YourHostReply(Server)); + ctx.Connection.SendReply(new CreatedReply(Context)); + ctx.Connection.SendReply(new MyInfoReply(Server)); + ctx.Connection.SendReply(new ISupportReply(Server)); + + if(WelcomeMessage.HasRandom) { + ctx.Connection.SendReply(new MotdStartReply()); + + string line = WelcomeMessage.GetRandomString(); + if(!string.IsNullOrWhiteSpace(line)) + ctx.Connection.SendReply(new MotdReply(line)); + + ctx.Connection.SendReply(new MotdEndReply()); + } else + ctx.Connection.SendReply(new NoMotdReply()); + + // are these necessary? + ctx.Connection.SendReply(new ListUserClientReply()); + ctx.Connection.SendReply(new ListUserOperatorsReply()); + ctx.Connection.SendReply(new ListUserUnknownReply()); + ctx.Connection.SendReply(new ListUserChannelsReply()); + ctx.Connection.SendReply(new ListUserMeReply()); + + Channels.GetDefaultChannels(channels => { + foreach(IChannel channel in channels) + ChannelUsers.JoinChannel(channel, session); + }); + }); + }); + }); + }, exceptionHandler); + }, + exceptionHandler + ); + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/UserHostCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/UserHostCommand.cs new file mode 100644 index 0000000..b259357 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/UserHostCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class UserHostCommand : IClientCommand { + public const string NAME = @"USERHOST"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns information about users + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/VersionCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/VersionCommand.cs new file mode 100644 index 0000000..2aa0fb2 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/VersionCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class VersionCommand : IClientCommand { + public const string NAME = @"VERSION"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns version info + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/WAllOpsCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/WAllOpsCommand.cs new file mode 100644 index 0000000..cde8341 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/WAllOpsCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class WAllOpsCommand : IClientCommand { // think it's Supposed to be Warn ALL OPS, using this to substitute /say + public const string NAME = @"WALLOPS"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/WhoCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/WhoCommand.cs new file mode 100644 index 0000000..aae6563 --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/WhoCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class WhoCommand : IClientCommand { + public const string NAME = @"WHO"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // return info about users + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/WhoIsCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/WhoIsCommand.cs new file mode 100644 index 0000000..7e81b3b --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/WhoIsCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class WhoIsCommand : IClientCommand { + public const string NAME = @"WHOIS"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns more info + } + } +} diff --git a/SharpChat.Protocol.IRC/ClientCommands/WhoWasCommand.cs b/SharpChat.Protocol.IRC/ClientCommands/WhoWasCommand.cs new file mode 100644 index 0000000..7fcac1e --- /dev/null +++ b/SharpChat.Protocol.IRC/ClientCommands/WhoWasCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.ClientCommands { + public class WhoWasCommand : IClientCommand { + public const string NAME = @"WHOWAS"; + + public string CommandName => NAME; + public bool RequireSession => true; + + public void HandleCommand(ClientCommandContext args) { + // returns the obituary of a deceased user + } + } +} diff --git a/SharpChat.Protocol.IRC/IRCConnection.cs b/SharpChat.Protocol.IRC/IRCConnection.cs new file mode 100644 index 0000000..13fcd4b --- /dev/null +++ b/SharpChat.Protocol.IRC/IRCConnection.cs @@ -0,0 +1,118 @@ +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Protocol.IRC.ServerCommands; +using SharpChat.Protocol.IRC.Users; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace SharpChat.Protocol.IRC { + public class IRCConnection : IConnection { + public const int ID_LENGTH = 16; + + public string ConnectionId { get; } + public IPAddress RemoteAddress { get; } + public bool IsSecure { get; } + + public bool IsAvailable => Socket.Connected; + + public Socket Socket { get; } + private readonly object Sync = new(); + + public bool IsAuthenticating { get; set; } + public bool HasAuthenticated { get; set; } + public string Password { get; set; } + + public DateTimeOffset LastPing { get; set; } + + private IRCServer Server { get; } + + public ISession Session { get; set; } + + public IRCConnection(IRCServer server, Socket sock) { + Socket = sock ?? throw new ArgumentNullException(nameof(sock)); + Server = server ?? throw new ArgumentNullException(nameof(server)); + ConnectionId = @"IRC!" + RNG.NextString(ID_LENGTH); + RemoteAddress = sock.RemoteEndPoint is IPEndPoint ipep ? ipep.Address : IPAddress.None; + } + + public void SendCommand(IServerCommand command) { + StringBuilder sb = new(); + + // Sender + sb.Append(IRCServer.PREFIX); + IUser sender = command.Sender; + if(sender != null) { + sb.Append(sender.GetIRCName()); + sb.Append('!'); + sb.Append(sender.UserName); + sb.Append('@'); + } + sb.Append(Server.ServerHost); + sb.Append(' '); + + // Command + sb.Append(command.CommandName); + sb.Append(' '); + + // Contents + sb.Append(command.GetLine()); + sb.Append(IServerCommand.CRLF); + + Send(sb); + } + + public void SendReply(IReply reply) { + StringBuilder sb = new(); + + // Server + sb.Append(IRCServer.PREFIX); + sb.Append(Server.ServerHost); + sb.Append(' '); + + // Reply code + sb.AppendFormat(@"{0:000}", reply.ReplyCode); + sb.Append(' '); + + // Receiver + if(Session == null) + sb.Append('-'); + else + sb.Append(Session.User.GetIRCName()); + sb.Append(' '); + + // Contents + sb.Append(reply.GetLine()); + sb.Append(IReply.CRLF); + + Send(sb); + } + + public int Receive(byte[] buffer) { + lock(Sync) + return Socket.Receive(buffer); + } + + private int Send(object obj) { + lock(Sync) + return Socket.Send(Encoding.UTF8.GetBytes(obj.ToString())); + } + + public void Close() { + lock(Sync) { + Password = null; + + try { + Socket.Shutdown(SocketShutdown.Both); + } finally { + Socket.Dispose(); + } + } + } + + public override string ToString() + => $@"C#{ConnectionId}"; + } +} diff --git a/SharpChat.Protocol.IRC/IRCConnectionList.cs b/SharpChat.Protocol.IRC/IRCConnectionList.cs new file mode 100644 index 0000000..48bcff1 --- /dev/null +++ b/SharpChat.Protocol.IRC/IRCConnectionList.cs @@ -0,0 +1,64 @@ +using SharpChat.Channels; +using SharpChat.Sessions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; + +namespace SharpChat.Protocol.IRC { + public class IRCConnectionList : ConnectionList { + // it's not there if you don't look at it + private Dictionary Connections { get; } = new(); + private Dictionary Sockets { get; } = new(); + private readonly object Sync = new(); + + public IRCConnectionList( + SessionManager sessions, + ChannelUserRelations channelUsers + ) : base(sessions, channelUsers) { } + + public override void AddConnection(IRCConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + lock(Sync) { + Connections.Add(conn.Socket, conn); + Sockets.Add(conn, conn.Socket); + base.AddConnection(conn); + } + } + + public override void RemoveConnection(IRCConnection conn) { + if(conn == null) + throw new ArgumentNullException(nameof(conn)); + lock(Sync) { + Connections.Remove(conn.Socket); + Sockets.Remove(conn); + base.RemoveConnection(conn); + } + } + + public void GetConnection(Socket sock, Action callback) { + if(sock == null) + throw new ArgumentNullException(nameof(sock)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) + callback(Connections.TryGetValue(sock, out IRCConnection conn) ? conn : null); + } + + public void GetReadyConnections(Action> callback) { + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + lock(Sync) { + if(!Connections.Any()) { + callback(Enumerable.Empty()); + return; + } + + List sockets = new(Sockets.Values); + Socket.Select(sockets, null, null, 5000000); + callback(sockets.Select(s => Connections[s])); + } + } + } +} diff --git a/SharpChat.Protocol.IRC/IRCServer.cs b/SharpChat.Protocol.IRC/IRCServer.cs new file mode 100644 index 0000000..31245eb --- /dev/null +++ b/SharpChat.Protocol.IRC/IRCServer.cs @@ -0,0 +1,340 @@ +using SharpChat.Channels; +using SharpChat.Configuration; +using SharpChat.Events; +using SharpChat.Protocol.IRC.ClientCommands; +using SharpChat.Protocol.IRC.Replies; +using SharpChat.Protocol.IRC.ServerCommands; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace SharpChat.Protocol.IRC { + [Server(@"irc")] + public class IRCServer : IServer { + private const int BUFFER_SIZE = 2048; + + public const char PREFIX = ':'; + + private Context Context { get; } + private IConfig Config { get; } + private Socket Socket { get; set; } + + private IRCConnectionList Connections { get; } + private IReadOnlyDictionary Commands { get; } + + private bool IsRunning { get; set; } + + private byte[] Buffer { get; } = new byte[BUFFER_SIZE]; + + // I feel like these two could be generalised + private CachedValue ServerHostValue { get; } + private CachedValue NetworkNameValue { get; } + + public string ServerHost => ServerHostValue; + public string NetworkName => NetworkNameValue; + + public IRCServer(Context ctx, IConfig config) { + Context = ctx ?? throw new ArgumentNullException(nameof(ctx)); + Config = config ?? throw new ArgumentNullException(nameof(config)); + + Connections = new IRCConnectionList(Context.Sessions, Context.ChannelUsers); + + Context.Events.AddEventHandler(this); + + ServerHostValue = Config.ReadCached(@"host", @"irc.example.com"); + NetworkNameValue = Config.ReadCached(@"network", @"SharpChat"); + + Dictionary handlers = new(); + void addHandler(IClientCommand handler) { + handlers.Add(handler.CommandName, handler); + }; + + addHandler(new AdminCommand(this)); + addHandler(new AwayCommand(Context.Users)); + addHandler(new CapabilitiesCommand()); + addHandler(new InfoCommand()); + addHandler(new InviteCommand(Context.Users, Context.Channels, Context.ChannelUsers)); + addHandler(new IsOnCommand(Context.Users)); + addHandler(new JoinCommand(Context.Channels, Context.ChannelUsers)); + addHandler(new KickCommand()); + addHandler(new KillCommand()); + addHandler(new ListCommand(Context.Channels, Context.ChannelUsers)); + addHandler(new ListUsersCommand()); + addHandler(new MessageOfTheDayCommand()); + addHandler(new ModeCommand(Context.Channels, Context.Users, Context.Sessions)); + addHandler(new NamesCommand()); + addHandler(new NickCommand(Context.Users)); + addHandler(new NoticeCommand()); + addHandler(new PartCommand(Context.Channels, Context.ChannelUsers)); + addHandler(new PassCommand()); + addHandler(new PingCommand(this, Context.Sessions)); + addHandler(new PrivateMessageCommand(Context.Channels, Context.ChannelUsers, Context.Messages)); + addHandler(new QuitCommand(Context.Sessions)); + addHandler(new ServerQuitCommand()); + addHandler(new ServiceCommand()); + addHandler(new ServiceListCommand()); + addHandler(new ServiceQueryCommand()); + addHandler(new SilenceCommand()); + addHandler(new StatsCommand()); + addHandler(new SummonCommand()); + addHandler(new TimeCommand()); + addHandler(new TopicCommand()); + addHandler(new UserCommand( + this, + Context, + Context.Users, + Context.Channels, + Context.ChannelUsers, + Context.Sessions, + Context.DataProvider.UserClient, + Context.Bans, + Context.WelcomeMessage + )); + addHandler(new UserHostCommand()); + addHandler(new VersionCommand()); + addHandler(new WAllOpsCommand()); + addHandler(new WhoCommand()); + addHandler(new WhoIsCommand()); + addHandler(new WhoWasCommand()); + + Commands = handlers; + } + + public void Listen(EndPoint endPoint) { + if(Socket != null) + throw new ProtocolAlreadyListeningException(); + if(endPoint == null) + throw new ArgumentNullException(nameof(endPoint)); + Socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + Socket.Bind(endPoint); + Socket.NoDelay = true; + Socket.Blocking = false; + Socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); + Socket.Listen(10); + + IsRunning = true; + new Thread(Worker).Start(); + } + + private void Worker() { + while(IsRunning) + try { + if(Socket.Poll(1000000, SelectMode.SelectRead)) + Connections.AddConnection(new IRCConnection(this, Socket.Accept())); + + Connections.GetReadyConnections(conns => { + foreach(IRCConnection conn in conns) { + try { + int read = conn.Receive(Buffer); + string[] lines = Encoding.UTF8.GetString(Buffer, 0, read).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach(string line in lines) + OnReceive(conn, line); + } catch(SocketException ex) { + Logger.Write($@"[IRC] Socket Error: {ex}"); + } + } + + // check pings + + Connections.GetDeadConnections(conns => { + Queue dead = new(conns); + while(dead.TryDequeue(out IRCConnection conn)) { + Connections.RemoveConnection(conn); + Context.Sessions.Destroy(conn); + } + }); + }); + } catch(Exception ex) { + Logger.Write($@"[IRC] {ex}"); + } + } + + private void OnReceive(IRCConnection conn, string line) { + // do rate limiting + + string commandName = null; + List args = new(); + + try { + int i = 0; + + // read prefix, if there is any. + // might be unnecessary, might only be server to server which will never happen + if(line[0] == PREFIX) + while(line[++i] != ' '); + + int commandStart = i; + while((i < (line.Length - 1)) && line[++i] != ' '); + if(line.Length - 1 == i) + ++i; + commandName = line[commandStart..i]; + + int paramStart = ++i; + while(i < line.Length) { + if(line[i] == ' ' && i != paramStart) { + args.Add(line[paramStart..i]); + paramStart = i + 1; + } + + if(line[i] == PREFIX) { + if(paramStart != i) + args.Add(line[paramStart..i]); + args.Add(line[(i + 1)..]); + break; + } + + ++i; + } + + if(i == line.Length) + args.Add(line[paramStart..]); + } catch(IndexOutOfRangeException) { + Logger.Debug($@"Invalid message: {line}"); + } + + if(commandName == null) + return; + + args.RemoveAll(string.IsNullOrWhiteSpace); + + if(Commands.TryGetValue(commandName, out IClientCommand command)) { + if(command.RequireSession && conn.Session == null) { + conn.SendReply(new PasswordMismatchReply()); + return; + } + + command.HandleCommand(new ClientCommandContext(conn, args)); + } + } + + // see comment in SockChatServer class + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case SessionPingEvent spe: + Connections.GetConnectionBySessionId(spe.SessionId, conn => { + if(conn == null) + return; + conn.LastPing = spe.DateTime; + }); + break; + + case MessageCreateEvent mce: + Context.Channels.GetChannelById(mce.ChannelId, channel => { + if(channel == null) + return; + + Context.Users.GetUser(mce.UserId, user => { + Queue msgs = new(ServerPrivateMessageCommand.Split(channel, user, mce.Text)); + Connections.GetConnections(channel, conns => { + conns = conns.Where(c => !mce.ConnectionId.Equals(c.ConnectionId)); + while(msgs.TryDequeue(out ServerPrivateMessageCommand spmc)) + foreach(IRCConnection conn in conns) + conn.SendCommand(spmc); + }); + }); + }); + break; + + //case MessageUpdateEvent mue: + //IMessage msg = Context.Messages.GetMessage(mue.MessageId); + + //break; + + + // these events need revising + case ChannelUserJoinEvent cuje: + Context.Channels.GetChannelById(cuje.ChannelId, channel => { + if(channel == null) + return; + + Context.Users.GetUser(cuje.UserId, user => { + if(user == null) + return; + + ServerJoinCommand sjc = new(channel, user); + Connections.GetConnections(channel, conns => { + conns = conns.Where(c => !user.Equals(c.Session?.User)); + foreach(IRCConnection conn in conns) + conn.SendCommand(sjc); + }); + }); + + UserJoinChannel(channel, cuje.SessionId); + }); + break; + case ChannelSessionJoinEvent csje: + Context.Channels.GetChannelById(csje.ChannelId, channel => { + if(channel == null) + return; + + Context.Users.GetUser(csje.UserId, user => { + if(user == null) + return; + + UserJoinChannel(channel, csje.SessionId); + }); + }); + break; + + case ChannelUserLeaveEvent cule: + Context.Channels.GetChannelById(cule.ChannelId, channel => { + if(channel == null) + return; + + Context.Users.GetUser(cule.UserId, user => { + if(user == null) + return; + + ServerPartCommand spc = new(channel, user, cule.Reason); + Connections.GetConnections(channel, conns => { + foreach(IRCConnection conn in conns) + conn.SendCommand(spc); + }); + }); + }); + break; + } + } + + private void UserJoinChannel(IChannel channel, string sessionId) { + Context.Sessions.GetLocalSession(sessionId, session => { + if(session == null || session.Connection is not IRCConnection csjec) + return; + + csjec.SendCommand(new ServerJoinCommand(channel, session.User)); + if(string.IsNullOrEmpty(channel.Topic)) + csjec.SendReply(new NoTopicReply(channel)); + else + csjec.SendReply(new TopicReply(channel)); + Context.ChannelUsers.GetUsers(channel, users => NamesReply.SendBatch(csjec, channel, users)); + csjec.SendReply(new EndOfNamesReply(channel)); + }); + } + + private bool IsDisposed; + ~IRCServer() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Context.Events.RemoveEventHandler(this); + + IsRunning = false; + + if(Socket != null) { + // kill all connections + + Socket.Dispose(); + } + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/AdminEMailReply.cs b/SharpChat.Protocol.IRC/Replies/AdminEMailReply.cs new file mode 100644 index 0000000..f06f0cc --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/AdminEMailReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class AdminEMailReply : Reply { + public const int CODE = 259; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // e -mail add ress + return @":irc@railgun.sh"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/AdminLocation1Reply.cs b/SharpChat.Protocol.IRC/Replies/AdminLocation1Reply.cs new file mode 100644 index 0000000..40f81ad --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/AdminLocation1Reply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class AdminLocation1Reply : Reply { + public const int CODE = 257; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: not static? + return @":Microsoft Windows XP"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/AdminLocation2Reply.cs b/SharpChat.Protocol.IRC/Replies/AdminLocation2Reply.cs new file mode 100644 index 0000000..47f944a --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/AdminLocation2Reply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class AdminLocation2Reply : Reply { + public const int CODE = 258; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: not static? + return @":Fundamentals for Legacy PCs"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/AdminMeReply.cs b/SharpChat.Protocol.IRC/Replies/AdminMeReply.cs new file mode 100644 index 0000000..934659c --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/AdminMeReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class AdminMeReply : Reply { + public const int CODE = 256; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo make server name not static + return @"irc.railgun.sh :Administrative info"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/AlreadyRegisteredReply.cs b/SharpChat.Protocol.IRC/Replies/AlreadyRegisteredReply.cs new file mode 100644 index 0000000..ba68edb --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/AlreadyRegisteredReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class AlreadyRegisteredReply : IReply { + public const int CODE = 462; + public const string LINE = @":You're already authenticated."; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/BadChannelKeyReply.cs b/SharpChat.Protocol.IRC/Replies/BadChannelKeyReply.cs new file mode 100644 index 0000000..f90eb93 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/BadChannelKeyReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class BadChannelKeyReply : Reply { + public const int CODE = 475; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public BadChannelKeyReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :Cannot join channel (+k)"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/BadChannelMaskReply.cs b/SharpChat.Protocol.IRC/Replies/BadChannelMaskReply.cs new file mode 100644 index 0000000..26f873a --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/BadChannelMaskReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class BadChannelMaskReply : Reply { + public const int CODE = 476; + + public override int ReplyCode => CODE; + + private string ChannelName { get; } + + public BadChannelMaskReply(string channelName) { + ChannelName = channelName ?? throw new ArgumentNullException(nameof(channelName)); + } + + protected override string BuildLine() { + return $@"{ChannelName} :Bad Channel Mask"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/CannotSendToChannelReply.cs b/SharpChat.Protocol.IRC/Replies/CannotSendToChannelReply.cs new file mode 100644 index 0000000..acb857c --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/CannotSendToChannelReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class CannotSendToChannelReply : Reply { + public const int CODE = 404; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public CannotSendToChannelReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :Cannot send to channel"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ChannelIsFullReply.cs b/SharpChat.Protocol.IRC/Replies/ChannelIsFullReply.cs new file mode 100644 index 0000000..5158122 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ChannelIsFullReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class ChannelIsFullReply : Reply { + public const int CODE = 471; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public ChannelIsFullReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :Cannot join channel (+l)"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/CreatedReply.cs b/SharpChat.Protocol.IRC/Replies/CreatedReply.cs new file mode 100644 index 0000000..624f18d --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/CreatedReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class CreatedReply : Reply { + public const int CODE = 3; + + public override int ReplyCode => CODE; + + private Context Context { get; } + + public CreatedReply(Context ctx) { + Context = ctx ?? throw new ArgumentNullException(nameof(ctx)); + } + + protected override string BuildLine() { + return $@":This server was created {Context.Created:r}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/EndOfInfoReply.cs b/SharpChat.Protocol.IRC/Replies/EndOfInfoReply.cs new file mode 100644 index 0000000..c659ba5 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/EndOfInfoReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class EndOfInfoReply : IReply { + public const int CODE = 374; + public const string LINE = @":End of INFO list"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/EndOfNamesReply.cs b/SharpChat.Protocol.IRC/Replies/EndOfNamesReply.cs new file mode 100644 index 0000000..b3f79f1 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/EndOfNamesReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class EndOfNamesReply : Reply { + public const int CODE = 366; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public EndOfNamesReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :End of NAMES list"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ErroneousNickNameReply.cs b/SharpChat.Protocol.IRC/Replies/ErroneousNickNameReply.cs new file mode 100644 index 0000000..13619a6 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ErroneousNickNameReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class ErroneousNickNameReply : Reply { + public const int CODE = 432; + + public override int ReplyCode => CODE; + + private string NickName { get; } + + public ErroneousNickNameReply(string nickName) { + NickName = nickName ?? throw new ArgumentNullException(nameof(nickName)); + } + + protected override string BuildLine() { + return $@"{NickName} :Erroneous nickname"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/IReply.cs b/SharpChat.Protocol.IRC/Replies/IReply.cs new file mode 100644 index 0000000..0059eb2 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/IReply.cs @@ -0,0 +1,9 @@ +namespace SharpChat.Protocol.IRC.Replies { + public interface IReply { + public const string CRLF = "\r\n"; + + int ReplyCode { get; } + + string GetLine(); + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ISupportReply.cs b/SharpChat.Protocol.IRC/Replies/ISupportReply.cs new file mode 100644 index 0000000..f0f4c12 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ISupportReply.cs @@ -0,0 +1,43 @@ +using System; +using System.Text; + +namespace SharpChat.Protocol.IRC.Replies { + public class ISupportReply : Reply { + public const int CODE = 5; + + public override int ReplyCode => CODE; + + private IRCServer Server { get; } + + public ISupportReply(IRCServer server) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + } + + protected override string BuildLine() { + StringBuilder sb = new(); + + // todo: make this numbers read from configs and share them across core + sb.Append(@"AWAYLEN=5 "); // internally this is actually 100, but leaving it at 5 until sock chat has the proper Facilities + sb.Append(@"CASEMAPPING=ascii "); + sb.Append(@"CHANLIMIT=#:10 "); // Limit channels to 1 (except for DMs) until Sock Chat v2 + sb.Append(@"CHANMODES=b,k,l,PTimnpstz "); + sb.Append(@"CHANNELLEN=40 "); // Determine this + sb.Append(@"CHANTYPES=# "); + sb.Append(@"FNC "); + sb.Append(@"KEYLEN=32 "); // propagate this to /password in sokc chat + sb.Append(@"KICKLEN=300 "); + sb.Append(@"MAXNICKLEN=16 "); // what the fuck is the difference between this and NICKLEN, i don't get it + sb.AppendFormat(@"NETWORK={0} ", Server.NetworkName); + sb.Append(@"NICKLEN=16 "); + sb.Append(@"OVERRIDE "); + sb.Append(@"PREFIX=(qaohv)~&@%+ "); + sb.Append(@"RFC2812 "); + sb.Append(@"SAFELIST "); + sb.Append(@"STD=i-d "); + sb.Append(@"TOPICLEN=300 "); + sb.Append(@": are supported by this server"); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/InfoReply.cs b/SharpChat.Protocol.IRC/Replies/InfoReply.cs new file mode 100644 index 0000000..b0afc73 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/InfoReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class InfoReply : Reply { + public const int CODE = 371; + public const string LINE = @":Isn't it adorable when a spec marks something as REQUIRED but then gives no implementation details?"; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/IsOnReply.cs b/SharpChat.Protocol.IRC/Replies/IsOnReply.cs new file mode 100644 index 0000000..1fdc47b --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/IsOnReply.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.IRC.Replies { + public class IsOnReply : Reply { + public const int CODE = 303; + + public override int ReplyCode => CODE; + + private IEnumerable UserNames { get; } + + public IsOnReply(IEnumerable userNames) { + UserNames = userNames ?? throw new ArgumentNullException(nameof(userNames)); + } + + protected override string BuildLine() { + return string.Join(' ', UserNames); + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListEndReply.cs b/SharpChat.Protocol.IRC/Replies/ListEndReply.cs new file mode 100644 index 0000000..2930dd9 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListEndReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class ListEndReply : IReply { + public const int CODE = 323; + public const string LINE = @":End of LIST"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListItemReply.cs b/SharpChat.Protocol.IRC/Replies/ListItemReply.cs new file mode 100644 index 0000000..3653154 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListItemReply.cs @@ -0,0 +1,36 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; +using System.Text; + +namespace SharpChat.Protocol.IRC.Replies { + public class ListItemReply : Reply { + public const int CODE = 322; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + private int UserCount { get; } + + public ListItemReply(IChannel channel, int userCount) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + + // some irc clients don't show channels unless they actually have people in them, + // so always show at least 1 user + UserCount = Math.Max(1, userCount); + } + + protected override string BuildLine() { + StringBuilder sb = new(); + + sb.Append(Channel.GetIRCName()); + sb.Append(' '); + sb.Append(UserCount); + sb.Append(' '); + sb.Append(':'); + sb.Append(Channel.Topic); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListUserChannelsReply.cs b/SharpChat.Protocol.IRC/Replies/ListUserChannelsReply.cs new file mode 100644 index 0000000..b0cebdd --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListUserChannelsReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class ListUserChannelsReply : Reply { + public const int CODE = 254; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: count channels actually + return @"60 :channels formed"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListUserClientReply.cs b/SharpChat.Protocol.IRC/Replies/ListUserClientReply.cs new file mode 100644 index 0000000..0e1d0ca --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListUserClientReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class ListUserClientReply : Reply { + public const int CODE = 251; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: make this real + return @":There are 810 users and 25 services on 3510 servers"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListUserMeReply.cs b/SharpChat.Protocol.IRC/Replies/ListUserMeReply.cs new file mode 100644 index 0000000..1d29c7e --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListUserMeReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class ListUserMeReply : Reply { + public const int CODE = 255; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: count local users and perhaps actually expose multi server lol + return @":I have 500 users and 2 servers"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListUserOperatorsReply.cs b/SharpChat.Protocol.IRC/Replies/ListUserOperatorsReply.cs new file mode 100644 index 0000000..f313a57 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListUserOperatorsReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class ListUserOperatorsReply : Reply { + public const int CODE = 252; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: count users with a high rank or something + return @"1 :operator(s) online"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/ListUserUnknownReply.cs b/SharpChat.Protocol.IRC/Replies/ListUserUnknownReply.cs new file mode 100644 index 0000000..b747825 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/ListUserUnknownReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class ListUserUnknownReply : Reply { + public const int CODE = 253; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: count unknown connections? whatever the fuck that means + return @"0 :unknown connection(s)"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/MotdEndReply.cs b/SharpChat.Protocol.IRC/Replies/MotdEndReply.cs new file mode 100644 index 0000000..4ae3501 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/MotdEndReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class MotdEndReply : IReply { + public const int CODE = 376; + public const string LINE = @":END of MOTD command"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/MotdReply.cs b/SharpChat.Protocol.IRC/Replies/MotdReply.cs new file mode 100644 index 0000000..390d6f0 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/MotdReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class MotdReply : Reply { + public const int CODE = 372; + + public override int ReplyCode => CODE; + + private string Line { get; } + + public MotdReply(string line) { + Line = line ?? throw new ArgumentNullException(nameof(line)); + } + + protected override string BuildLine() { + return Line; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/MotdStartReply.cs b/SharpChat.Protocol.IRC/Replies/MotdStartReply.cs new file mode 100644 index 0000000..61841d8 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/MotdStartReply.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.IRC.Replies { + public class MotdStartReply : Reply { + public const int CODE = 375; + + public override int ReplyCode => CODE; + + protected override string BuildLine() { + // todo: unstatic address + return @":- irc.railgun.sh Message of the day -"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/MyInfoReply.cs b/SharpChat.Protocol.IRC/Replies/MyInfoReply.cs new file mode 100644 index 0000000..430e2e4 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/MyInfoReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class MyInfoReply : Reply { + public const int CODE = 4; + + public override int ReplyCode => CODE; + + private IRCServer Server { get; } + + public MyInfoReply(IRCServer server) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + } + + protected override string BuildLine() { + return $@":{Server.ServerHost} {SharpInfo.ProgramName} ABCaiowxz PTahklmnoqstvz"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NamesReply.cs b/SharpChat.Protocol.IRC/Replies/NamesReply.cs new file mode 100644 index 0000000..5e1b3c2 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NamesReply.cs @@ -0,0 +1,53 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.IRC.Replies { + public class NamesReply : Reply { + public const int CODE = 353; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + private IEnumerable UserNames { get; } + + public NamesReply(IChannel channel, IEnumerable userNames) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + UserNames = userNames ?? throw new ArgumentNullException(nameof(userNames)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCNamesPrefix()} {Channel.GetIRCName()} :{string.Join(' ', UserNames)}"; + } + + public static void SendBatch(IRCConnection conn, IChannel channel, IEnumerable users) { + const int max_length = 400; // allow for 112 characters of overhead + int length = 0; + List userNames = new(); + + void sendBatch() { + if(length < 1) + return; + conn.SendReply(new NamesReply(channel, userNames)); + length = 0; + userNames.Clear(); + }; + + foreach(IUser user in users) { + string name = user.GetIRCName(channel); + int nameLength = name.Length + 1; + + if(length + nameLength > max_length) + sendBatch(); + + length += nameLength; + userNames.Add(name); + } + + sendBatch(); + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NeedMoreParamsReply.cs b/SharpChat.Protocol.IRC/Replies/NeedMoreParamsReply.cs new file mode 100644 index 0000000..07b8fb4 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NeedMoreParamsReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NeedMoreParamsReply : Reply { + public const int CODE = 461; + + public override int ReplyCode => CODE; + + private string CommandName { get; } + + public NeedMoreParamsReply(string commandName) { + CommandName = commandName ?? throw new ArgumentNullException(nameof(commandName)); + } + + protected override string BuildLine() { + return $@"{CommandName} :Not enough parameters"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NickNameInUseReply.cs b/SharpChat.Protocol.IRC/Replies/NickNameInUseReply.cs new file mode 100644 index 0000000..b6caca8 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NickNameInUseReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NickNameInUseReply : Reply { + public const int CODE = 433; + + public override int ReplyCode => CODE; + + private string NickName { get; } + + public NickNameInUseReply(string nickName) { + NickName = nickName ?? throw new ArgumentNullException(nameof(nickName)); + } + + protected override string BuildLine() { + return $@"{NickName} :Nickname is already in use"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoMotdReply.cs b/SharpChat.Protocol.IRC/Replies/NoMotdReply.cs new file mode 100644 index 0000000..4c147f6 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoMotdReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class NoMotdReply : IReply { + public const int CODE = 422; + public const string LINE = @":There is no MOTD"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoNickNameGivenReply.cs b/SharpChat.Protocol.IRC/Replies/NoNickNameGivenReply.cs new file mode 100644 index 0000000..32c112b --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoNickNameGivenReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class NoNickNameGivenReply : IReply { + public const int CODE = 431; + public const string LINE = @":No nickname given"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoOperatorHostReply.cs b/SharpChat.Protocol.IRC/Replies/NoOperatorHostReply.cs new file mode 100644 index 0000000..8f56bfb --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoOperatorHostReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class NoOperatorHostReply : IReply { + public const int CODE = 491; + public const string LINE = @":No O-lines for your host"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoPrivilegesReply.cs b/SharpChat.Protocol.IRC/Replies/NoPrivilegesReply.cs new file mode 100644 index 0000000..0fb9b76 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoPrivilegesReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class NoPrivilegesReply : IReply { + public const int CODE = 481; + public const string LINE = @":Permission Denied- You're not an IRC operator"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoRecipientReply.cs b/SharpChat.Protocol.IRC/Replies/NoRecipientReply.cs new file mode 100644 index 0000000..6241d93 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoRecipientReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NoRecipientReply : Reply { + public const int CODE = 411; + + public override int ReplyCode => CODE; + + private string Command { get; } + + public NoRecipientReply(string command) { + Command = command ?? throw new ArgumentNullException(nameof(command)); + } + + protected override string BuildLine() { + return $@":No recipient given ({Command})"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoSuchChannelReply.cs b/SharpChat.Protocol.IRC/Replies/NoSuchChannelReply.cs new file mode 100644 index 0000000..ca66983 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoSuchChannelReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NoSuchChannelReply : Reply { + public const int CODE = 403; + + public override int ReplyCode => CODE; + + private string ChannelName { get; } + + public NoSuchChannelReply(string channelName) { + ChannelName = channelName ?? throw new ArgumentNullException(nameof(channelName)); + } + + protected override string BuildLine() { + return $@"{ChannelName} :Channel not found."; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoSuchNickReply.cs b/SharpChat.Protocol.IRC/Replies/NoSuchNickReply.cs new file mode 100644 index 0000000..d387466 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoSuchNickReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NoSuchNickReply : Reply { + public const int CODE = 401; + + public override int ReplyCode => CODE; + + private string UserName { get; } + + public NoSuchNickReply(string userName) { + UserName = userName ?? throw new ArgumentNullException(nameof(userName)); + } + + protected override string BuildLine() { + return $@"{UserName} :User not found."; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoTextToSendReply.cs b/SharpChat.Protocol.IRC/Replies/NoTextToSendReply.cs new file mode 100644 index 0000000..2692f68 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoTextToSendReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class NoTextToSendReply : IReply { + public const int CODE = 412; + public const string LINE = @":No text to send"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NoTopicReply.cs b/SharpChat.Protocol.IRC/Replies/NoTopicReply.cs new file mode 100644 index 0000000..464f5ea --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NoTopicReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NoTopicReply : Reply { + public const int CODE = 331; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public NoTopicReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :No topic is set"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/NotOnChannelReply.cs b/SharpChat.Protocol.IRC/Replies/NotOnChannelReply.cs new file mode 100644 index 0000000..eec4347 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/NotOnChannelReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class NotOnChannelReply : Reply { + public const int CODE = 442; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public NotOnChannelReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :You're not on that channel"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/PasswordMismatchReply.cs b/SharpChat.Protocol.IRC/Replies/PasswordMismatchReply.cs new file mode 100644 index 0000000..8fe09d6 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/PasswordMismatchReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class PasswordMismatchReply : IReply { + public const int CODE = 464; + public const string LINE = @":Authentication failed."; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/Reply.cs b/SharpChat.Protocol.IRC/Replies/Reply.cs new file mode 100644 index 0000000..4df1edc --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/Reply.cs @@ -0,0 +1,14 @@ +namespace SharpChat.Protocol.IRC.Replies { + public abstract class Reply : IReply { + public abstract int ReplyCode { get; } + private string Line { get; set; } + + protected abstract string BuildLine(); + + public string GetLine() { + if(Line == null) + Line = BuildLine(); + return Line; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/RestrictedReply.cs b/SharpChat.Protocol.IRC/Replies/RestrictedReply.cs new file mode 100644 index 0000000..06c388e --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/RestrictedReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class RestrictedReply : IReply { + public const int CODE = 484; + public const string LINE = @":Your connection is restricted!"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/TopicReply.cs b/SharpChat.Protocol.IRC/Replies/TopicReply.cs new file mode 100644 index 0000000..d8a2d02 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/TopicReply.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class TopicReply : Reply { + public const int CODE = 332; + + public override int ReplyCode => CODE; + + private IChannel Channel { get; } + + public TopicReply(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :{Channel.Topic}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/UserModeIsReply.cs b/SharpChat.Protocol.IRC/Replies/UserModeIsReply.cs new file mode 100644 index 0000000..e7ab151 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/UserModeIsReply.cs @@ -0,0 +1,23 @@ +using SharpChat.Protocol.IRC.Users; +using SharpChat.Users; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class UserModeIsReply : Reply { + public const int CODE = 221; + + public override int ReplyCode => CODE; + + private IUser User { get; } + private bool IsSecure { get; } + + public UserModeIsReply(IUser user, bool isSecure) { + User = user ?? throw new ArgumentNullException(nameof(user)); + IsSecure = isSecure; + } + + protected override string BuildLine() { + return '+' + User.GetIRCModeString(IsSecure); + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/UserModeUnknownFlagReply.cs b/SharpChat.Protocol.IRC/Replies/UserModeUnknownFlagReply.cs new file mode 100644 index 0000000..1c8a097 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/UserModeUnknownFlagReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class UserModeUnknownFlagReply : IReply { + public const int CODE = 501; + public const string LINE = @":Unknown MODE flag"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/UserOnChannelReply.cs b/SharpChat.Protocol.IRC/Replies/UserOnChannelReply.cs new file mode 100644 index 0000000..f91488a --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/UserOnChannelReply.cs @@ -0,0 +1,25 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Users; +using SharpChat.Users; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class UserOnChannelReply : Reply { + public const int CODE = 443; + + public override int ReplyCode => CODE; + + private IUser User { get; } + private IChannel Channel { get; } + + public UserOnChannelReply(IUser user, IChannel channel) { + User = user ?? throw new ArgumentNullException(nameof(user)); + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string BuildLine() { + return $@"{User.GetIRCName()} {Channel.GetIRCName()} :is already on channel"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/UsersDoNotMatchReply.cs b/SharpChat.Protocol.IRC/Replies/UsersDoNotMatchReply.cs new file mode 100644 index 0000000..b6e4fb5 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/UsersDoNotMatchReply.cs @@ -0,0 +1,12 @@ +namespace SharpChat.Protocol.IRC.Replies { + public class UsersDoNotMatchReply : IReply { + public const int CODE = 502; + public const string LINE = @":Cannot change mode for other users"; + + public int ReplyCode => CODE; + + public string GetLine() { + return LINE; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/WelcomeReply.cs b/SharpChat.Protocol.IRC/Replies/WelcomeReply.cs new file mode 100644 index 0000000..e036372 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/WelcomeReply.cs @@ -0,0 +1,24 @@ +using SharpChat.Protocol.IRC.Users; +using SharpChat.Users; +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class WelcomeReply : Reply { + public const int CODE = 1; + + public override int ReplyCode => CODE; + + private IRCServer Server { get; } + private IUser User { get; } + + public WelcomeReply(IRCServer server, IUser user) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + protected override string BuildLine() { + // todo: allow customisation + return $@":Welcome to {Server.NetworkName} IRC {User.GetIRCMask(Server)}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/YouAreBannedReply.cs b/SharpChat.Protocol.IRC/Replies/YouAreBannedReply.cs new file mode 100644 index 0000000..660e8c1 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/YouAreBannedReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class YouAreBannedReply : Reply { + public const int CODE = 465; + + public override int ReplyCode => CODE; + + private string Reason { get; } + + public YouAreBannedReply(string reason) { + Reason = reason ?? throw new ArgumentNullException(nameof(reason)); + } + + protected override string BuildLine() { + return $@":{Reason}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/Replies/YourHostReply.cs b/SharpChat.Protocol.IRC/Replies/YourHostReply.cs new file mode 100644 index 0000000..6700cd9 --- /dev/null +++ b/SharpChat.Protocol.IRC/Replies/YourHostReply.cs @@ -0,0 +1,19 @@ +using System; + +namespace SharpChat.Protocol.IRC.Replies { + public class YourHostReply : Reply { + public const int CODE = 2; + + public override int ReplyCode => CODE; + + private IRCServer Server { get; } + + public YourHostReply(IRCServer server) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + } + + protected override string BuildLine() { + return $@":Your host is {Server.ServerHost}, running version {SharpInfo.ProgramName}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/ServerCommands/IServerCommand.cs b/SharpChat.Protocol.IRC/ServerCommands/IServerCommand.cs new file mode 100644 index 0000000..f36e221 --- /dev/null +++ b/SharpChat.Protocol.IRC/ServerCommands/IServerCommand.cs @@ -0,0 +1,12 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.IRC.ServerCommands { + public interface IServerCommand { + public const string CRLF = "\r\n"; + + string CommandName { get; } + IUser Sender { get; } + + string GetLine(); + } +} diff --git a/SharpChat.Protocol.IRC/ServerCommands/ServerCommand.cs b/SharpChat.Protocol.IRC/ServerCommands/ServerCommand.cs new file mode 100644 index 0000000..963c08d --- /dev/null +++ b/SharpChat.Protocol.IRC/ServerCommands/ServerCommand.cs @@ -0,0 +1,18 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.IRC.ServerCommands { + public abstract class ServerCommand : IServerCommand { + public abstract string CommandName { get; } + public virtual IUser Sender => null; + + private string Line { get; set; } + + protected abstract string BuildLine(); + + public string GetLine() { + if(Line == null) + Line = BuildLine(); + return Line; + } + } +} diff --git a/SharpChat.Protocol.IRC/ServerCommands/ServerJoinCommand.cs b/SharpChat.Protocol.IRC/ServerCommands/ServerJoinCommand.cs new file mode 100644 index 0000000..ee34a0a --- /dev/null +++ b/SharpChat.Protocol.IRC/ServerCommands/ServerJoinCommand.cs @@ -0,0 +1,24 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Users; +using System; + +namespace SharpChat.Protocol.IRC.ServerCommands { + public class ServerJoinCommand : ServerCommand { + public const string NAME = @"JOIN"; + + public override string CommandName => NAME; + + public override IUser Sender { get; } + private IChannel Channel { get; } + + public ServerJoinCommand(IChannel channel, IUser user) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Sender = user ?? throw new ArgumentNullException(nameof(user)); + } + + protected override string BuildLine() { + return Channel.GetIRCName(); + } + } +} diff --git a/SharpChat.Protocol.IRC/ServerCommands/ServerPartCommand.cs b/SharpChat.Protocol.IRC/ServerCommands/ServerPartCommand.cs new file mode 100644 index 0000000..2392c97 --- /dev/null +++ b/SharpChat.Protocol.IRC/ServerCommands/ServerPartCommand.cs @@ -0,0 +1,26 @@ +using SharpChat.Channels; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Users; +using System; + +namespace SharpChat.Protocol.IRC.ServerCommands { + public class ServerPartCommand : ServerCommand { + public const string NAME = @"PART"; + + public override string CommandName => NAME; + + public override IUser Sender { get; } + private IChannel Channel { get; } + private UserDisconnectReason Reason { get; } + + public ServerPartCommand(IChannel channel, IUser user, UserDisconnectReason reason) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Sender = user ?? throw new ArgumentNullException(nameof(user)); + Reason = reason; + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :{Reason}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/ServerCommands/ServerPongCommand.cs b/SharpChat.Protocol.IRC/ServerCommands/ServerPongCommand.cs new file mode 100644 index 0000000..d422a11 --- /dev/null +++ b/SharpChat.Protocol.IRC/ServerCommands/ServerPongCommand.cs @@ -0,0 +1,21 @@ +using System; + +namespace SharpChat.Protocol.IRC.ServerCommands { + public class ServerPongCommand : ServerCommand { + public const string NAME = @"PONG"; + + public override string CommandName => NAME; + + private IRCServer Server { get; } + private string Argument { get; } + + public ServerPongCommand(IRCServer server, string argument) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + Argument = argument ?? throw new ArgumentNullException(nameof(argument)); + } + + protected override string BuildLine() { + return $@"{IRCServer.PREFIX}{Server.ServerHost} {Argument}"; + } + } +} diff --git a/SharpChat.Protocol.IRC/ServerCommands/ServerPrivateMessageCommand.cs b/SharpChat.Protocol.IRC/ServerCommands/ServerPrivateMessageCommand.cs new file mode 100644 index 0000000..6f9367a --- /dev/null +++ b/SharpChat.Protocol.IRC/ServerCommands/ServerPrivateMessageCommand.cs @@ -0,0 +1,64 @@ +using SharpChat.Channels; +using SharpChat.Events; +using SharpChat.Messages; +using SharpChat.Protocol.IRC.Channels; +using SharpChat.Protocol.IRC.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Protocol.IRC.ServerCommands { + public class ServerPrivateMessageCommand : ServerCommand { + public const string NAME = @"PRIVMSG"; + + public override string CommandName => NAME; + + private IChannel Channel { get; } + public override IUser Sender { get; } + private string Line { get; } + + public ServerPrivateMessageCommand(IChannel channel, IUser sender, string line) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + Line = line ?? throw new ArgumentNullException(nameof(line)); + } + + protected override string BuildLine() { + return $@"{Channel.GetIRCName()} :{Line}"; + } + + public static IEnumerable Split(IMessage message) { + return Split(message.Channel, message.Sender, message.Text); + } + + public static IEnumerable Split(IChannel channel, IUser sender, string text) { + Queue parts = new(SplitText(text)); + while(parts.TryDequeue(out string part)) + yield return new ServerPrivateMessageCommand(channel, sender, part); + } + + public static IEnumerable SplitText(string text, int targetLength = 400) { + Queue lines = new(text.Split('\n')); + + static int getStartingChar(byte[] buff, int index) { + return (buff[index] & 0xC0) == 0x80 ? getStartingChar(buff, index - 1) : index; + }; + + while(lines.TryDequeue(out string rawLine)) { + byte[] line = Encoding.UTF8.GetBytes(rawLine.Trim()); + int chunks = line.Length / targetLength; + int start = 0; + + for(int i = chunks; i > 0; --i) { + int end = getStartingChar(line, start + targetLength); + yield return Encoding.UTF8.GetString(line, start, end - start); + start = end; + } + + if(line.Length > start) + yield return Encoding.UTF8.GetString(line, start, line.Length - start); + } + } + } +} diff --git a/SharpChat.Protocol.IRC/Sessions/SessionManagerExtensions.cs b/SharpChat.Protocol.IRC/Sessions/SessionManagerExtensions.cs new file mode 100644 index 0000000..a27f158 --- /dev/null +++ b/SharpChat.Protocol.IRC/Sessions/SessionManagerExtensions.cs @@ -0,0 +1,16 @@ +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Linq; + +namespace SharpChat.Protocol.IRC.Sessions { + public static class SessionManagerExtensions { + public static void CheckIRCSecure(this SessionManager sessions, IUser user, Action callback) { + if(user == null) + throw new ArgumentNullException(nameof(user)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + sessions.GetSessions(user, sessions => callback(sessions.Any() && sessions.All(s => s.IsSecure))); + } + } +} diff --git a/Hamakaze/Hamakaze.csproj b/SharpChat.Protocol.IRC/SharpChat.Protocol.IRC.csproj similarity index 54% rename from Hamakaze/Hamakaze.csproj rename to SharpChat.Protocol.IRC/SharpChat.Protocol.IRC.csproj index f208d30..5e06771 100644 --- a/Hamakaze/Hamakaze.csproj +++ b/SharpChat.Protocol.IRC/SharpChat.Protocol.IRC.csproj @@ -4,4 +4,8 @@ net5.0 + + + + diff --git a/SharpChat.Protocol.IRC/Users/IUserExtensions.cs b/SharpChat.Protocol.IRC/Users/IUserExtensions.cs new file mode 100644 index 0000000..d5d4059 --- /dev/null +++ b/SharpChat.Protocol.IRC/Users/IUserExtensions.cs @@ -0,0 +1,80 @@ +using SharpChat.Channels; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Protocol.IRC.Users { + public static class IUserExtensions { + public static string GetIRCName(this IUser user, IChannel channel = null) { + StringBuilder sb = new(); + + // SharpChat does not implement per-channel permissions besides owner + // if it did an IMember should exist + // these should also be Less Hardcoded + if(channel != null) { + if(user.UserId == 1) // flashwave + sb.Append('~'); + else if(user.Rank >= 10) // admins + sb.Append('&'); + else if(user.Rank >= 5) // mods + sb.Append('@'); + else if(user.UserId == channel.OwnerId) // channel owner + sb.Append('%'); + else if(user.Can(UserPermissions.SetOwnNickname)) // tenshi + sb.Append('+'); + } + + if(user is ILocalUser lu && !string.IsNullOrWhiteSpace(lu.NickName)) + sb.Append(lu.NickName); + else + sb.Append(user.UserName); + + return sb.ToString(); + } + + public static string GetIRCMask(this IUser user) { + return $@"{user.GetIRCName()}!{user.UserName}"; + } + + public static string GetIRCMask(this IUser user, IRCServer server) { + return $@"{user.GetIRCMask()}@{server.ServerHost}"; + } + + public static string GetIRCModeString(this IUser user, bool isSecure = false) { + StringBuilder sb = new(); + + // see GetIRCName for rank based modes + + if(user.UserId == 1) // flashwave + sb.Append('A'); // administrator + + if(user.IsBot()) // bot, make IsBot attr + sb.Append('B'); + + if(user.Rank >= 10) + sb.Append('C'); // co-admin + + if(user is ILocalUser lu) { + if(lu.Status == UserStatus.Away) + sb.Append('a'); // away + + if(lu.Status == UserStatus.Offline) + sb.Append('i'); // invisible + } + + if(user.Rank is < 10 and >= 5) + sb.Append('o'); // global mod + + sb.Append('w'); // wallops + sb.Append('x'); // host hiding + + if(isSecure) + sb.Append('z'); // secure + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Channels/IChannelExtensions.cs b/SharpChat.Protocol.SockChat/Channels/IChannelExtensions.cs new file mode 100644 index 0000000..e7a3df6 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Channels/IChannelExtensions.cs @@ -0,0 +1,21 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Channels { + public static class IChannelExtensions { + public static string Pack(this IChannel channel) { + StringBuilder sb = new(); + channel.Pack(sb); + return sb.ToString(); + } + + public static void Pack(this IChannel channel, StringBuilder sb) { + sb.Append(channel.Name); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(channel.HasPassword ? '1' : '0'); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(channel.IsTemporary ? '1' : '0'); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/AFKCommand.cs b/SharpChat.Protocol.SockChat/Commands/AFKCommand.cs new file mode 100644 index 0000000..70df661 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/AFKCommand.cs @@ -0,0 +1,34 @@ +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class AFKCommand : ICommand { + private const string DEFAULT = @"AFK"; + private const int MAX_LENGTH = 5; + + private UserManager Users { get; } + + public AFKCommand(UserManager users) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"afk"; + + public bool DispatchCommand(CommandContext ctx) { + string statusText = ctx.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(); + } + + Users.Update(ctx.User, status: UserStatus.Away, statusMessage: statusText); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/ActionCommand.cs b/SharpChat.Protocol.SockChat/Commands/ActionCommand.cs new file mode 100644 index 0000000..d6ebe57 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/ActionCommand.cs @@ -0,0 +1,25 @@ +using SharpChat.Messages; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class ActionCommand : ICommand { + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"action" or @"me"; + + private MessageManager Messages { get; } + + public ActionCommand(MessageManager messages) { + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + } + + public bool DispatchCommand(CommandContext ctx) { + if(ctx.Args.Count() < 2) + return false; + + Messages.Create(ctx.Session, ctx.Channel, string.Join(' ', ctx.Args.Skip(1)), true); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/BanListCommand.cs b/SharpChat.Protocol.SockChat/Commands/BanListCommand.cs new file mode 100644 index 0000000..a2bc15b --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/BanListCommand.cs @@ -0,0 +1,37 @@ +using SharpChat.Bans; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.SockChat.Commands { + public class BanListCommand : ICommand { + private BanManager Bans { get; } + private IUser Sender { get; } + + public BanListCommand(BanManager bans, IUser sender) { + Bans = bans ?? throw new ArgumentNullException(nameof(bans)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"bans" or @"banned"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + Bans.GetBanList(b => { + ctx.Connection.SendPacket(new BanListPacket(Sender, b)); + }, ex => { + Logger.Write(@"Error during ban list retrieval."); + Logger.Write(ex); + ctx.Connection.SendPacket(new GenericErrorPacket(Sender)); + }); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/BroadcastCommand.cs b/SharpChat.Protocol.SockChat/Commands/BroadcastCommand.cs new file mode 100644 index 0000000..5aabba9 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/BroadcastCommand.cs @@ -0,0 +1,32 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class BroadcastCommand : ICommand { + private const string NAME = @"say"; + + private Context Context { get; } + private IUser Sender { get; } + + public BroadcastCommand(Context context, IUser sender) { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == NAME; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.Broadcast)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, NAME)); + return true; + } + + Context.BroadcastMessage(string.Join(' ', ctx.Args.Skip(1))); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/ChannelPasswordCommand.cs b/SharpChat.Protocol.SockChat/Commands/ChannelPasswordCommand.cs new file mode 100644 index 0000000..b9b8532 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/ChannelPasswordCommand.cs @@ -0,0 +1,37 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class ChannelPasswordCommand : ICommand { + private ChannelManager Channels { get; } + private IUser Sender { get; } + + public ChannelPasswordCommand(ChannelManager channels, IUser sender) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"password" or @"pwd"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.SetChannelPassword) || ctx.Channel.OwnerId != ctx.User.UserId) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + string password = string.Join(' ', ctx.Args.Skip(1)).Trim(); + + if(string.IsNullOrWhiteSpace(password)) + password = string.Empty; + + Channels.Update(ctx.Channel, password: password); + ctx.Connection.SendPacket(new ChannelPasswordResponsePacket(Sender)); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/ChannelRankCommand.cs b/SharpChat.Protocol.SockChat/Commands/ChannelRankCommand.cs new file mode 100644 index 0000000..4cfd904 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/ChannelRankCommand.cs @@ -0,0 +1,37 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class ChannelRankCommand : ICommand { + private ChannelManager Channels { get; } + private IUser Sender { get; } + + public ChannelRankCommand(ChannelManager channels, IUser sender) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"rank" or @"hierarchy" or @"priv"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.SetChannelHierarchy) || ctx.Channel.OwnerId != ctx.User.UserId) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + if(!int.TryParse(ctx.Args.ElementAtOrDefault(1), out int rank) || rank > ctx.User.Rank) { + ctx.Connection.SendPacket(new InsufficientRankErrorPacket(Sender)); + return true; + } + + Channels.Update(ctx.Channel, minRank: rank); + ctx.Connection.SendPacket(new ChannelRankResponsePacket(Sender)); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/CommandContext.cs b/SharpChat.Protocol.SockChat/Commands/CommandContext.cs new file mode 100644 index 0000000..0b7a505 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/CommandContext.cs @@ -0,0 +1,29 @@ +using SharpChat.Channels; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.SockChat.Commands { + public class CommandContext { + public IEnumerable Args { get; } + public ILocalUser User { get; } + public IChannel Channel { get; } + public ISession Session { get; } + public SockChatConnection Connection { get; } + + public CommandContext( + IEnumerable args, + ILocalUser user, + IChannel channel, + ISession session, + SockChatConnection connection + ) { + Args = args ?? throw new ArgumentNullException(nameof(args)); + User = user ?? throw new ArgumentNullException(nameof(user)); + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Session = session ?? throw new ArgumentNullException(nameof(session)); + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/CreateChannelCommand.cs b/SharpChat.Protocol.SockChat/Commands/CreateChannelCommand.cs new file mode 100644 index 0000000..18f621e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/CreateChannelCommand.cs @@ -0,0 +1,71 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class CreateChannelCommand : ICommand { + private const string NAME = @"create"; + + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private IUser Sender { get; } + + public CreateChannelCommand(ChannelManager channels, ChannelUserRelations channelUsers, IUser sender) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == NAME; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.CreateChannel)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + bool hasRank; + if(ctx.Args.Count() < 2 || (hasRank = ctx.Args.ElementAtOrDefault(1)?.All(char.IsDigit) == true && ctx.Args.Count() < 3)) { + ctx.Connection.SendPacket(new CommandFormatErrorPacket(Sender)); + return true; + } + + int rank = 0; + if(hasRank && !int.TryParse(ctx.Args.ElementAtOrDefault(1), out rank) && rank < 0) + rank = 0; + + if(rank > ctx.User.Rank) { + ctx.Connection.SendPacket(new InsufficientRankErrorPacket(Sender)); + return true; + } + + string createChanName = string.Join('_', ctx.Args.Skip(hasRank ? 2 : 1)); + IChannel createChan; + + try { + createChan = Channels.Create( + ctx.User, + createChanName, + null, + !ctx.User.Can(UserPermissions.SetChannelPermanent), + rank + ); + } catch(ChannelExistException) { + ctx.Connection.SendPacket(new ChannelExistsErrorPacket(Sender, createChanName)); + return true; + } catch(ChannelInvalidNameException) { + ctx.Connection.SendPacket(new ChannelNameFormatErrorPacket(Sender)); + return true; + } + + ChannelUsers.JoinChannel(createChan, ctx.Session); + + ctx.Connection.SendPacket(new ChannelCreateResponsePacket(Sender, createChan)); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/DeleteChannelCommand.cs b/SharpChat.Protocol.SockChat/Commands/DeleteChannelCommand.cs new file mode 100644 index 0000000..d721417 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/DeleteChannelCommand.cs @@ -0,0 +1,47 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class DeleteChannelCommand : ICommand { + private ChannelManager Channels { get; } + private IUser Sender { get; } + + public DeleteChannelCommand(ChannelManager channels, IUser sender) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"delchan" || (name == @"delete" && args.ElementAtOrDefault(1)?.All(char.IsDigit) == false); + + public bool DispatchCommand(CommandContext ctx) { + string channelName = string.Join('_', ctx.Args.Skip(1)); + + if(string.IsNullOrWhiteSpace(channelName)) { + ctx.Connection.SendPacket(new CommandFormatErrorPacket(Sender)); + return true; + } + + Channels.GetChannelByName(channelName, channel => { + if(channel == null) { + ctx.Connection.SendPacket(new ChannelNotFoundErrorPacket(Sender, channelName)); + return; + } + + if(!ctx.User.Can(UserPermissions.DeleteChannel) && channel.OwnerId != ctx.User.UserId) { + ctx.Connection.SendPacket(new ChannelDeleteErrorPacket(Sender, channel)); + return; + } + + Channels.Remove(channel); + ctx.Connection.SendPacket(new ChannelDeleteResponsePacket(Sender, channel.Name)); + }); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/DeleteMessageCommand.cs b/SharpChat.Protocol.SockChat/Commands/DeleteMessageCommand.cs new file mode 100644 index 0000000..b4b4aa3 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/DeleteMessageCommand.cs @@ -0,0 +1,45 @@ +using SharpChat.Messages; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class DeleteMessageCommand : ICommand { + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"delmsg" || (name == @"delete" && args.ElementAtOrDefault(1)?.All(char.IsDigit) == true); + + private MessageManager Messages { get; } + private IUser Sender { get; } + + public DeleteMessageCommand(MessageManager messages, IUser sender) { + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool DispatchCommand(CommandContext ctx) { + bool deleteAnyMessage = ctx.User.Can(UserPermissions.DeleteAnyMessage); + + if(!deleteAnyMessage && !ctx.User.Can(UserPermissions.DeleteOwnMessage)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + if(!long.TryParse(ctx.Args.ElementAtOrDefault(1), out long messageId)) { + ctx.Connection.SendPacket(new CommandFormatErrorPacket(Sender)); + return true; + } + + Messages.GetMessage(messageId, msg => { + if(msg == null || msg.Sender.Rank > ctx.User.Rank + || (!deleteAnyMessage && msg.Sender.UserId != ctx.User.UserId)) + ctx.Connection.SendPacket(new DeleteMessageNotFoundErrorPacket(Sender)); + else + Messages.Delete(ctx.User, msg); + }); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/ICommand.cs b/SharpChat.Protocol.SockChat/Commands/ICommand.cs new file mode 100644 index 0000000..a39de30 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/ICommand.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace SharpChat.Protocol.SockChat.Commands { + public interface ICommand { + bool IsCommandMatch(string name, IEnumerable args); + bool DispatchCommand(CommandContext ctx); + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/JoinCommand.cs b/SharpChat.Protocol.SockChat/Commands/JoinCommand.cs new file mode 100644 index 0000000..b47a75a --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/JoinCommand.cs @@ -0,0 +1,79 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class JoinCommand : ICommand { + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"join"; + + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private SessionManager Sessions { get; } + private IUser Sender { get; } + + public JoinCommand(ChannelManager channels, ChannelUserRelations channelUsers, SessionManager sessions, IUser sender) { + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool DispatchCommand(CommandContext ctx) { + string channelName = ctx.Args.ElementAtOrDefault(1); + + // no error, apparently + if(string.IsNullOrWhiteSpace(channelName)) + return false; + + Channels.GetChannelByName(channelName, channel => { + // the original server sends ForceChannel before sending the error message, but this order probably makes more sense. + // NEW: REVERT THIS ^^^^ WHEN CONVERTING BACK TO NOT EXCEPTIONS + // EXCEPTIONS ARE HEAVY, DON'T USE THEM FOR USER ERRORS YOU IDIOT + + if(channel == null) { + ctx.Connection.SendPacket(new ChannelNotFoundErrorPacket(Sender, channelName)); + Sessions.SwitchChannel(ctx.Session); + return; + } + + ChannelUsers.HasUser(channel, ctx.User, hasUser => { + if(hasUser) { + ctx.Connection.SendPacket(new ChannelAlreadyJoinedErrorPacket(Sender, channel)); + Sessions.SwitchChannel(ctx.Session); + return; + } + + string password = string.Join(' ', ctx.Args.Skip(2)); + + if(!ctx.User.Can(UserPermissions.JoinAnyChannel) && channel.OwnerId != ctx.User.UserId) { + if(channel.MinimumRank > ctx.User.Rank) { + ctx.Connection.SendPacket(new ChannelInsufficientRankErrorPacket(Sender, channel)); + Sessions.SwitchChannel(ctx.Session); + return; + } + + // add capacity check + + Channels.VerifyPassword(channel, password, success => { + if(!success) { + ctx.Connection.SendPacket(new ChannelInvalidPasswordErrorPacket(Sender, channel)); + Sessions.SwitchChannel(ctx.Session); + return; + } + + ChannelUsers.JoinChannel(channel, ctx.Session); + }); + } else + ChannelUsers.JoinChannel(channel, ctx.Session); + }); + }); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/KickBanUserCommand.cs b/SharpChat.Protocol.SockChat/Commands/KickBanUserCommand.cs new file mode 100644 index 0000000..4240534 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/KickBanUserCommand.cs @@ -0,0 +1,72 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class KickBanUserCommand : ICommand { + private UserManager Users { get; } + private IUser Sender { get; } + + public KickBanUserCommand(UserManager users, IUser sender) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"kick" or @"ban"; + + public bool DispatchCommand(CommandContext ctx) { + string commandName = ctx.Args.First(); + bool isBan = commandName == @"ban"; + + if(!ctx.User.Can(isBan ? UserPermissions.BanUser : UserPermissions.KickUser)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, commandName)); + return true; + } + + string userName = ctx.Args.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(userName)) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return true; + } + + Users.GetUserBySockChatName(userName, user => { + if(user == null) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return; + } + + if(user == ctx.User || user.Rank >= ctx.User.Rank) { + ctx.Connection.SendPacket(new KickNotAllowedErrorPacket(Sender, user.UserName)); + return; + } + + bool isPermanent = isBan; + string durationArg = ctx.Args.ElementAtOrDefault(2); + TimeSpan duration = TimeSpan.Zero; + + if(!string.IsNullOrEmpty(durationArg)) { + if(durationArg == @"-1") { + isPermanent = true; + } else { + if(!double.TryParse(durationArg, out double durationRaw)) { + ctx.Connection.SendPacket(new CommandFormatErrorPacket(Sender)); + return; + } + isPermanent = false; + duration = TimeSpan.FromSeconds(durationRaw); + } + } + + // TODO: allow supplying a textReason + + //ctx.Chat.BanUser(user, duration, isPermanent: isPermanent, modUser: ctx.User); + }); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/LeaveCommand.cs b/SharpChat.Protocol.SockChat/Commands/LeaveCommand.cs new file mode 100644 index 0000000..3c37c9f --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/LeaveCommand.cs @@ -0,0 +1,31 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.SockChat.Commands { + public class LeaveCommand : ICommand { + public const string NAME = @"leave"; + + public bool IsCommandMatch(string name, IEnumerable args) + => name == NAME; + + private IUser Sender { get; } + + public LeaveCommand(IUser sender) { + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.Connection.HasCapability(ClientCapability.MCHAN)) { + ctx.Connection.SendPacket(new CommandNotFoundErrorPacket(Sender, NAME)); + return true; + } + + // figure out the channel leaving logic + // should i postpone this implementation till i have the event based shit in place? + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/NickCommand.cs b/SharpChat.Protocol.SockChat/Commands/NickCommand.cs new file mode 100644 index 0000000..5520e9d --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/NickCommand.cs @@ -0,0 +1,72 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class NickCommand : ICommand { + private const string NAME = @"nick"; + + private UserManager Users { get; } + private IUser Sender { get; } + + public NickCommand(UserManager users, IUser sender) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == NAME; + + public bool DispatchCommand(CommandContext ctx) { + bool setOthersNick = ctx.User.Can(UserPermissions.SetOthersNickname); + + if(!setOthersNick && !ctx.User.Can(UserPermissions.SetOwnNickname)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, NAME)); + return true; + } + + if(setOthersNick && long.TryParse(ctx.Args.ElementAtOrDefault(1), out long targetUserId) && targetUserId > 0) { + Users.GetUser(targetUserId, user => DoCommand(ctx, 2, user)); + } else + DoCommand(ctx); + + //string previousName = targetUser == ctx.User ? (targetUser.NickName ?? targetUser.UserName) : null; + //Users.Update(targetUser, nickName: nickStr); + + // both of these need to go in ChannelUsers + //ctx.Channel.SendPacket(new UserNickChangePacket(Sender, previousName, targetUser.GetDisplayName())); + //ctx.Channel.SendPacket(new UserUpdatePacket(targetUser)); + return true; + } + + private void DoCommand(CommandContext ctx, int offset = 1, ILocalUser targetUser = null) { + targetUser ??= ctx.User; + + if(ctx.Args.Count() < offset) { + ctx.Connection.SendPacket(new CommandFormatErrorPacket(Sender)); + return; + } + + string nickStr = string.Join('_', ctx.Args.Skip(offset)).CleanNickName().Trim(); + + if(nickStr.Equals(targetUser.UserName, StringComparison.InvariantCulture)) + nickStr = null; + else if(nickStr.Length > 15) + nickStr = nickStr.Substring(0, 15); + else if(string.IsNullOrEmpty(nickStr)) + nickStr = null; + + if(nickStr != null) + Users.GetUser(nickStr, user => { + if(user != null) + ctx.Connection.SendPacket(new NickNameInUseErrorPacket(Sender, nickStr)); + else + Users.Update(targetUser, nickName: nickStr); + }); + else + Users.Update(targetUser, nickName: string.Empty); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/PardonIPCommand.cs b/SharpChat.Protocol.SockChat/Commands/PardonIPCommand.cs new file mode 100644 index 0000000..198f39e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/PardonIPCommand.cs @@ -0,0 +1,44 @@ +using SharpChat.Bans; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace SharpChat.Protocol.SockChat.Commands { + public class PardonIPCommand : ICommand { + private BanManager Bans { get; } + private IUser Sender { get; } + + public PardonIPCommand(BanManager bans, IUser sender) { + Bans = bans ?? throw new ArgumentNullException(nameof(bans)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"pardonip" or @"unbanip"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + string ipAddress = ctx.Args.ElementAtOrDefault(1); + if(!IPAddress.TryParse(ipAddress, out IPAddress ipAddr)) { + ctx.Connection.SendPacket(new NotBannedErrorPacket(Sender, ipAddr?.ToString() ?? @"::")); + return true; + } + + Bans.RemoveBan(ipAddr, success => { + if(success) + ctx.Connection.SendPacket(new PardonResponsePacket(Sender, ipAddr)); + else + ctx.Connection.SendPacket(new NotBannedErrorPacket(Sender, ipAddr.ToString())); + }, ex => ctx.Connection.SendPacket(new GenericErrorPacket(Sender))); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/PardonUserCommand.cs b/SharpChat.Protocol.SockChat/Commands/PardonUserCommand.cs new file mode 100644 index 0000000..fd84b68 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/PardonUserCommand.cs @@ -0,0 +1,43 @@ +using SharpChat.Bans; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class PardonUserCommand : ICommand { + private BanManager Bans { get; } + private IUser Sender { get; } + + public PardonUserCommand(BanManager bans, IUser sender) { + Bans = bans ?? throw new ArgumentNullException(nameof(bans)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"pardon" or @"unban"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + string userName = ctx.Args.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(userName)) { + ctx.Connection.SendPacket(new NotBannedErrorPacket(Sender, userName ?? @"User")); + return true; + } + + Bans.RemoveBan(userName, success => { + if(success) + ctx.Connection.SendPacket(new PardonResponsePacket(Sender, userName)); + else + ctx.Connection.SendPacket(new NotBannedErrorPacket(Sender, userName)); + }, ex => ctx.Connection.SendPacket(new GenericErrorPacket(Sender))); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/SilenceUserCommand.cs b/SharpChat.Protocol.SockChat/Commands/SilenceUserCommand.cs new file mode 100644 index 0000000..9c7b988 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/SilenceUserCommand.cs @@ -0,0 +1,74 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class SilenceUserCommand : ICommand { + private UserManager Users { get; } + private IUser Sender { get; } + + public SilenceUserCommand(UserManager users, IUser sender) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"silence"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.SilenceUser)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + string userName = ctx.Args.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(userName)) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return true; + } + + Users.GetUserBySockChatName(userName, user => { + if(user == null) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return; + } + + if(user == ctx.User) { + ctx.Connection.SendPacket(new SilenceSelfErrorPacket(Sender)); + return; + } + + if(user.Rank >= user.Rank) { + ctx.Connection.SendPacket(new SilenceNotAllowedErrorPacket(Sender)); + return; + } + + //if(user.IsSilenced) { + // ctx.Connection.SendPacket(new SilencedAlreadyErrorPacket(Sender)); + // return; + //} + + string durationArg = ctx.Args.ElementAtOrDefault(2); + + if(!string.IsNullOrEmpty(durationArg)) { + if(!double.TryParse(durationArg, out double durationRaw)) { + ctx.Connection.SendPacket(new CommandFormatErrorPacket(Sender)); + return; + } + //ctx.Chat.Users.Silence(user, TimeSpan.FromSeconds(durationRaw)); + } //else + //ctx.Chat.Users.Silence(user); + + // UserManager + //user.SendPacket(new SilenceNoticePacket(Sender)); + + // Remain? Also UserManager? + ctx.Connection.SendPacket(new SilenceResponsePacket(Sender, user)); + }); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/UnsilenceUserCommand.cs b/SharpChat.Protocol.SockChat/Commands/UnsilenceUserCommand.cs new file mode 100644 index 0000000..ac53b04 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/UnsilenceUserCommand.cs @@ -0,0 +1,60 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class UnsilenceUserCommand : ICommand { + private UserManager Users { get; } + private IUser Sender { get; } + + public UnsilenceUserCommand(UserManager users, IUser sender) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"unsilence"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.SilenceUser)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + string userName = ctx.Args.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(userName)) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return true; + } + + Users.GetUserBySockChatName(userName, user => { + if(user == null) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return; + } + + if(user.Rank >= ctx.User.Rank) { + ctx.Connection.SendPacket(new SilenceRevokeNotAllowedErrorPacket(Sender)); + return; + } + + //if(!user.IsSilenced) { + // ctx.Connection.SendPacket(new SilenceAlreadyRevokedErrorPacket(Sender)); + // return; + //} + + //ctx.Chat.Users.RevokeSilence(user); + + // UserManager + //user.SendPacket(new SilenceRevokeNoticePacket(Sender)); + + // Remain? Also UserManager? + ctx.Connection.SendPacket(new SilenceRevokeResponsePacket(Sender, user)); + }); + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/WhisperCommand.cs b/SharpChat.Protocol.SockChat/Commands/WhisperCommand.cs new file mode 100644 index 0000000..0e5e517 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/WhisperCommand.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace SharpChat.Protocol.SockChat.Commands { + public class WhisperCommand : ICommand { + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"whisper" or @"msg"; + + public bool DispatchCommand(CommandContext ctx) { + // reimplement this entirely + // this should invoke the creation of a private temporary channel + // if the client joins this channel, it should no longer use the Private message flag and just pump shit into that channel + + /*if(ctx.Args.Count() < 3) + throw new CommandException(LCR.COMMAND_FORMAT_ERROR); + + string whisperUserName = ctx.Args.ElementAtOrDefault(1); + ChatUser whisperUser = ctx.Chat.Users.Get(whisperUserName); + + if(whisperUser == null) + throw new CommandException(LCR.USER_NOT_FOUND, whisperUserName); + + if(whisperUser == ctx.User) + return null; + + string whisperStr = string.Join(' ', ctx.Args.Skip(2)); + + whisperUser.Send(new ChatMessageAddPacket(new ChatMessageEvent(ctx.User, whisperUser, whisperStr, EventFlags.Private))); + ctx.User.Send(new ChatMessageAddPacket(new ChatMessageEvent(ctx.User, ctx.User, $@"{whisperUser.DisplayName} {whisperStr}", EventFlags.Private)));*/ + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/WhoCommand.cs b/SharpChat.Protocol.SockChat/Commands/WhoCommand.cs new file mode 100644 index 0000000..32cd232 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/WhoCommand.cs @@ -0,0 +1,66 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Commands { + public class WhoCommand : ICommand { + private UserManager Users { get; } + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private IUser Sender { get; } + + public WhoCommand(UserManager users, ChannelManager channels, ChannelUserRelations channelUsers, IUser sender) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name == @"who"; + + private void WhoServer(CommandContext ctx) { + Users.GetUsers(u => { + ctx.Connection.SendPacket(new UserListResponsePacket(Sender, ctx.User, u)); + }); + } + + private void WhoChannel(CommandContext ctx, string channelName) { + Channels.GetChannelByName(channelName, channel => { + if(channel == null) { + ctx.Connection.SendPacket(new ChannelNotFoundErrorPacket(Sender, channelName)); + return; + } + + if(channel.MinimumRank > ctx.User.Rank || (channel.HasPassword && !ctx.User.Can(UserPermissions.JoinAnyChannel))) { + ctx.Connection.SendPacket(new UserListChannelNotFoundPacket(Sender, channelName)); + return; + } + + ChannelUsers.GetUsers( + channel, + users => ctx.Connection.SendPacket(new UserListResponsePacket( + Sender, + channel, + ctx.User, + users.OrderByDescending(u => u.Rank) + )) + ); + }); + } + + public bool DispatchCommand(CommandContext ctx) { + string channelName = ctx.Args.ElementAtOrDefault(1) ?? string.Empty; + + if(string.IsNullOrEmpty(channelName)) + WhoServer(ctx); + else + WhoChannel(ctx, channelName); + + return true; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Commands/WhoIsUserCommand.cs b/SharpChat.Protocol.SockChat/Commands/WhoIsUserCommand.cs new file mode 100644 index 0000000..242c084 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Commands/WhoIsUserCommand.cs @@ -0,0 +1,52 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace SharpChat.Protocol.SockChat.Commands { + public class WhoIsUserCommand : ICommand { + private UserManager Users { get; } + private SessionManager Sessions { get; } + private IUser Sender { get; } + + public WhoIsUserCommand(UserManager users, SessionManager sessions, IUser sender) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public bool IsCommandMatch(string name, IEnumerable args) + => name is @"ip" or @"whois"; + + public bool DispatchCommand(CommandContext ctx) { + if(!ctx.User.Can(UserPermissions.SeeIPAddress)) { + ctx.Connection.SendPacket(new CommandNotAllowedErrorPacket(Sender, ctx.Args)); + return true; + } + + string userName = ctx.Args.ElementAtOrDefault(1); + if(string.IsNullOrEmpty(userName)) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return true; + } + + Users.GetUserBySockChatName(userName, user => { + if(user == null) { + ctx.Connection.SendPacket(new UserNotFoundPacket(Sender, userName)); + return; + } + + Sessions.GetRemoteAddresses(user, addrs => { + foreach(IPAddress addr in addrs) + ctx.Connection.SendPacket(new WhoIsResponsePacket(Sender, user, addr)); + }); + }); + + return true; + } + } +} diff --git a/SharpChat/SharpChatWebSocketServer.cs b/SharpChat.Protocol.SockChat/FleckWebSocketServer.cs similarity index 69% rename from SharpChat/SharpChatWebSocketServer.cs rename to SharpChat.Protocol.SockChat/FleckWebSocketServer.cs index e01b75a..1a59e8d 100644 --- a/SharpChat/SharpChatWebSocketServer.cs +++ b/SharpChat.Protocol.SockChat/FleckWebSocketServer.cs @@ -9,35 +9,33 @@ 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 { - +namespace SharpChat.Protocol.SockChat { + internal class FleckWebSocketServer : IWebSocketServer { private readonly string _scheme; private readonly IPAddress _locationIP; private Action _config; - public SharpChatWebSocketServer(string location, bool supportDualStack = true) { - Uri uri = new Uri(location); + public EndPoint EndPoint { get; } - Port = uri.Port; - Location = location; - SupportDualStack = supportDualStack; + public FleckWebSocketServer(IPEndPoint endPoint, bool secure = false) { + EndPoint = endPoint ?? throw new ArgumentNullException(nameof(endPoint)); + _scheme = secure ? @"wss" : @"ws"; - _locationIP = ParseIPAddress(uri); - _scheme = uri.Scheme; - Socket socket = new Socket(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP); + Port = endPoint.Port; + _locationIP = endPoint.Address; + Location = string.Format(@"{0}://{1}:{2}/", _scheme, _locationIP, Port); + + SupportDualStack = true; + + Socket socket = new(endPoint.AddressFamily, SocketType.Stream, ProtocolType.IP); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); - if (SupportDualStack && Type.GetType(@"Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + 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]; + SupportedSubProtocols = Array.Empty(); } public ISocket ListenerSocket { get; set; } @@ -55,37 +53,22 @@ namespace SharpChat { 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); - } - } + GC.SuppressFinalize(this); } public void Start(Action config) { - IPEndPoint ipLocal = new IPEndPoint(_locationIP, Port); - ListenerSocket.Bind(ipLocal); + ListenerSocket.Bind(EndPoint); ListenerSocket.Listen(100); - Port = ((IPEndPoint)ListenerSocket.LocalEndPoint).Port; + if(ListenerSocket.LocalEndPoint is IPEndPoint ipep) + Port = ipep.Port; FleckLog.Info(string.Format("Server started at {0} (actual port {1})", Location, Port)); - if (_scheme == "wss") { - if (Certificate == null) { + if(_scheme == "wss") { + if(Certificate == null) { FleckLog.Error("Scheme cannot be 'wss' without a Certificate"); return; } - if (EnabledSslProtocols == SslProtocols.None) { + if(EnabledSslProtocols == SslProtocols.None) { EnabledSslProtocols = SslProtocols.Tls; FleckLog.Debug("Using default TLS 1.0 security protocol."); } @@ -97,16 +80,16 @@ namespace SharpChat { private void ListenForClients() { ListenerSocket.Accept(OnClientConnect, e => { FleckLog.Error("Listener socket is closed", e); - if (RestartAfterListenError) { + if(RestartAfterListenError) { FleckLog.Info("Listener socket restarting"); try { ListenerSocket.Dispose(); - Socket socket = new Socket(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP); + Socket socket = new(_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) { + } catch(Exception ex) { FleckLog.Error("Listener could not be restarted", ex); } } @@ -114,7 +97,8 @@ namespace SharpChat { } private void OnClientConnect(ISocket clientSocket) { - if (clientSocket == null) return; // socket closed + if(clientSocket == null) + return; // socket closed FleckLog.Debug(string.Format("Client connected from {0}:{1}", clientSocket.RemoteIpAddress, clientSocket.RemotePort.ToString())); ListenForClients(); @@ -151,7 +135,7 @@ namespace SharpChat { }, s => SubProtocolNegotiator.Negotiate(SupportedSubProtocols, s)); - if (IsSecure) { + if(IsSecure) { FleckLog.Debug("Authenticating Secure Connection"); clientSocket .Authenticate(Certificate, diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/AuthPacketHandler.cs b/SharpChat.Protocol.SockChat/PacketHandlers/AuthPacketHandler.cs new file mode 100644 index 0000000..01eb015 --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/AuthPacketHandler.cs @@ -0,0 +1,122 @@ +using SharpChat.Bans; +using SharpChat.Channels; +using SharpChat.Messages; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Sessions; +using SharpChat.Users; +using SharpChat.Users.Remote; +using System; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public class AuthPacketHandler : IPacketHandler { + public ClientPacketId PacketId => ClientPacketId.Authenticate; + + private SockChatServer Server { get; } + private SessionManager Sessions { get; } + private UserManager Users { get; } + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private MessageManager Messages { get; } + private IRemoteUserClient RemoteUserClient { get; } + private BanManager Bans { get; } + private IUser Sender { get; } + private WelcomeMessage WelcomeMessage { get; } + + public AuthPacketHandler( + SockChatServer server, + SessionManager sessions, + UserManager users, + ChannelManager channels, + ChannelUserRelations channelUsers, + MessageManager messages, + IRemoteUserClient remoteUserClient, + BanManager bans, + IUser sender, + WelcomeMessage welcomeMessage + ) { + Server = server ?? throw new ArgumentNullException(nameof(server)); + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + Users = users ?? throw new ArgumentNullException(nameof(users)); + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + RemoteUserClient = remoteUserClient ?? throw new ArgumentNullException(nameof(remoteUserClient)); + Bans = bans ?? throw new ArgumentNullException(nameof(bans)); + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + WelcomeMessage = welcomeMessage ?? throw new ArgumentNullException(nameof(welcomeMessage)); + } + + public void HandlePacket(PacketHandlerContext ctx) { + if(ctx.HasSession) + return; + + if(!long.TryParse(ctx.Args.ElementAtOrDefault(1), out long userId) || userId < 1) + return; + + string token = ctx.Args.ElementAtOrDefault(2); + if(string.IsNullOrEmpty(token)) + return; + + Action exceptionHandler = new(ex => { + Logger.Debug($@"[{ctx.Connection}] Auth fail: {ex.Message}"); + ctx.Connection.SendPacket(new AuthFailPacket(AuthFailReason.AuthInvalid)); + ctx.Connection.Close(); + }); + + RemoteUserClient.AuthenticateUser( + new UserAuthRequest(userId, token, ctx.Connection.RemoteAddress), + res => { + Bans.CheckBan(res, ctx.Connection.RemoteAddress, ban => { + if(ban.IsPermanent || ban.Expires > DateTimeOffset.Now) { + ctx.Connection.SendPacket(new AuthFailPacket(AuthFailReason.Banned, ban)); + ctx.Connection.Close(); + return; + } + + Users.Connect(res, user => { + Sessions.HasAvailableSessions(user, available => { + if(!available) { + ctx.Connection.SendPacket(new AuthFailPacket(AuthFailReason.MaxSessions)); + ctx.Connection.Close(); + return; + } + + Sessions.Create(ctx.Connection, user, session => { + // TODO: !!!!!!!!!!!!!!!! + ctx.Connection.Session = session; + session.Connection = ctx.Connection; + + string welcome = Server.WelcomeMessage; + if(!string.IsNullOrWhiteSpace(welcome)) + ctx.Connection.SendPacket(new WelcomeMessagePacket(Sender, welcome.Replace(@"{username}", user.UserName))); + + if(WelcomeMessage.HasRandom) { + string line = WelcomeMessage.GetRandomString(); + if(!string.IsNullOrWhiteSpace(line)) + ctx.Connection.SendPacket(new WelcomeMessagePacket(Sender, line)); + } + + Channels.GetDefaultChannels(channels => { + if(!channels.Any()) + return; // what do, this is impossible + + // other channels should be joined if MCHAN has been received + IChannel firstChan = channels.FirstOrDefault(); + + ctx.Connection.LastChannel = firstChan; + ctx.Connection.SendPacket(new AuthSuccessPacket(user, firstChan, session, Messages.TextMaxLength)); + + Channels.GetChannels(user.Rank, c => ctx.Connection.SendPacket(new ContextChannelsPacket(c))); + ChannelUsers.JoinChannel(firstChan, session); + }); + }); + }); + }); + }, exceptionHandler); + }, + exceptionHandler + ); + } + } +} diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/CapabilitiesPacketHandler.cs b/SharpChat.Protocol.SockChat/PacketHandlers/CapabilitiesPacketHandler.cs new file mode 100644 index 0000000..74c90af --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/CapabilitiesPacketHandler.cs @@ -0,0 +1,33 @@ +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Sessions; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public class CapabilitiesPacketHandler : IPacketHandler { + public ClientPacketId PacketId => ClientPacketId.Capabilities; + + private SessionManager Sessions { get; } + + public CapabilitiesPacketHandler(SessionManager sessions) { + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + + public void HandlePacket(PacketHandlerContext ctx) { + if(!ctx.HasSession) + return; + + ClientCapability caps = 0; + + IEnumerable capStrs = ctx.Args.ElementAtOrDefault(1)?.Split(' '); + if(capStrs != null && capStrs.Any()) + foreach(string capStr in capStrs) + if(Enum.TryParse(typeof(ClientCapability), capStr.ToUpperInvariant(), out object cap)) + caps |= (ClientCapability)cap; + + ctx.Connection.SendPacket(new CapabilityConfirmationPacket(caps)); + ctx.Connection.Capabilities = caps; + } + } +} diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/IPacketHandler.cs b/SharpChat.Protocol.SockChat/PacketHandlers/IPacketHandler.cs new file mode 100644 index 0000000..7e727f6 --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/IPacketHandler.cs @@ -0,0 +1,6 @@ +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public interface IPacketHandler { + ClientPacketId PacketId { get; } + void HandlePacket(PacketHandlerContext ctx); + } +} diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/MessageSendPacketHandler.cs b/SharpChat.Protocol.SockChat/PacketHandlers/MessageSendPacketHandler.cs new file mode 100644 index 0000000..d3e0197 --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/MessageSendPacketHandler.cs @@ -0,0 +1,114 @@ +using SharpChat.Channels; +using SharpChat.Events; +using SharpChat.Messages; +using SharpChat.Protocol.SockChat.Commands; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public class MessageSendPacketHandler : IPacketHandler { + public ClientPacketId PacketId => ClientPacketId.MessageSend; + + private IEventDispatcher Dispatcher { get; } + private MessageManager Messages { get; } + private UserManager Users { get; } + private ChannelManager Channels { get; } + private ChannelUserRelations ChannelUsers { get; } + private ChatBot Bot { get; } + private IEnumerable Commands { get; } + + public MessageSendPacketHandler( + UserManager users, + ChannelManager channels, + ChannelUserRelations channelUsers, + MessageManager messages, + ChatBot bot, + IEnumerable commands + ) { + Users = users ?? throw new ArgumentNullException(nameof(users)); + Channels = channels ?? throw new ArgumentNullException(nameof(channels)); + ChannelUsers = channelUsers ?? throw new ArgumentNullException(nameof(channelUsers)); + Messages = messages ?? throw new ArgumentNullException(nameof(messages)); + Bot = bot ?? throw new ArgumentNullException(nameof(bot)); + Commands = commands ?? throw new ArgumentNullException(nameof(commands)); + } + + public void HandlePacket(PacketHandlerContext ctx) { + if(ctx.Args.Count() < 3 || !ctx.HasUser || !ctx.User.Can(UserPermissions.SendMessage)) + return; + + if(!long.TryParse(ctx.Args.ElementAtOrDefault(1), out long userId) || ctx.User.UserId != userId) + return; + + // No longer concats everything after index 1 with \t, no previous implementation did that either + string text = ctx.Args.ElementAtOrDefault(2); + if(string.IsNullOrWhiteSpace(text)) + return; + + string channelName = ctx.Args.ElementAtOrDefault(3)?.ToLowerInvariant(); + if(string.IsNullOrWhiteSpace(channelName)) + ChannelContinue(ctx, ctx.Connection.LastChannel, text); // this should grab from the user, not the context wtf + else + Channels.GetChannelByName(channelName, channel => ChannelContinue(ctx, channel, text)); // this also doesn't check if we're actually in the channel + } + + private void ChannelContinue(PacketHandlerContext ctx, IChannel channel, string text) { + if(channel == null + // || !ChannelUsers.HasUser(channel, ctx.User) look below + // || (ctx.User.IsSilenced && !ctx.User.Can(UserPermissions.SilenceUser)) TODO: readd silencing + ) + return; + + ChannelUsers.HasSession(channel, ctx.Session, hasSession => { + if(!hasSession) + return; + + if(ctx.User.Status != UserStatus.Online) { + Users.Update(ctx.User, status: UserStatus.Online); + // ChannelUsers? + //channel.SendPacket(new UserUpdatePacket(ctx.User)); + } + + // there's a very miniscule chance that this will return a different value on second read + int maxLength = Messages.TextMaxLength; + if(text.Length > maxLength) + text = text.Substring(0, maxLength); + + text = text.Trim(); + +#if DEBUG + Logger.Write($@"<{ctx.Session.SessionId} {ctx.User.UserName}> {text}"); +#endif + + bool handled = false; + + if(text[0] == '/') + handled = HandleCommand(text, ctx.User, channel, ctx.Session, ctx.Connection); + + if(!handled) + Messages.Create(ctx.Session, channel, text); + }); + } + + public bool HandleCommand(string message, ILocalUser user, IChannel channel, ISession session, SockChatConnection connection) { + string[] parts = message[1..].Split(' '); + string commandName = parts[0].CleanCommandName(); + + for(int i = 1; i < parts.Length; i++) + parts[i] = parts[i].CleanTextForCommand(); + + ICommand command = Commands.FirstOrDefault(x => x.IsCommandMatch(commandName, parts)); + + if(command == null) { + connection.SendPacket(new CommandNotFoundErrorPacket(Bot, commandName)); + return true; + } + + return command.DispatchCommand(new CommandContext(parts, user, channel, session, connection)); + } + } +} diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/PacketHandlerContext.cs b/SharpChat.Protocol.SockChat/PacketHandlers/PacketHandlerContext.cs new file mode 100644 index 0000000..28cf4d3 --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/PacketHandlerContext.cs @@ -0,0 +1,22 @@ +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Collections.Generic; + +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public class PacketHandlerContext { + public IEnumerable Args { get; } + public SockChatConnection Connection { get; } + + public ISession Session => Connection.Session; + public ILocalUser User => Session.User; + + public bool HasSession => Session != null; + public bool HasUser => HasSession; + + public PacketHandlerContext(IEnumerable args, SockChatConnection conn) { + Args = args ?? throw new ArgumentNullException(nameof(args)); + Connection = conn ?? throw new ArgumentNullException(nameof(conn)); + } + } +} diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/PingPacketHandler.cs b/SharpChat.Protocol.SockChat/PacketHandlers/PingPacketHandler.cs new file mode 100644 index 0000000..0266a79 --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/PingPacketHandler.cs @@ -0,0 +1,26 @@ +using SharpChat.Sessions; +using System; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public class PingPacketHandler : IPacketHandler { + public ClientPacketId PacketId => ClientPacketId.Ping; + + private SessionManager Sessions { get; } + + public PingPacketHandler(SessionManager sessions) { + Sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + } + + public void HandlePacket(PacketHandlerContext ctx) { + if(!ctx.HasSession + && !long.TryParse(ctx.Args.ElementAtOrDefault(1), out long userId) + && ctx.Session.User.UserId != userId) + return; + //if(!int.TryParse(ctx.Args.ElementAtOrDefault(2), out int timestamp)) + // timestamp = -1; + + Sessions.DoKeepAlive(ctx.Session); + } + } +} diff --git a/SharpChat.Protocol.SockChat/PacketHandlers/TypingPacketHandler.cs b/SharpChat.Protocol.SockChat/PacketHandlers/TypingPacketHandler.cs new file mode 100644 index 0000000..35f57a4 --- /dev/null +++ b/SharpChat.Protocol.SockChat/PacketHandlers/TypingPacketHandler.cs @@ -0,0 +1,29 @@ +namespace SharpChat.Protocol.SockChat.PacketHandlers { + public class TypingPacketHandler : IPacketHandler { + public ClientPacketId PacketId => ClientPacketId.Typing; + + public void HandlePacket(PacketHandlerContext ctx) { + /*if(!ctx.HasUser) + return; + + if(!long.TryParse(ctx.Args.ElementAtOrDefault(1), out long userId) || ctx.User.UserId != userId) + return; + + string channelName = ctx.Args.ElementAtOrDefault(2)?.ToLowerInvariant(); + if(!string.IsNullOrWhiteSpace(channelName)) + return; + + IChannel channel = ctx.User.GetChannels().FirstOrDefault(c => c.Name.ToLowerInvariant() == channelName); + if(channel == null || !channel.CanType(ctx.User)) + return; + + ctx.Session.LastChannel = channel; + + ChannelTyping info = channel.RegisterTyping(ctx.User); + if(info == null) + return; + + channel.SendPacket(new TypingPacket(channel, info));*/ + } + } +} diff --git a/SharpChat/Packet/AuthFailPacket.cs b/SharpChat.Protocol.SockChat/Packets/AuthFailPacket.cs similarity index 50% rename from SharpChat/Packet/AuthFailPacket.cs rename to SharpChat.Protocol.SockChat/Packets/AuthFailPacket.cs index 7b36837..5db6c5b 100644 --- a/SharpChat/Packet/AuthFailPacket.cs +++ b/SharpChat.Protocol.SockChat/Packets/AuthFailPacket.cs @@ -1,8 +1,8 @@ -using System; -using System.Collections.Generic; +using SharpChat.Bans; +using System; using System.Text; -namespace SharpChat.Packet { +namespace SharpChat.Protocol.SockChat.Packets { public enum AuthFailReason { AuthInvalid, MaxSessions, @@ -11,23 +11,22 @@ namespace SharpChat.Packet { public class AuthFailPacket : ServerPacket { public AuthFailReason Reason { get; private set; } - public DateTimeOffset Expires { get; private set; } + public IBanRecord BanInfo { get; private set; } - public AuthFailPacket(AuthFailReason reason, DateTimeOffset? expires = null) { + public AuthFailPacket(AuthFailReason reason, IBanRecord banInfo = null) { Reason = reason; - if (reason == AuthFailReason.Banned) { - if (!expires.HasValue) - throw new ArgumentNullException(nameof(expires)); - Expires = expires.Value; - } + if(reason == AuthFailReason.Banned) + BanInfo = banInfo ?? throw new ArgumentNullException(nameof(banInfo)); } - public override IEnumerable Pack() { - StringBuilder sb = new StringBuilder(); + protected override string DoPack() { + StringBuilder sb = new(); - sb.Append((int)SockChatServerPacket.UserConnect); - sb.Append("\tn\t"); + sb.Append((int)ServerPacketId.UserConnect); + sb.Append(IServerPacket.SEPARATOR); + sb.Append('n'); + sb.Append(IServerPacket.SEPARATOR); switch (Reason) { case AuthFailReason.AuthInvalid: @@ -43,15 +42,15 @@ namespace SharpChat.Packet { } if (Reason == AuthFailReason.Banned) { - sb.Append('\t'); + sb.Append(IServerPacket.SEPARATOR); - if (Expires == DateTimeOffset.MaxValue) + if (BanInfo.IsPermanent) sb.Append(@"-1"); else - sb.Append(Expires.ToUnixTimeSeconds()); + sb.Append(BanInfo.Expires.ToUnixTimeSeconds()); } - yield return sb.ToString(); + return sb.ToString(); } } } diff --git a/SharpChat.Protocol.SockChat/Packets/AuthSuccessPacket.cs b/SharpChat.Protocol.SockChat/Packets/AuthSuccessPacket.cs new file mode 100644 index 0000000..fe829e7 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/AuthSuccessPacket.cs @@ -0,0 +1,40 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Sessions; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class AuthSuccessPacket : ServerPacket { + public IUser User { get; private set; } + public IChannel Channel { get; private set; } + public ISession Session { get; private set; } + public int CharacterLimit { get; private set; } + + public AuthSuccessPacket(IUser user, IChannel channel, ISession sess, int charLimit) { + User = user ?? throw new ArgumentNullException(nameof(user)); + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + Session = sess ?? throw new ArgumentNullException(nameof(channel)); + CharacterLimit = charLimit; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserConnect); + sb.Append(IServerPacket.SEPARATOR); + sb.Append('y'); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.Pack()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channel.Name); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Session.SessionId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(CharacterLimit); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/BanListPacket.cs b/SharpChat.Protocol.SockChat/Packets/BanListPacket.cs new file mode 100644 index 0000000..1ebf4ae --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/BanListPacket.cs @@ -0,0 +1,18 @@ +using SharpChat.Bans; +using SharpChat.Users; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Packets { + public class BanListPacket : BotResponsePacket { + private const string FORMAT = @"{0}, "; + + public BanListPacket(IUser sender, IEnumerable bans) + : base( + sender.UserId, + BotArguments.BANS, + false, + string.Join(@", ", bans.Select(b => string.Format(FORMAT, b.UserName))) + ) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/BotArguments.cs b/SharpChat.Protocol.SockChat/Packets/BotArguments.cs new file mode 100644 index 0000000..db48951 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/BotArguments.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class BotArguments { + public const char SEPARATOR = '\f'; + + public const string BANS = @"banlist"; // [ban list html] + public const string BROADCAST = @"say"; // [broadcast message] + public const string CHANNEL_CREATED = @"crchan"; // [channel name] + public const string CHANNEL_DELETED = @"delchan"; // [target channel name] + public const string CHANNEL_PASSWORD_CHANGED = @"cpwdchan"; // [] + public const string CHANNEL_RANK_CHANGED = @"cprivchan"; // [] + public const string FLOOD_WARNING = @"flwarn"; // [] + public const string BAN_PARDON = @"unban"; // [target user name or ip address] + public const string SILENCE_PLACE_NOTICE = @"silence"; // [] + public const string SILENCE_PLACE_CONFIRM = @"silok"; // [target user name] + public const string SILENCE_REVOKE_NOTICE = @"unsil"; // [] + public const string SILENCE_REVOKE_CONFIRM = @"usilok"; // [target user name] + public const string USER_LIST_ALL = @"who"; // [user list html] + public const string USER_LIST_CHANNEL = @"whochan"; // [target channel name, user list html] + public const string NICKNAME_CHANGE = @"nick"; // [old display name, new display name] + public const string USER_IP_ADDRESS = @"ipaddr"; // [target user name, user ip address] + public const string WELCOME = @"welcome"; // [welcome message] + + public const string GENERIC_ERROR = @"generr"; // [] + public const string COMMAND_NOT_FOUND = @"nocmd"; // [target channel name] + public const string COMMAND_NOT_ALLOWED = @"cmdna"; // [/target command name] + public const string COMMAND_FORMAT_ERROR = @"cmderr"; // [] + public const string USER_NOT_FOUND_ERROR = @"usernf"; // [target user name] + public const string SILENCE_SELF_ERROR = @"silself"; // [] + public const string SILENCE_NOT_ALLOWED_ERROR = @"silperr"; // [] + public const string SILENCE_ALREADY_ERROR = @"silerr"; // [] + public const string SILENCE_REVOKE_NOT_ALLOWED_ERROR = @"usilperr"; // [] + public const string SILENCE_REVOKE_ALREADY_ERROR = @"usilerr"; // [] + public const string NICKNAME_IN_USE_ERROR = @"nameinuse"; // [target nick name] + public const string USER_LIST_ERROR = @"whoerr"; // [target channel name] + public const string INSUFFICIENT_RANK_ERROR = @"rankerr"; // [] + public const string DELETE_MESSAGE_NOT_FOUND_ERROR = @"delerr"; // [] + public const string KICK_NOT_ALLOWED_ERROR = @"kickna"; // [] + public const string NOT_BANNED_ERROR = @"notban"; // [target user name] + public const string CHANNEL_NOT_FOUND_ERROR = @"nochan"; // [target user name] + public const string CHANNEL_EXISTS_ERROR = @"nischan"; // [desired channel name] + public const string CHANNEL_INSUFFICIENT_RANK_ERROR = @"ipchan"; // [target channel name] + public const string CHANNEL_INVALID_PASSWORD_ERROR = @"ipwchan"; // [target channel name] + public const string CHANNEL_ALREADY_JOINED_ERROR = @"samechan"; // [target channel name] + public const string CHANNEL_NAME_FORMAT_ERROR = @"inchan"; // [] + public const string CHANNEL_DELETE_ERROR = @"ndchan"; // [target channel name] + + public bool IsError { get; } + public string Name { get; } + public IEnumerable Arguments { get; } + + public BotArguments(string name, bool error, params object[] args) { + Name = name ?? throw new ArgumentNullException(nameof(name)); + IsError = error; + Arguments = args; + } + + public override string ToString() { + StringBuilder sb = new(); + sb.Append(IsError ? '1' : '0'); + sb.Append(SEPARATOR); + sb.Append(Name); + + foreach(object arg in Arguments) { + sb.Append(SEPARATOR); + sb.Append(arg); + } + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/BotResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/BotResponsePacket.cs new file mode 100644 index 0000000..e9b588a --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/BotResponsePacket.cs @@ -0,0 +1,52 @@ +using SharpChat.Channels; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public abstract class BotResponsePacket : ServerPacket { + private IChannel Channel { get; } + private long UserId { get; } + private BotArguments Arguments { get; } + private DateTimeOffset DateTime { get; } + private long ArbitraryId { get; } + + public BotResponsePacket(IUser sender, string stringId, bool isError, params object[] args) + : this(sender?.UserId ?? throw new ArgumentNullException(nameof(sender)), stringId, isError, args) { } + + public BotResponsePacket(long userId, string stringId, bool isError, params object[] args) + : this(null, userId, stringId, isError, args) { } + + public BotResponsePacket(IChannel channel, long userId, string stringId, bool isError, params object[] args) + : this(channel, userId, new BotArguments(stringId, isError, args)) { } + + public BotResponsePacket(IChannel channel, long userid, BotArguments args) { + Arguments = args ?? throw new ArgumentNullException(nameof(args)); + UserId = userid; + Channel = channel; + DateTime = DateTimeOffset.Now; + ArbitraryId = SharpId.Next(); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.MessageAdd); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(DateTime.ToUnixTimeSeconds()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Arguments); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(ArbitraryId); + sb.Append(IServerPacket.SEPARATOR); + sb.AppendFormat(@"10010"); + sb.Append(IServerPacket.SEPARATOR); + if(Channel != null) + sb.Append(Channel.Name); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/BroadcastMessagePacket.cs b/SharpChat.Protocol.SockChat/Packets/BroadcastMessagePacket.cs new file mode 100644 index 0000000..e98f2ae --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/BroadcastMessagePacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Events; + +namespace SharpChat.Protocol.SockChat.Packets { + public class BroadcastMessagePacket : BotResponsePacket { + public BroadcastMessagePacket(BroadcastMessageEvent broadcast) + : base(broadcast.UserId, BotArguments.BROADCAST, false, broadcast.Text) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/CapabilityConfirmationPacket.cs b/SharpChat.Protocol.SockChat/Packets/CapabilityConfirmationPacket.cs new file mode 100644 index 0000000..c6e0519 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/CapabilityConfirmationPacket.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class CapabilityConfirmationPacket : ServerPacket { + private IEnumerable Capabilities { get; } + + private static readonly string[] Names = Enum.GetNames(typeof(ClientCapability)); + private static readonly int[] Values = Enum.GetValues(typeof(ClientCapability)) as int[]; + + public CapabilityConfirmationPacket(ClientCapability caps) { + Capabilities = GetStrings((int)caps); + } + + private static IEnumerable GetStrings(int caps) { + for(int i = 0; i < Values.Length; ++i) + if((caps & Values[i]) > 0) + yield return Names[i]; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.CapabilityConfirm); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(string.Join(' ', Capabilities)); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelAlreadyJoinedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelAlreadyJoinedErrorPacket.cs new file mode 100644 index 0000000..a92a6c8 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelAlreadyJoinedErrorPacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelAlreadyJoinedErrorPacket : BotResponsePacket { + public ChannelAlreadyJoinedErrorPacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_ALREADY_JOINED_ERROR, true, channelName) { } + + public ChannelAlreadyJoinedErrorPacket(IUser sender, IChannel channel) + : this(sender, channel.Name) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelCreatePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelCreatePacket.cs new file mode 100644 index 0000000..be9b623 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelCreatePacket.cs @@ -0,0 +1,25 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Channels; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelCreatePacket : ServerPacket { + public IChannel Channel { get; private set; } + + public ChannelCreatePacket(IChannel channel) { + Channel = channel; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ChannelEvent); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerChannelSubPacketId.Create); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channel.Pack()); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelCreateResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelCreateResponsePacket.cs new file mode 100644 index 0000000..3b36eef --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelCreateResponsePacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelCreateResponsePacket : BotResponsePacket { + public ChannelCreateResponsePacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_CREATED, false, channelName) { } + + public ChannelCreateResponsePacket(IUser sender, IChannel channel) + : this(sender, channel.Name) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelDeleteErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelDeleteErrorPacket.cs new file mode 100644 index 0000000..3f6a4f1 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelDeleteErrorPacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelDeleteErrorPacket : BotResponsePacket { + public ChannelDeleteErrorPacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_DELETE_ERROR, true, channelName) { } + + public ChannelDeleteErrorPacket(IUser sender, IChannel channel) + : this(sender, channel.Name) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelDeletePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelDeletePacket.cs new file mode 100644 index 0000000..32d8a4a --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelDeletePacket.cs @@ -0,0 +1,25 @@ +using SharpChat.Channels; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelDeletePacket : ServerPacket { + public IChannel Channel { get; private set; } + + public ChannelDeletePacket(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ChannelEvent); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerChannelSubPacketId.Delete); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channel.Name); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelDeleteResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelDeleteResponsePacket.cs new file mode 100644 index 0000000..e221429 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelDeleteResponsePacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelDeleteResponsePacket : BotResponsePacket { + public ChannelDeleteResponsePacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_DELETED, false, channelName) { } + + public ChannelDeleteResponsePacket(IUser sender, IChannel channel) + : this(sender, channel.Name) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelExistsErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelExistsErrorPacket.cs new file mode 100644 index 0000000..ba7c62b --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelExistsErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelExistsErrorPacket : BotResponsePacket { + public ChannelExistsErrorPacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_EXISTS_ERROR, true, channelName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelInsufficientRankErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelInsufficientRankErrorPacket.cs new file mode 100644 index 0000000..d72b075 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelInsufficientRankErrorPacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelInsufficientRankErrorPacket : BotResponsePacket { + public ChannelInsufficientRankErrorPacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_INSUFFICIENT_RANK_ERROR, true, channelName) { } + + public ChannelInsufficientRankErrorPacket(IUser sender, IChannel channel) + : this(sender, channel.Name) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelInvalidPasswordErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelInvalidPasswordErrorPacket.cs new file mode 100644 index 0000000..2ab99fd --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelInvalidPasswordErrorPacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Channels; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelInvalidPasswordErrorPacket : BotResponsePacket { + public ChannelInvalidPasswordErrorPacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_INVALID_PASSWORD_ERROR, true, channelName) { } + + public ChannelInvalidPasswordErrorPacket(IUser sender, IChannel channel) + : this(sender, channel.Name) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelJoinPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelJoinPacket.cs new file mode 100644 index 0000000..35a0e66 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelJoinPacket.cs @@ -0,0 +1,36 @@ +using SharpChat.Events; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelJoinPacket : ServerPacket { + private ChannelUserJoinEvent Join { get; } + + private IUser User { get; } + + public ChannelJoinPacket(ChannelUserJoinEvent join, IUser user) { + Join = join ?? throw new ArgumentNullException(nameof(join)); + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserMove); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerMoveSubPacketId.UserJoined); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.GetDisplayName()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.Colour); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Join.EventId); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelLeavePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelLeavePacket.cs new file mode 100644 index 0000000..d566560 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelLeavePacket.cs @@ -0,0 +1,27 @@ +using SharpChat.Events; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelLeavePacket : ServerPacket { + private ChannelUserLeaveEvent Leave { get; } + + public ChannelLeavePacket(ChannelUserLeaveEvent leave) { + Leave = leave ?? throw new ArgumentNullException(nameof(leave)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserMove); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerMoveSubPacketId.UserLeft); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Leave.UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Leave.EventId); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelNameFormatErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelNameFormatErrorPacket.cs new file mode 100644 index 0000000..cc5827c --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelNameFormatErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelNameFormatErrorPacket : BotResponsePacket { + public ChannelNameFormatErrorPacket(IUser sender) + : base(sender, BotArguments.CHANNEL_NAME_FORMAT_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelNotFoundErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelNotFoundErrorPacket.cs new file mode 100644 index 0000000..d050c4e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelNotFoundErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelNotFoundErrorPacket : BotResponsePacket { + public ChannelNotFoundErrorPacket(IUser sender, string channelName) + : base(sender, BotArguments.CHANNEL_NOT_FOUND_ERROR, true, channelName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelPasswordResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelPasswordResponsePacket.cs new file mode 100644 index 0000000..e9808dc --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelPasswordResponsePacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelPasswordResponsePacket : BotResponsePacket { + public ChannelPasswordResponsePacket(IUser sender) + : base(sender, BotArguments.CHANNEL_PASSWORD_CHANGED, false) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelRankResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelRankResponsePacket.cs new file mode 100644 index 0000000..12ba114 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelRankResponsePacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelRankResponsePacket : BotResponsePacket { + public ChannelRankResponsePacket(IUser sender) + : base(sender.UserId, BotArguments.CHANNEL_RANK_CHANGED, false) {} + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelSwitchPacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelSwitchPacket.cs new file mode 100644 index 0000000..0a55f23 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelSwitchPacket.cs @@ -0,0 +1,25 @@ +using SharpChat.Channels; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelSwitchPacket : ServerPacket { + public IChannel Channel { get; private set; } + + public ChannelSwitchPacket(IChannel channel) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserMove); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerMoveSubPacketId.ForcedMove); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channel.Name); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ChannelUpdatePacket.cs b/SharpChat.Protocol.SockChat/Packets/ChannelUpdatePacket.cs new file mode 100644 index 0000000..64a693c --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ChannelUpdatePacket.cs @@ -0,0 +1,29 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Channels; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ChannelUpdatePacket : ServerPacket { + public string PreviousName { get; private set; } + public IChannel Channel { get; private set; } + + public ChannelUpdatePacket(string previousName, IChannel channel) { + PreviousName = previousName; + Channel = channel; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ChannelEvent); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerChannelSubPacketId.Update); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(PreviousName); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channel.Pack()); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/CommandFormatErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/CommandFormatErrorPacket.cs new file mode 100644 index 0000000..ca89004 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/CommandFormatErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class CommandFormatErrorPacket : BotResponsePacket { + public CommandFormatErrorPacket(IUser sender) + : base(sender, BotArguments.COMMAND_FORMAT_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/CommandNotAllowedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/CommandNotAllowedErrorPacket.cs new file mode 100644 index 0000000..a51710f --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/CommandNotAllowedErrorPacket.cs @@ -0,0 +1,13 @@ +using SharpChat.Users; +using System.Collections.Generic; +using System.Linq; + +namespace SharpChat.Protocol.SockChat.Packets { + public class CommandNotAllowedErrorPacket : BotResponsePacket { + public CommandNotAllowedErrorPacket(IUser sender, string commandName) + : base(sender, BotArguments.COMMAND_NOT_ALLOWED, true, @"/" + commandName) { } + + public CommandNotAllowedErrorPacket(IUser sender, IEnumerable argList) + : this(sender, argList.First()) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/CommandNotFoundErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/CommandNotFoundErrorPacket.cs new file mode 100644 index 0000000..b8bd303 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/CommandNotFoundErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class CommandNotFoundErrorPacket : BotResponsePacket { + public CommandNotFoundErrorPacket(IUser sender, string commandName) + : base(sender, BotArguments.COMMAND_NOT_FOUND, true, commandName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ContextChannelsPacket.cs b/SharpChat.Protocol.SockChat/Packets/ContextChannelsPacket.cs new file mode 100644 index 0000000..a32625b --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ContextChannelsPacket.cs @@ -0,0 +1,33 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Channels; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + 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)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ContextPopulate); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerContextSubPacketId.Channels); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channels.Count()); + + foreach(IChannel channel in Channels) { + sb.Append(IServerPacket.SEPARATOR); + sb.Append(channel.Pack()); + } + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ContextClearPacket.cs b/SharpChat.Protocol.SockChat/Packets/ContextClearPacket.cs new file mode 100644 index 0000000..6bfb458 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ContextClearPacket.cs @@ -0,0 +1,38 @@ +using SharpChat.Channels; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public enum ContextClearMode { + Messages = 0, + Users = 1, + Channels = 2, + MessagesUsers = 3, + MessagesUsersChannels = 4, + } + + public class ContextClearPacket : ServerPacket { + public IChannel Channel { get; private set; } + public ContextClearMode Mode { get; private set; } + + public bool IsGlobal + => Channel == null; + + public ContextClearPacket(IChannel channel, ContextClearMode mode) { + Channel = channel; + Mode = mode; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ContextClear); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)Mode); + sb.Append(IServerPacket.SEPARATOR); + if(!IsGlobal) + sb.Append(Channel.Name); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ContextMessagePacket.cs b/SharpChat.Protocol.SockChat/Packets/ContextMessagePacket.cs new file mode 100644 index 0000000..6b8be30 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ContextMessagePacket.cs @@ -0,0 +1,90 @@ +using SharpChat.Messages; +using SharpChat.Protocol.SockChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ContextMessagePacket : ServerPacket { + public IMessage Message { get; private set; } + public bool Notify { get; private set; } + + public ContextMessagePacket(IMessage msg, bool notify = false) { + Message = msg ?? throw new ArgumentNullException(nameof(msg)); + Notify = notify; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ContextPopulate); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerContextSubPacketId.Message); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Message.Created.ToUnixTimeSeconds()); + sb.Append(IServerPacket.SEPARATOR); + + sb.Append(Message.Sender.Pack()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Message.GetSanitisedText()); + + /*switch (Event) { + case MessageCreateEvent msg: + sb.Append(Event.Sender.Pack()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append( + msg.Text + .Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
") + .Replace("\t", @" ") + ); + break; + + case UserConnectEvent _: + sb.Append(V1_CHATBOT); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(BotArguments.Notice(@"join", Event.Sender.UserName)); + break; + + case ChannelJoinEvent _: + sb.Append(V1_CHATBOT); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(BotArguments.Notice(@"jchan", Event.Sender.UserName)); + break; + + case ChannelLeaveEvent _: + sb.Append(V1_CHATBOT); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(BotArguments.Notice(@"lchan", Event.Sender.UserName)); + break; + + case UserDisconnectEvent ude: + string udeReason = ude.Reason switch { + UserDisconnectReason.Flood => @"flood", + UserDisconnectReason.Kicked => @"kick", + UserDisconnectReason.TimeOut => @"timeout", + _ => @"leave", + }; + + sb.Append(V1_CHATBOT); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(BotArguments.Notice(udeReason, Event.Sender.UserName)); + break; + }*/ + + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Message.MessageId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Notify ? '1' : '0'); + sb.Append(IServerPacket.SEPARATOR); + sb.AppendFormat( + "1{0}0{1}{2}", + Message.IsAction ? '1' : '0', + Message.IsAction ? '0' : '1', + /*Event.Flags.HasFlag(EventFlags.Private)*/ false ? '1' : '0' + ); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ContextUsersPacket.cs b/SharpChat.Protocol.SockChat/Packets/ContextUsersPacket.cs new file mode 100644 index 0000000..dae037d --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ContextUsersPacket.cs @@ -0,0 +1,35 @@ +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + 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)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ContextPopulate); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerContextSubPacketId.Users); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Users.Count()); + + foreach(IUser user in Users) { + sb.Append(IServerPacket.SEPARATOR); + sb.Append(user.Pack()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append('1'); // visibility flag + } + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/DeleteMessageNotFoundErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/DeleteMessageNotFoundErrorPacket.cs new file mode 100644 index 0000000..e092100 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/DeleteMessageNotFoundErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class DeleteMessageNotFoundErrorPacket : BotResponsePacket { + public DeleteMessageNotFoundErrorPacket(IUser sender) + : base(sender, BotArguments.DELETE_MESSAGE_NOT_FOUND_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/FloodWarningPacket.cs b/SharpChat.Protocol.SockChat/Packets/FloodWarningPacket.cs new file mode 100644 index 0000000..918811e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/FloodWarningPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class FloodWarningPacket : BotResponsePacket { + public FloodWarningPacket(IUser sender) + : base(sender.UserId, BotArguments.FLOOD_WARNING, false) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ForceDisconnectPacket.cs b/SharpChat.Protocol.SockChat/Packets/ForceDisconnectPacket.cs new file mode 100644 index 0000000..faf9d9e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ForceDisconnectPacket.cs @@ -0,0 +1,36 @@ +using SharpChat.Events; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class ForceDisconnectPacket : ServerPacket { + public DateTimeOffset? Expires { get; } + public bool IsPermanent { get; } + + public ForceDisconnectPacket(UserBanCreatedEvent ubce) { + Expires = ubce.Duration < 1 + ? null + : DateTimeOffset.Now + TimeSpan.FromSeconds(ubce.Duration); + IsPermanent = ubce.IsPermanent; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.BAKA); + sb.Append(IServerPacket.SEPARATOR); + + if(Expires.HasValue) { + sb.Append('1'); + sb.Append(IServerPacket.SEPARATOR); + if(IsPermanent) + sb.Append(-1); + else + sb.Append(Expires.Value.ToUnixTimeSeconds()); + } else + sb.Append('0'); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/GenericErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/GenericErrorPacket.cs new file mode 100644 index 0000000..0cf2200 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/GenericErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class GenericErrorPacket : BotResponsePacket { + public GenericErrorPacket(IUser sender) + : base(sender, BotArguments.GENERIC_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/IServerPacket.cs b/SharpChat.Protocol.SockChat/Packets/IServerPacket.cs new file mode 100644 index 0000000..62e15bc --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/IServerPacket.cs @@ -0,0 +1,6 @@ +namespace SharpChat.Protocol.SockChat.Packets { + public interface IServerPacket { + public const char SEPARATOR = '\t'; + string Pack(); + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/InsufficientRankErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/InsufficientRankErrorPacket.cs new file mode 100644 index 0000000..ae65880 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/InsufficientRankErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class InsufficientRankErrorPacket : BotResponsePacket { + public InsufficientRankErrorPacket(IUser sender) + : base(sender, BotArguments.INSUFFICIENT_RANK_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/KickNotAllowedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/KickNotAllowedErrorPacket.cs new file mode 100644 index 0000000..892ce3a --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/KickNotAllowedErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class KickNotAllowedErrorPacket : BotResponsePacket { + public KickNotAllowedErrorPacket(IUser sender, string userName) + : base(sender, BotArguments.KICK_NOT_ALLOWED_ERROR, true, userName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/MessageCreatePacket.cs b/SharpChat.Protocol.SockChat/Packets/MessageCreatePacket.cs new file mode 100644 index 0000000..64fc5f8 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/MessageCreatePacket.cs @@ -0,0 +1,60 @@ +using SharpChat.Channels; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class MessageCreatePacket : ServerPacket { + private long MessageId { get; } + private long UserId { get; } + private DateTimeOffset DateTime { get; } + private IChannel Channel { get; } + private string Text { get; } + private bool IsAction { get; } + + public MessageCreatePacket(long messageId, long userId, DateTimeOffset dateTime, IChannel channel, string text, bool isAction) { + MessageId = messageId; + UserId = userId; + DateTime = dateTime; + Channel = channel; + IsAction = isAction; + + StringBuilder sb = new(); + + if(isAction) + sb.Append(@""); + + sb.Append(text.CleanTextForMessage()); + + if(isAction) + sb.Append(@""); + + Text = sb.ToString(); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.MessageAdd); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(DateTime.ToUnixTimeSeconds()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Text); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(MessageId); + sb.Append(IServerPacket.SEPARATOR); + sb.AppendFormat( + "1{0}0{1}{2}", + IsAction ? '1' : '0', + IsAction ? '0' : '1', + /*Flags.HasFlag(EventFlags.Private)*/ false ? '1' : '0' + ); + sb.Append(IServerPacket.SEPARATOR); + if(Channel == null) // broadcast + sb.Append(Channel.Name); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/MessageDeletePacket.cs b/SharpChat.Protocol.SockChat/Packets/MessageDeletePacket.cs new file mode 100644 index 0000000..5d9d669 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/MessageDeletePacket.cs @@ -0,0 +1,27 @@ +using SharpChat.Events; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class MessageDeletePacket : ServerPacket { + public long MessageId { get; } + + public MessageDeletePacket(MessageUpdateEvent mue) { + MessageId = (mue ?? throw new ArgumentNullException(nameof(mue))).MessageId; + } + + public MessageDeletePacket(MessageDeleteEvent mde) { + MessageId = (mde ?? throw new ArgumentNullException(nameof(mde))).MessageId; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.MessageDelete); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(MessageId); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/NickNameInUseErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/NickNameInUseErrorPacket.cs new file mode 100644 index 0000000..36f9ba8 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/NickNameInUseErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class NickNameInUseErrorPacket : BotResponsePacket { + public NickNameInUseErrorPacket(IUser sender, string nickName) + : base(sender, BotArguments.NICKNAME_IN_USE_ERROR, true, nickName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/NotBannedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/NotBannedErrorPacket.cs new file mode 100644 index 0000000..813349e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/NotBannedErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class NotBannedErrorPacket : BotResponsePacket { + public NotBannedErrorPacket(IUser sender, string subject) + : base(sender, BotArguments.NOT_BANNED_ERROR, true, subject) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/PardonResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/PardonResponsePacket.cs new file mode 100644 index 0000000..61948ec --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/PardonResponsePacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Users; +using System.Net; + +namespace SharpChat.Protocol.SockChat.Packets { + public class PardonResponsePacket : BotResponsePacket { + public PardonResponsePacket(IUser sender, string userName) + : base(sender, BotArguments.BAN_PARDON, false, userName) { } + + public PardonResponsePacket(IUser sender, IPAddress ipAddr) + : base(sender, BotArguments.BAN_PARDON, false, ipAddr) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/PongPacket.cs b/SharpChat.Protocol.SockChat/Packets/PongPacket.cs new file mode 100644 index 0000000..54d0d98 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/PongPacket.cs @@ -0,0 +1,23 @@ +using SharpChat.Events; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class PongPacket : ServerPacket { + public DateTimeOffset PongTime { get; private set; } + + public PongPacket(SessionPingEvent spe) { + PongTime = spe.DateTime; + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.Pong); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(PongTime.ToUnixTimeSeconds()); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/ServerPacket.cs b/SharpChat.Protocol.SockChat/Packets/ServerPacket.cs new file mode 100644 index 0000000..cc02eef --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/ServerPacket.cs @@ -0,0 +1,13 @@ +namespace SharpChat.Protocol.SockChat.Packets { + public abstract class ServerPacket : IServerPacket { + private string Packed { get; set; } + + protected abstract string DoPack(); + + public string Pack() { + if(Packed == null) + Packed = DoPack(); + return Packed; + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceAlreadyRevokedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceAlreadyRevokedErrorPacket.cs new file mode 100644 index 0000000..b2ee5e6 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceAlreadyRevokedErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceAlreadyRevokedErrorPacket : BotResponsePacket { + public SilenceAlreadyRevokedErrorPacket(IUser sender) + : base(sender, BotArguments.SILENCE_REVOKE_ALREADY_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceNotAllowedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceNotAllowedErrorPacket.cs new file mode 100644 index 0000000..a6355e9 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceNotAllowedErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceNotAllowedErrorPacket : BotResponsePacket { + public SilenceNotAllowedErrorPacket(IUser sender) + : base(sender, BotArguments.SILENCE_NOT_ALLOWED_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceNoticePacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceNoticePacket.cs new file mode 100644 index 0000000..5920a53 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceNoticePacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceNoticePacket : BotResponsePacket { + public SilenceNoticePacket(IUser sender) + : base(sender, BotArguments.SILENCE_PLACE_NOTICE, false) {} + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceResponsePacket.cs new file mode 100644 index 0000000..cf6691e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceResponsePacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceResponsePacket : BotResponsePacket { + public SilenceResponsePacket(IUser sender, string userName) + : base(sender, BotArguments.SILENCE_PLACE_CONFIRM, false, userName) { } + + public SilenceResponsePacket(IUser sender, IUser target) + : this(sender, target.GetDisplayName()) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceRevokeNotAllowedErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceRevokeNotAllowedErrorPacket.cs new file mode 100644 index 0000000..f19ad87 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceRevokeNotAllowedErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceRevokeNotAllowedErrorPacket : BotResponsePacket { + public SilenceRevokeNotAllowedErrorPacket(IUser sender) + : base(sender, BotArguments.SILENCE_REVOKE_NOT_ALLOWED_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceRevokeNoticePacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceRevokeNoticePacket.cs new file mode 100644 index 0000000..4e8623d --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceRevokeNoticePacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceRevokeNoticePacket : BotResponsePacket { + public SilenceRevokeNoticePacket(IUser sender) + : base(sender, BotArguments.SILENCE_REVOKE_NOTICE, false) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceRevokeResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceRevokeResponsePacket.cs new file mode 100644 index 0000000..bc8037e --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceRevokeResponsePacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceRevokeResponsePacket : BotResponsePacket { + public SilenceRevokeResponsePacket(IUser sender, string userName) + : base(sender, BotArguments.SILENCE_REVOKE_CONFIRM, false, userName) { } + + public SilenceRevokeResponsePacket(IUser sender, IUser target) + : this(sender, target.GetDisplayName()) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilenceSelfErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/SilenceSelfErrorPacket.cs new file mode 100644 index 0000000..0c87244 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilenceSelfErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilenceSelfErrorPacket : BotResponsePacket { + public SilenceSelfErrorPacket(IUser sender) + : base(sender, BotArguments.SILENCE_SELF_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SilencedAlreadyErrorPacket.cs b/SharpChat.Protocol.SockChat/Packets/SilencedAlreadyErrorPacket.cs new file mode 100644 index 0000000..4db86e3 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SilencedAlreadyErrorPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SilencedAlreadyErrorPacket : BotResponsePacket { + public SilencedAlreadyErrorPacket(IUser sender) + : base(sender, BotArguments.SILENCE_ALREADY_ERROR, true) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/SwitchServerPacket.cs b/SharpChat.Protocol.SockChat/Packets/SwitchServerPacket.cs new file mode 100644 index 0000000..0c40a49 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/SwitchServerPacket.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SharpChat.Protocol.SockChat.Packets { + public class SwitchServerPacket : ServerPacket { + public SwitchServerPacket() { + // definition unfinished + // optional argument containing server, if no argument assume client has a list? + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.SwitchServer); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/TypingPacket.cs b/SharpChat.Protocol.SockChat/Packets/TypingPacket.cs new file mode 100644 index 0000000..d361bbf --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/TypingPacket.cs @@ -0,0 +1,29 @@ +using SharpChat.Channels; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class TypingPacket : ServerPacket { + public IChannel Channel { get; } + public object TypingInfo { get; } + + public TypingPacket(IChannel channel, object typingInfo) { + Channel = channel ?? throw new ArgumentNullException(nameof(channel)); + TypingInfo = typingInfo ?? throw new ArgumentNullException(nameof(typingInfo)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.TypingInfo); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Channel.Name); + sb.Append(IServerPacket.SEPARATOR); + //sb.Append(TypingInfo.User.UserId); + sb.Append(IServerPacket.SEPARATOR); + //sb.Append(TypingInfo.Started.ToUnixTimeSeconds()); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserConnectPacket.cs b/SharpChat.Protocol.SockChat/Packets/UserConnectPacket.cs new file mode 100644 index 0000000..c62b9c1 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserConnectPacket.cs @@ -0,0 +1,31 @@ +using SharpChat.Events; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserConnectPacket : ServerPacket { + private UserConnectEvent Connect { get; } + private IUser User { get; } + + public UserConnectPacket(UserConnectEvent connect, IUser user) { + Connect = connect ?? throw new ArgumentNullException(nameof(connect)); + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserConnect); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Connect.DateTime.ToUnixTimeSeconds()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.Pack()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Connect.EventId); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserDisconnectPacket.cs b/SharpChat.Protocol.SockChat/Packets/UserDisconnectPacket.cs new file mode 100644 index 0000000..9f28a09 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserDisconnectPacket.cs @@ -0,0 +1,51 @@ +using SharpChat.Events; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserDisconnectPacket : ServerPacket { + private UserDisconnectEvent Disconnect { get; } + private IUser User { get; } + + public UserDisconnectPacket(UserDisconnectEvent disconnect, IUser user) { + Disconnect = disconnect ?? throw new ArgumentNullException(nameof(disconnect)); + User = user ?? throw new ArgumentNullException(nameof(user)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserDisconnect); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(User.GetDisplayName()); + sb.Append(IServerPacket.SEPARATOR); + + switch(Disconnect.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(IServerPacket.SEPARATOR); + sb.Append(Disconnect.DateTime.ToUnixTimeSeconds()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Disconnect.EventId); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserListChannelNotFoundPacket.cs b/SharpChat.Protocol.SockChat/Packets/UserListChannelNotFoundPacket.cs new file mode 100644 index 0000000..b91f5b6 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserListChannelNotFoundPacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserListChannelNotFoundPacket : BotResponsePacket { + public UserListChannelNotFoundPacket(IUser sender, string channelName) + : base(sender, BotArguments.USER_LIST_ERROR, true, channelName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserListResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/UserListResponsePacket.cs new file mode 100644 index 0000000..75cb684 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserListResponsePacket.cs @@ -0,0 +1,38 @@ +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System.Collections.Generic; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserListResponsePacket : BotResponsePacket { + public UserListResponsePacket(IUser sender, IUser requester, IEnumerable users) + : base(sender, BotArguments.USER_LIST_ALL, false, MakeUserList(requester, users)) { } + + public UserListResponsePacket(IUser sender, IChannel channel, IUser requester, IEnumerable users) + : this(sender, channel.Name, requester, users) { } + + public UserListResponsePacket(IUser sender, string channelName, IUser requester, IEnumerable users) + : base(sender, BotArguments.USER_LIST_CHANNEL, false, channelName, MakeUserList(requester, users)) { } + + private static string MakeUserList(IUser requester, IEnumerable users) { + StringBuilder sb = new(); + + foreach(IUser user in users) { + sb.Append(@"'); + sb.Append(user.GetDisplayName()); + sb.Append(@", "); + } + + if(sb.Length > 2) + sb.Length -= 2; + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserNickChangePacket.cs b/SharpChat.Protocol.SockChat/Packets/UserNickChangePacket.cs new file mode 100644 index 0000000..83aa59b --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserNickChangePacket.cs @@ -0,0 +1,8 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserNickChangePacket : BotResponsePacket { + public UserNickChangePacket(IUser sender, string oldName, string newName) + : base(sender, BotArguments.NICKNAME_CHANGE, false, oldName, newName) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserNotFoundPacket.cs b/SharpChat.Protocol.SockChat/Packets/UserNotFoundPacket.cs new file mode 100644 index 0000000..2c58b87 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserNotFoundPacket.cs @@ -0,0 +1,10 @@ +using SharpChat.Users; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserNotFoundPacket : BotResponsePacket { + private const string FALLBACK = @"User"; + + public UserNotFoundPacket(IUser sender, string userName) + : base(sender, BotArguments.USER_NOT_FOUND_ERROR, true, userName ?? FALLBACK) { } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/UserUpdatePacket.cs b/SharpChat.Protocol.SockChat/Packets/UserUpdatePacket.cs new file mode 100644 index 0000000..6f4017c --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/UserUpdatePacket.cs @@ -0,0 +1,32 @@ +using SharpChat.Events; +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class UserUpdatePacket : ServerPacket { + private UserUpdateEvent Update { get; } + + public UserUpdatePacket(UserUpdateEvent uue) { + Update = uue ?? throw new ArgumentNullException(nameof(uue)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.UserUpdate); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Update.UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Update.GetDisplayName()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Update.NewColour ?? Update.OldColour); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Update.NewRank ?? Update.OldRank); + (Update.NewPerms ?? Update.OldPerms).Pack(sb); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/WelcomeMessagePacket.cs b/SharpChat.Protocol.SockChat/Packets/WelcomeMessagePacket.cs new file mode 100644 index 0000000..0b8d33b --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/WelcomeMessagePacket.cs @@ -0,0 +1,38 @@ +using SharpChat.Protocol.SockChat.Users; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Packets { + public class WelcomeMessagePacket : ServerPacket { + private IUser Sender { get; } + private string Message { get; } + + public WelcomeMessagePacket(IUser sender, string message) { + Sender = sender ?? throw new ArgumentNullException(nameof(sender)); + Message = message ?? throw new ArgumentNullException(nameof(message)); + } + + protected override string DoPack() { + StringBuilder sb = new(); + + sb.Append((int)ServerPacketId.ContextPopulate); + sb.Append(IServerPacket.SEPARATOR); + sb.Append((int)ServerContextSubPacketId.Message); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(Sender.Pack()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(new BotArguments(BotArguments.WELCOME, false, Message)); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(BotArguments.WELCOME); + sb.Append(IServerPacket.SEPARATOR); + sb.Append('0'); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(@"10010"); + + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Packets/WhoIsResponsePacket.cs b/SharpChat.Protocol.SockChat/Packets/WhoIsResponsePacket.cs new file mode 100644 index 0000000..f321b78 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Packets/WhoIsResponsePacket.cs @@ -0,0 +1,12 @@ +using SharpChat.Users; +using System.Net; + +namespace SharpChat.Protocol.SockChat.Packets { + public class WhoIsResponsePacket : BotResponsePacket { + public WhoIsResponsePacket(IUser sender, string userName, IPAddress ipAddress) + : base(sender, BotArguments.USER_IP_ADDRESS, false, userName, ipAddress) { } + + public WhoIsResponsePacket(IUser sender, IUser user, IPAddress ipAddress) + : this(sender, user.UserName, ipAddress) { } + } +} diff --git a/SharpChat.Protocol.SockChat/SharpChat.Protocol.SockChat.csproj b/SharpChat.Protocol.SockChat/SharpChat.Protocol.SockChat.csproj new file mode 100644 index 0000000..9cf6069 --- /dev/null +++ b/SharpChat.Protocol.SockChat/SharpChat.Protocol.SockChat.csproj @@ -0,0 +1,15 @@ + + + + net5.0 + + + + + + + + + + + diff --git a/SharpChat.Protocol.SockChat/SockChatConnection.cs b/SharpChat.Protocol.SockChat/SockChatConnection.cs new file mode 100644 index 0000000..1f60fb5 --- /dev/null +++ b/SharpChat.Protocol.SockChat/SockChatConnection.cs @@ -0,0 +1,57 @@ +using Fleck; +using SharpChat.Channels; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Sessions; +using System; +using System.Net; + +namespace SharpChat.Protocol.SockChat { + public class SockChatConnection : IConnection { + public const int ID_LENGTH = 16; + + public string ConnectionId { get; } + public IPAddress RemoteAddress { get; } + public bool IsSecure { get; } + + public bool IsAvailable => Connection.IsAvailable; + + public ClientCapability Capabilities { get; set; } + + private IWebSocketConnection Connection { get; } + private readonly object Sync = new(); + + public IChannel LastChannel { get; set; } + + public DateTimeOffset LastPing { get; set; } + public ISession Session { get; set; } + + public SockChatConnection(IWebSocketConnection conn) { + Connection = conn ?? throw new ArgumentNullException(nameof(conn)); + ConnectionId = @"SC!" + RNG.NextString(ID_LENGTH); + IPAddress remoteAddr = IPAddress.Parse(Connection.ConnectionInfo.ClientIpAddress); + RemoteAddress = IPAddress.IsLoopback(remoteAddr) + && Connection.ConnectionInfo.Headers.ContainsKey(@"X-Real-IP") + ? IPAddress.Parse(Connection.ConnectionInfo.Headers[@"X-Real-IP"]) + : remoteAddr; + } + + public bool HasCapability(ClientCapability capability) + => (Capabilities & capability) == capability; + + public void SendPacket(IServerPacket packet) { + lock(Sync) { + if(!Connection.IsAvailable) + return; + Connection.Send(packet.Pack()); + } + } + + public void Close() { + lock(Sync) + Connection.Close(); + } + + public override string ToString() + => $@"C#{ConnectionId}"; + } +} diff --git a/SharpChat.Protocol.SockChat/SockChatEnums.cs b/SharpChat.Protocol.SockChat/SockChatEnums.cs new file mode 100644 index 0000000..588d677 --- /dev/null +++ b/SharpChat.Protocol.SockChat/SockChatEnums.cs @@ -0,0 +1,167 @@ +using System; + +namespace SharpChat { + /// + /// Packet IDs sent from the client to the server. + /// + public enum ClientPacketId { + /************* + * VERSION 1 * + *************/ + + /// + /// Keep the current session alive and occupied. + /// + Ping = 0, + + /// + /// Authenticates the user and creates a session. + /// + Authenticate = 1, + + /// + /// Sends a message or a command. + /// + MessageSend = 2, + + /************* + * VERSION 2 * + *************/ + + /// + /// Informs the server which extensions the client supports. + /// + Capabilities = 3, + + /// + /// Informs the server that the client is currently typing a message. + /// + Typing = 4, + } + + /// + /// Packet IDs sent from the server to the client. + /// + public enum ServerPacketId { + /************* + * VERSION 1 * + *************/ + + /// + /// Response to the packet. + /// + Pong = 0, + + /// + /// Both acts as a response to and as a method to inform that a user has connected. + /// + UserConnect = 1, + + /// + /// Informs the client of a new message. + /// + MessageAdd = 2, + + /// + /// Informs the client that a user has disconnected. + /// + UserDisconnect = 3, + + /// + /// Informs the client that a channel may have been added, removed or updated. + /// + ChannelEvent = 4, + + /// + /// Informs the client that a user joined or left the channel they are in OR that the client has been forcibly moved to a different channel. + /// + UserMove = 5, + + /// + /// Informs the client that a message has been deleted. + /// + MessageDelete = 6, + + /// + /// Informs the client about preexisting users, channels and messages. + /// + ContextPopulate = 7, + + /// + /// Informs the client that it should clear its user list and/or channel list and/or message list. + /// + ContextClear = 8, + + /// + /// Informs the client that they've been kicked or banned. + /// + BAKA = 9, + + /// + /// Informs the client that another user has been updated. + /// + UserUpdate = 10, + + /************* + * VERSION 2 * + *************/ + + /// + /// Tells the client what capabilities have been accepted. + /// + CapabilityConfirm = 11, + + /// + /// Informs the client that another user is typing. + /// + TypingInfo = 12, + + /// + /// Tells the client that it should switch to a different server. + /// + SwitchServer = 13, + } + + /// + /// Actions for . + /// + public enum ServerChannelSubPacketId { + Create = 0, + Update = 1, + Delete = 2, + } + + /// + /// Actions for . + /// + public enum ServerMoveSubPacketId { + UserJoined = 0, + UserLeft = 1, + ForcedMove = 2, + } + + /// + /// Actions for . + /// + public enum ServerContextSubPacketId { + Users = 0, + Message = 1, + Channels = 2, + } + + /// + /// Capability list for and . + /// + [Flags] + public enum ClientCapability : int { + /// + /// Supports the typing event. + /// + TYPING = 0x01, + + /// + /// Supports being in multiple channels at once. + /// + MCHAN = 0x02, + } +} diff --git a/SharpChat.Protocol.SockChat/SockChatExtensions.cs b/SharpChat.Protocol.SockChat/SockChatExtensions.cs new file mode 100644 index 0000000..45b34bd --- /dev/null +++ b/SharpChat.Protocol.SockChat/SockChatExtensions.cs @@ -0,0 +1,31 @@ +namespace SharpChat.Protocol.SockChat { + public static class SockChatExtensions { + public static string CleanTextForMessage(this string str) { + return str + .Replace("\t", @" ") + .Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
"); + } + + public static string CleanCommandName(this string str) { + return str.Replace(@".", string.Empty).ToLowerInvariant(); + } + + public static string CleanTextForCommand(this string str) { + return str + .Replace(@"<", @"<") + .Replace(@">", @">") + .Replace("\n", @"
"); + } + + public static string CleanNickName(this string nick) { + return nick + .Replace(' ', '_') + .Replace("\n", string.Empty) + .Replace("\r", string.Empty) + .Replace("\f", string.Empty) + .Replace("\t", string.Empty); + } + } +} diff --git a/SharpChat.Protocol.SockChat/SockChatServer.cs b/SharpChat.Protocol.SockChat/SockChatServer.cs new file mode 100644 index 0000000..700cf15 --- /dev/null +++ b/SharpChat.Protocol.SockChat/SockChatServer.cs @@ -0,0 +1,355 @@ +using SharpChat.Configuration; +using SharpChat.Events; +using SharpChat.Messages; +using SharpChat.Protocol.SockChat.Commands; +using SharpChat.Protocol.SockChat.PacketHandlers; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.RateLimiting; +using SharpChat.Users; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace SharpChat.Protocol.SockChat { + [Server(@"sockchat")] + public class SockChatServer : IServer { + public const int DEFAULT_MAX_CONNECTIONS = 5; + + private Context Context { get; } + private FleckWebSocketServer Server { get; set; } + + private ConnectionList Connections { get; } + private IReadOnlyDictionary PacketHandlers { get; } + + private CachedValue WelcomeMessageValue { get; } + + public string WelcomeMessage => WelcomeMessageValue; + + public SockChatServer(Context ctx, IConfig config) { + Context = ctx ?? throw new ArgumentNullException(nameof(ctx)); + + if(config == null) + throw new ArgumentNullException(nameof(config)); + + WelcomeMessageValue = config.ReadCached(@"welcome", string.Empty); + + Context.Events.AddEventHandler(this); + + Connections = new ConnectionList(Context.Sessions, Context.ChannelUsers); + + Dictionary handlers = new(); + void addHandler(IPacketHandler handler) { + handlers.Add(handler.PacketId, handler); + }; + + addHandler(new PingPacketHandler(Context.Sessions)); + addHandler(new AuthPacketHandler( + this, + Context.Sessions, + Context.Users, + Context.Channels, + Context.ChannelUsers, + Context.Messages, + Context.DataProvider.UserClient, + Context.Bans, + Context.Bot, + Context.WelcomeMessage + )); + addHandler(new MessageSendPacketHandler(Context.Users, Context.Channels, Context.ChannelUsers, Context.Messages, Context.Bot, new ICommand[] { + new JoinCommand(Context.Channels, Context.ChannelUsers, Context.Sessions, Context.Bot), + new AFKCommand(Context.Users), + new WhisperCommand(), + new ActionCommand(Context.Messages), + new WhoCommand(Context.Users, Context.Channels, Context.ChannelUsers, Context.Bot), + new DeleteMessageCommand(Context.Messages, Context.Bot), + + new NickCommand(Context.Users, Context.Bot), + new CreateChannelCommand(Context.Channels, Context.ChannelUsers, Context.Bot), + new DeleteChannelCommand(Context.Channels, Context.Bot), + new ChannelPasswordCommand(Context.Channels, Context.Bot), + new ChannelRankCommand(Context.Channels, Context.Bot), + + new BroadcastCommand(Context, Context.Bot), + new KickBanUserCommand(Context.Users, Context.Bot), + new PardonUserCommand(Context.Bans, Context.Bot), + new PardonIPCommand(Context.Bans, Context.Bot), + new BanListCommand(Context.Bans, Context.Bot), + new WhoIsUserCommand(Context.Users, Context.Sessions, Context.Bot), + new SilenceUserCommand(Context.Users, Context.Bot), + new UnsilenceUserCommand(Context.Users, Context.Bot), + })); + addHandler(new CapabilitiesPacketHandler(Context.Sessions)); + addHandler(new TypingPacketHandler()); + + PacketHandlers = handlers; + } + + public void Listen(EndPoint endPoint) { + if(Server != null) + throw new ProtocolAlreadyListeningException(); + if(endPoint == null) + throw new ArgumentNullException(nameof(endPoint)); + if(endPoint is not IPEndPoint ipEndPoint) + throw new ArgumentException(@"EndPoint must be an IPEndPoint", nameof(endPoint)); + + Server = new FleckWebSocketServer(ipEndPoint, false); + Server.Start(rawConn => { + SockChatConnection conn = new(rawConn); + rawConn.OnOpen += () => OnOpen(conn); + rawConn.OnClose += () => OnClose(conn); + rawConn.OnError += ex => OnError(conn, ex); + rawConn.OnMessage += msg => OnMessage(conn, msg); + }); + } + + private void OnOpen(SockChatConnection conn) { + Logger.Debug($@"[{conn}] Connection opened"); + Connections.AddConnection(conn); + } + + private void OnClose(SockChatConnection conn) { + Logger.Debug($@"[{conn}] Connection closed"); + Connections.RemoveConnection(conn); + Context.Sessions.Destroy(conn); + } + + private static void OnError(SockChatConnection conn, Exception ex) { + Logger.Write($@"[{conn}] {ex}"); + } + + private void OnMessage(SockChatConnection conn, string msg) { + if(Context.RateLimiting.UpdateConnection(conn)) { + Logger.Debug($@"[{conn}] Rate limit exceeded."); + conn.Close(); + return; + } + + bool hasSession = conn.Session != null; + + IEnumerable args = msg.Split(IServerPacket.SEPARATOR); + if(!Enum.TryParse(args.ElementAtOrDefault(0), out ClientPacketId packetId)) + return; + + if(conn.Session != null) { + (bool warn, bool kick) = Context.RateLimiting.UpdateUser(conn.Session.User); + + if(kick) { + void applyBan(int count) { + Context.Bans.CreateBan( + conn.Session.User, + null, + false, + Context.RateLimiting.GetKickLength(count), + @"User was kicked for flood protection.", + success => { + Logger.Write($@"Flood protection kick of {conn.Session.User}: {success}."); + }, + ex => { + Logger.Write($@"Flood protection kick of {conn.Session.User} failed."); + Logger.Debug(ex); + } + ); + }; + + Context.Bans.CheckBan( + conn.Session.User, + conn.RemoteAddress, + banRecord => { + if(banRecord == null) + applyBan(1); + }, + ex => applyBan(1) + ); + return; + } + + if(warn) + conn.SendPacket(new FloodWarningPacket(Context.Bot)); + } + + if(PacketHandlers.TryGetValue(packetId, out IPacketHandler handler)) + handler.HandlePacket(new PacketHandlerContext(args, conn)); + } + + // the implementation of Everything here needs to be revised + // probably needs to be something that can more directly associate connections with user( id)s and session( id)s + public void HandleEvent(object sender, IEvent evt) { + switch(evt) { + case SessionPingEvent spe: + Connections.GetConnectionBySessionId(spe.SessionId, conn => { + if(conn == null) + return; + conn.LastPing = spe.DateTime; + conn.SendPacket(new PongPacket(spe)); + }); + break; + case SessionChannelSwitchEvent scwe: + Connections.GetConnectionBySessionId(scwe.SessionId, conn => { + if(conn == null) + return; + if(string.IsNullOrEmpty(scwe.ChannelId)) + Context.Channels.GetChannelById(scwe.ChannelId, channel => { + if(channel != null) + conn.LastChannel = channel; + conn.SendPacket(new ChannelSwitchPacket(conn.LastChannel)); + }); + }); + break; + case SessionDestroyEvent sde: + Connections.GetConnectionBySessionId(sde.SessionId, conn => { + if(conn == null) + return; + conn.Close(); + }); + break; + case SessionResumeEvent sre: + if(string.IsNullOrWhiteSpace(sre.ConnectionId)) + break; + Connections.GetConnection(sre.ConnectionId, conn => { + if(conn == null) + return; + Context.Sessions.GetSession(sre.SessionId, sess => { + if(sess == null) + return; + sess.Connection = conn; + conn.Session = sess; + }); + }); + break; + + case UserUpdateEvent uue: + UserUpdatePacket uuep = new(uue); + Connections.GetAllConnectionsByUserId(uue.UserId, conns => { + foreach(SockChatConnection conn in conns) + conn.SendPacket(uuep); + }); + break; + case UserDisconnectEvent ude: + Context.Users.GetUser(ude.UserId, user => { + UserDisconnectPacket udep = new(ude, user); + Connections.GetAllConnectionsByUserId(ude.UserId, conns => { + foreach(SockChatConnection conn in conns) + conn.SendPacket(udep); + }); + }); + break; + + case UserBanCreatedEvent ubce: + Connections.GetAllConnectionsByUserId(ubce.UserId, conns => { + ForceDisconnectPacket fde = new(ubce); + foreach(SockChatConnection conn in conns) { + conn.SendPacket(fde); + conn.Close(); + } + }); + break; + + case ChannelSessionJoinEvent csje: + UserJoinChannel(csje.ChannelId, csje.SessionId); + break; + + case ChannelUserJoinEvent cuje: // should send UserConnectPacket on first channel join + Context.Users.GetUser(cuje.UserId, user => { + ChannelJoinPacket cjep = new(cuje, user); + Connections.GetConnectionsByChannelId(cuje.ChannelId, conns => { + conns = conns.Where(c => !user.Equals(c.Session?.User)); + foreach(SockChatConnection conn in conns) + conn.SendPacket(cjep); + }); + }); + + UserJoinChannel(cuje.ChannelId, cuje.SessionId); + break; + case ChannelUserLeaveEvent cle: + ChannelLeavePacket clep = new(cle); + Connections.GetConnectionsByChannelId(cle.ChannelId, conns => { + foreach(SockChatConnection conn in conns) + conn.SendPacket(clep); + }); + break; + + case MessageCreateEvent mce: + Context.Channels.GetChannelById(mce.ChannelId, channel => { + if(channel == null) + return; + MessageCreatePacket mcep = new(mce.MessageId, mce.UserId, mce.DateTime, channel, mce.Text, mce.IsAction); + Connections.GetConnectionsByChannelId(mce.ChannelId, conns => { + foreach(SockChatConnection conn in conns) + conn.SendPacket(mcep); + }); + }); + break; + case MessageDeleteEvent mde: + MessageDeletePacket mdep = new(mde); + Connections.GetConnectionsByChannelId(mde.ChannelId, conns => { + foreach(SockChatConnection conn in conns) + conn.SendPacket(mdep); + }); + break; + case MessageUpdateEvent mue: + Context.Messages.GetMessage(mue.MessageId, msg => { + if(msg == null) + return; + + Context.Channels.GetChannel(msg.Channel, channel => { + if(channel == null) + return; + + MessageDeletePacket muepd = new(mue); + MessageCreatePacket muecd = new(mue.MessageId, msg.Sender.UserId, mue.DateTime, channel, mue.Text, msg.IsAction); + + Connections.GetConnectionsByChannelId(mue.ChannelId, conns => { + foreach(SockChatConnection conn in conns) { + conn.SendPacket(muepd); + conn.SendPacket(muecd); + } + }); + }); + }); + break; + + case BroadcastMessageEvent bme: + BroadcastMessagePacket bmep = new(bme); + Connections.GetConnectionsWithSession(conns => { + foreach(SockChatConnection conn in conns) + conn.SendPacket(bmep); + }); + break; + } + } + + private void UserJoinChannel(string channelId, string sessionId) { + Context.Sessions.GetLocalSession(sessionId, session => { + if(session == null || session.Connection is not SockChatConnection conn) + return; + + Context.Channels.GetChannelById(channelId, channel => { + Context.ChannelUsers.GetUsers(channel, users => conn.SendPacket( + new ContextUsersPacket(users.Except(new[] { session.User }).OrderByDescending(u => u.Rank)) + )); + + Context.Messages.GetMessages(channel, msgs => { + foreach(IMessage msg in msgs) + conn.SendPacket(new ContextMessagePacket(msg)); + }); + }); + }); + } + + private bool IsDisposed; + ~SockChatServer() + => DoDispose(); + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + Context.Events.RemoveEventHandler(this); + Server?.Dispose(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Users/IUserExtensions.cs b/SharpChat.Protocol.SockChat/Users/IUserExtensions.cs new file mode 100644 index 0000000..704c69f --- /dev/null +++ b/SharpChat.Protocol.SockChat/Users/IUserExtensions.cs @@ -0,0 +1,94 @@ +using SharpChat.Events; +using SharpChat.Protocol.SockChat.Packets; +using SharpChat.Users; +using System; +using System.Text; + +namespace SharpChat.Protocol.SockChat.Users { + public static class IUserExtensions { + public static string GetDisplayName(this IUser user) { + if(user is ChatBot) + return user.UserName; + + StringBuilder sb = new(); + + if(user is ILocalUser localUser) { + if(localUser.Status == UserStatus.Away) + sb.Append(localUser.StatusMessage.ToAFKString()); + + if(string.IsNullOrWhiteSpace(localUser.NickName)) + sb.Append(user.UserName); + else { + sb.Append('~'); + sb.Append(localUser.NickName); + } + } else + sb.Append(user.UserName); + + return sb.ToString(); + } + + public static string GetDisplayName(this UserUpdateEvent uue) { + StringBuilder sb = new(); + + if((uue.NewStatus ?? uue.OldStatus) == UserStatus.Away) + sb.Append((uue.NewStatusMessage ?? uue.OldStatusMessage).ToAFKString()); + + if(string.IsNullOrWhiteSpace(uue.NewNickName ?? uue.OldNickName)) + sb.Append(uue.NewUserName ?? uue.OldUserName); + else { + sb.Append('~'); + sb.Append(uue.NewNickName ?? uue.OldNickName); + } + + return sb.ToString(); + } + + public static string ToAFKString(this string str) + => string.Format(@"<{0}>_", str.Substring(0, Math.Min(str.Length, 5)).ToUpperInvariant()); + + public static string Pack(this IUser user) { + StringBuilder sb = new(); + user.Pack(sb); + return sb.ToString(); + } + + public static void Pack(this IUser user, StringBuilder sb) { + sb.Append(user.UserId); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(user.GetDisplayName()); + sb.Append(IServerPacket.SEPARATOR); + sb.Append(user.Colour); + sb.Append(IServerPacket.SEPARATOR); + + if(!user.IsBot()) { // permission part is empty for bot apparently + sb.Append(user.Rank); + sb.Append(' '); + sb.Append(user.Can(UserPermissions.KickUser) ? '1' : '0'); + sb.Append(@" 0 "); + sb.Append(user.Can(UserPermissions.SetOwnNickname) ? '1' : '0'); + sb.Append(' '); + sb.Append(user.Can(UserPermissions.CreateChannel | UserPermissions.SetChannelPermanent) ? 2 : ( + user.Can(UserPermissions.CreateChannel) ? 1 : 0 + )); + } + } + + public static void Pack(this UserPermissions perms, StringBuilder sb) { + sb.Append(' '); + sb.Append((perms & UserPermissions.KickUser) > 0 ? '1' : '0'); + sb.Append(' '); + sb.Append(0); // Legacy view logs + sb.Append(' '); + sb.Append((perms & UserPermissions.SetOwnNickname) > 0 ? '1' : '0'); + sb.Append(' '); + sb.Append((perms & UserPermissions.CreateChannel) > 0 ? ((perms & UserPermissions.SetChannelPermanent) > 0 ? 2 : 1) : 0); + } + + public static string Pack(this UserPermissions perms) { + StringBuilder sb = new(); + perms.Pack(sb); + return sb.ToString(); + } + } +} diff --git a/SharpChat.Protocol.SockChat/Users/UserManagerExtensions.cs b/SharpChat.Protocol.SockChat/Users/UserManagerExtensions.cs new file mode 100644 index 0000000..5219d32 --- /dev/null +++ b/SharpChat.Protocol.SockChat/Users/UserManagerExtensions.cs @@ -0,0 +1,19 @@ +using SharpChat.Users; +using System; + +namespace SharpChat.Protocol.SockChat.Users { + public static class UserManagerExtensions { + public static void GetUserBySockChatName(this UserManager users, string userName, Action callback) { + if(userName == null) + throw new ArgumentNullException(nameof(userName)); + if(callback == null) + throw new ArgumentNullException(nameof(callback)); + users.GetUser( + u => userName.Equals(u.GetDisplayName(), StringComparison.InvariantCultureIgnoreCase) + || userName.Equals(u.UserName, StringComparison.InvariantCultureIgnoreCase) + || userName.Equals(u.NickName, StringComparison.InvariantCultureIgnoreCase), + callback + ); + } + } +} diff --git a/SharpChat.sln b/SharpChat.sln index 0140e92..c4643dd 100644 --- a/SharpChat.sln +++ b/SharpChat.sln @@ -5,7 +5,44 @@ 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}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat.Common", "SharpChat.Common\SharpChat.Common.csproj", "{15CA52F0-8883-44BF-8DD8-80224795D78F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat.Database.MariaDB", "SharpChat.Database.MariaDB\SharpChat.Database.MariaDB.csproj", "{E53DA108-2C3A-4F7B-AACC-CDA44CD04C92}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Databases", "Databases", "{EC70B2EC-31AE-44B3-8259-2E718F224420}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataProviders", "DataProviders", "{9CF387F1-938F-4B3B-AEB1-992F6686BDD0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat.Database.SQLite", "SharpChat.Database.SQLite\SharpChat.Database.SQLite.csproj", "{B0873F00-4EB9-465A-8BB3-C1C9000EF395}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat.DataProvider.Misuzu", "SharpChat.DataProvider.Misuzu\SharpChat.DataProvider.Misuzu.csproj", "{13A7839E-FA19-4C9D-994D-941499DA9763}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MisuzuDataProviderTest", "MisuzuDataProviderTest\MisuzuDataProviderTest.csproj", "{37D6A2A4-2C3B-49C9-90FA-0FA850A15B44}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Http", "Http", "{5DA078C6-EF2C-4813-8255-572EE327D19E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpClientTest", "HttpClientTest\HttpClientTest.csproj", "{13E15EEC-D79B-444C-BB68-4D989F3A59C2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Protocols", "Protocols", "{4B2F7C61-54CC-4011-B77A-CB0CAED71963}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat.Protocol.SockChat", "SharpChat.Protocol.SockChat\SharpChat.Protocol.SockChat.csproj", "{33EA7582-B493-4E82-94AD-FDEA5E42AFD1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat.Protocol.IRC", "SharpChat.Protocol.IRC\SharpChat.Protocol.IRC.csproj", "{A376597C-B357-4072-96F7-9A6230EE4A23}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{60E17F79-4B06-4ED7-905B-59B2D6B6F763}" + ProjectSection(SolutionItems) = preProject + .gitattributes = .gitattributes + .gitignore = .gitignore + LICENSE = LICENSE + Protocol-draft.md = Protocol-draft.md + Protocol.md = Protocol.md + README.md = README.md + start.sh = start.sh + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hamakaze", "hamakaze", "{858BA251-E20C-433A-827E-2DCA8385D19C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hamakaze", "hamakaze\Hamakaze\Hamakaze.csproj", "{741C1564-0784-4699-A9C9-3C7B70F6EC12}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,14 +54,56 @@ Global {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 + {15CA52F0-8883-44BF-8DD8-80224795D78F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15CA52F0-8883-44BF-8DD8-80224795D78F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15CA52F0-8883-44BF-8DD8-80224795D78F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15CA52F0-8883-44BF-8DD8-80224795D78F}.Release|Any CPU.Build.0 = Release|Any CPU + {E53DA108-2C3A-4F7B-AACC-CDA44CD04C92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E53DA108-2C3A-4F7B-AACC-CDA44CD04C92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E53DA108-2C3A-4F7B-AACC-CDA44CD04C92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E53DA108-2C3A-4F7B-AACC-CDA44CD04C92}.Release|Any CPU.Build.0 = Release|Any CPU + {B0873F00-4EB9-465A-8BB3-C1C9000EF395}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0873F00-4EB9-465A-8BB3-C1C9000EF395}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0873F00-4EB9-465A-8BB3-C1C9000EF395}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0873F00-4EB9-465A-8BB3-C1C9000EF395}.Release|Any CPU.Build.0 = Release|Any CPU + {13A7839E-FA19-4C9D-994D-941499DA9763}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13A7839E-FA19-4C9D-994D-941499DA9763}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13A7839E-FA19-4C9D-994D-941499DA9763}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13A7839E-FA19-4C9D-994D-941499DA9763}.Release|Any CPU.Build.0 = Release|Any CPU + {37D6A2A4-2C3B-49C9-90FA-0FA850A15B44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37D6A2A4-2C3B-49C9-90FA-0FA850A15B44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37D6A2A4-2C3B-49C9-90FA-0FA850A15B44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37D6A2A4-2C3B-49C9-90FA-0FA850A15B44}.Release|Any CPU.Build.0 = Release|Any CPU + {13E15EEC-D79B-444C-BB68-4D989F3A59C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13E15EEC-D79B-444C-BB68-4D989F3A59C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13E15EEC-D79B-444C-BB68-4D989F3A59C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13E15EEC-D79B-444C-BB68-4D989F3A59C2}.Release|Any CPU.Build.0 = Release|Any CPU + {33EA7582-B493-4E82-94AD-FDEA5E42AFD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33EA7582-B493-4E82-94AD-FDEA5E42AFD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33EA7582-B493-4E82-94AD-FDEA5E42AFD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33EA7582-B493-4E82-94AD-FDEA5E42AFD1}.Release|Any CPU.Build.0 = Release|Any CPU + {A376597C-B357-4072-96F7-9A6230EE4A23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A376597C-B357-4072-96F7-9A6230EE4A23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A376597C-B357-4072-96F7-9A6230EE4A23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A376597C-B357-4072-96F7-9A6230EE4A23}.Release|Any CPU.Build.0 = Release|Any CPU + {741C1564-0784-4699-A9C9-3C7B70F6EC12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {741C1564-0784-4699-A9C9-3C7B70F6EC12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {741C1564-0784-4699-A9C9-3C7B70F6EC12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {741C1564-0784-4699-A9C9-3C7B70F6EC12}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E53DA108-2C3A-4F7B-AACC-CDA44CD04C92} = {EC70B2EC-31AE-44B3-8259-2E718F224420} + {B0873F00-4EB9-465A-8BB3-C1C9000EF395} = {EC70B2EC-31AE-44B3-8259-2E718F224420} + {13A7839E-FA19-4C9D-994D-941499DA9763} = {9CF387F1-938F-4B3B-AEB1-992F6686BDD0} + {37D6A2A4-2C3B-49C9-90FA-0FA850A15B44} = {9CF387F1-938F-4B3B-AEB1-992F6686BDD0} + {13E15EEC-D79B-444C-BB68-4D989F3A59C2} = {5DA078C6-EF2C-4813-8255-572EE327D19E} + {33EA7582-B493-4E82-94AD-FDEA5E42AFD1} = {4B2F7C61-54CC-4011-B77A-CB0CAED71963} + {A376597C-B357-4072-96F7-9A6230EE4A23} = {4B2F7C61-54CC-4011-B77A-CB0CAED71963} + {741C1564-0784-4699-A9C9-3C7B70F6EC12} = {858BA251-E20C-433A-827E-2DCA8385D19C} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {42279FE1-5980-440A-87F8-25338DFE54CF} EndGlobalSection diff --git a/SharpChat/BanManager.cs b/SharpChat/BanManager.cs deleted file mode 100644 index 782c221..0000000 --- a/SharpChat/BanManager.cs +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index bfac51f..0000000 --- a/SharpChat/ChannelManager.cs +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index 0e856b6..0000000 --- a/SharpChat/ChatChannel.cs +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 6e55ae4..0000000 --- a/SharpChat/ChatChannelTyping.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index c80ca90..0000000 --- a/SharpChat/ChatColour.cs +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 5e4ad95..0000000 --- a/SharpChat/ChatContext.cs +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 35bc3a3..0000000 --- a/SharpChat/ChatEventManager.cs +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 1806e7d..0000000 --- a/SharpChat/ChatRateLimiter.cs +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 769542c..0000000 --- a/SharpChat/ChatUser.cs +++ /dev/null @@ -1,216 +0,0 @@ -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/ChatUserSession.cs b/SharpChat/ChatUserSession.cs deleted file mode 100644 index 69c6658..0000000 --- a/SharpChat/ChatUserSession.cs +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 63cf945..0000000 --- a/SharpChat/ChatUserStatus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SharpChat { - public enum ChatUserStatus { - Online, - Away, - Offline, - } -} diff --git a/SharpChat/Commands/AFKCommand.cs b/SharpChat/Commands/AFKCommand.cs deleted file mode 100644 index de30586..0000000 --- a/SharpChat/Commands/AFKCommand.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 5b28e3e..0000000 --- a/SharpChat/Database.cs +++ /dev/null @@ -1,230 +0,0 @@ -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 deleted file mode 100644 index c01516f..0000000 --- a/SharpChat/Database_Migrations.cs +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 83e2a3c..0000000 --- a/SharpChat/Events/ChatMessage.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index d78b202..0000000 --- a/SharpChat/Events/IChatEvent.cs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index fa54412..0000000 --- a/SharpChat/Events/UserChannelJoinEvent.cs +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 3d52219..0000000 --- a/SharpChat/Events/UserChannelLeaveEvent.cs +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 5d8ab69..0000000 --- a/SharpChat/Events/UserConnectEvent.cs +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index b6cd52c..0000000 --- a/SharpChat/Events/UserDisconnectEvent.cs +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 247ea6c..0000000 --- a/SharpChat/Extensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 7382bb3..0000000 --- a/SharpChat/Flashii/FlashiiAuth.cs +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 56d411d..0000000 --- a/SharpChat/Flashii/FlashiiBan.cs +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 06c6168..0000000 --- a/SharpChat/Flashii/FlashiiBump.cs +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 58c939b..0000000 --- a/SharpChat/Flashii/FlashiiUrls.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 92f03b0..0000000 --- a/SharpChat/IChatCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 5bab770..0000000 --- a/SharpChat/IChatCommandContext.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 50e442e..0000000 --- a/SharpChat/IPacketTarget.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SharpChat { - public interface IPacketTarget { - string TargetName { get; } - void Send(IServerPacket packet); - } -} diff --git a/SharpChat/IServerPacket.cs b/SharpChat/IServerPacket.cs deleted file mode 100644 index 6d63d82..0000000 --- a/SharpChat/IServerPacket.cs +++ /dev/null @@ -1,22 +0,0 @@ -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/Packet/AuthSuccessPacket.cs b/SharpChat/Packet/AuthSuccessPacket.cs deleted file mode 100644 index b886066..0000000 --- a/SharpChat/Packet/AuthSuccessPacket.cs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 8979bcd..0000000 --- a/SharpChat/Packet/BanListPacket.cs +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 8bab3c2..0000000 --- a/SharpChat/Packet/ChannelCreatePacket.cs +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 615319e..0000000 --- a/SharpChat/Packet/ChannelDeletePacket.cs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index a151885..0000000 --- a/SharpChat/Packet/ChannelUpdatePacket.cs +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index b42ca87..0000000 --- a/SharpChat/Packet/ChatMessageAddPacket.cs +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 0d94b74..0000000 --- a/SharpChat/Packet/ChatMessageDeletePacket.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index f7919bb..0000000 --- a/SharpChat/Packet/ContextChannelsPacket.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 80b10b8..0000000 --- a/SharpChat/Packet/ContextClearPacket.cs +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 2b87d77..0000000 --- a/SharpChat/Packet/ContextMessagePacket.cs +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index bb13774..0000000 --- a/SharpChat/Packet/ContextUsersPacket.cs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 410ac86..0000000 --- a/SharpChat/Packet/FloodWarningPacket.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 1de41aa..0000000 --- a/SharpChat/Packet/ForceDisconnectPacket.cs +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index c4fb3fa..0000000 --- a/SharpChat/Packet/LegacyCommandResponse.cs +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index bda008e..0000000 --- a/SharpChat/Packet/PongPacket.cs +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 57b2dfd..0000000 --- a/SharpChat/Packet/TypingPacket.cs +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index da58ea2..0000000 --- a/SharpChat/Packet/UserChannelForceJoinPacket.cs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 39b7dd6..0000000 --- a/SharpChat/Packet/UserChannelJoinPacket.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 2bc6275..0000000 --- a/SharpChat/Packet/UserChannelLeavePacket.cs +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index b04bbf5..0000000 --- a/SharpChat/Packet/UserConnectPacket.cs +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 1b3623b..0000000 --- a/SharpChat/Packet/UserDisconnectPacket.cs +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 36d12fe..0000000 --- a/SharpChat/Packet/UserUpdatePacket.cs +++ /dev/null @@ -1,42 +0,0 @@ -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 index 16ebfb5..8f8f7e4 100644 --- a/SharpChat/Program.cs +++ b/SharpChat/Program.cs @@ -1,10 +1,31 @@ using Hamakaze; +using SharpChat.Configuration; +using SharpChat.Database; +using SharpChat.Database.Null; +using SharpChat.DataProvider; +using SharpChat.DataProvider.Null; +using SharpChat.Protocol; +using SharpChat.Protocol.IRC; +using SharpChat.Protocol.Null; +using SharpChat.Protocol.SockChat; +using SharpChat.Reflection; using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; using System.Threading; namespace SharpChat { public class Program { - public const ushort PORT = 6770; + public const string CONFIG = @"sharpchat.cfg"; + public const ushort DEFAULT_PORT = 6770; + + private static string GetFlagArgument(string[] args, string flag) { + int offset = Array.IndexOf(args, flag) + 1; + return offset < 1 ? null : args.ElementAtOrDefault(offset); + } public static void Main(string[] args) { Console.WriteLine(@" _____ __ ________ __ "); @@ -12,19 +33,203 @@ namespace SharpChat { Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/"); Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ "); Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ "); - Console.WriteLine(@" / _/ Sock Chat Server"); + /**/Console.Write(@" / _/"); + if(SharpInfo.IsDebugBuild) { + Console.WriteLine(); + Console.Write(@"== "); + Console.Write(SharpInfo.VersionString); + Console.WriteLine(@" == DBG =="); + } else + Console.WriteLine(SharpInfo.VersionStringShort.PadLeft(28, ' ')); + + string configFile = GetFlagArgument(args, @"--cfg") ?? CONFIG; + + // If the config file doesn't exist and we're using the default path, run the converter + if(!File.Exists(configFile) && configFile == CONFIG) + ConvertConfiguration(); + + using IConfig config = new StreamConfig(configFile); + + // Load database and data provider libraries + ReflectionUtilities.LoadAssemblies(@"SharpChat.Database.*.dll"); + ReflectionUtilities.LoadAssemblies(@"SharpChat.DataProvider.*.dll"); + ReflectionUtilities.LoadAssemblies(@"SharpChat.Protocol.*.dll"); + + // Allow forcing a sqlite database through console flags + string sqliteDbPath = GetFlagArgument(args, @"--dbpath"); + string databaseBackendName; + object databaseArgument; + if(!string.IsNullOrEmpty(sqliteDbPath)) { + Logger.Write($@"Forcing SQLite: {sqliteDbPath}"); + databaseBackendName = @"sqlite"; + databaseArgument = sqliteDbPath; + } else { + databaseBackendName = GetFlagArgument(args, @"--dbb") ?? config.ReadValue(@"db"); + databaseArgument = config.ScopeTo($@"db:{databaseBackendName}"); + } + + IDatabaseBackend databaseBackend = new ObjectConstructor() + .Construct(databaseBackendName, databaseArgument); + + using HttpClient httpClient = new() { + DefaultUserAgent = @"SharpChat/1.0", + }; + + string dataProviderName = GetFlagArgument(args, @"--dpn") ?? config.ReadValue(@"dp"); + IDataProvider dataProvider = new ObjectConstructor() + .Construct(dataProviderName, config.ScopeTo($@"dp:{dataProviderName}"), httpClient); + + string portArg = GetFlagArgument(args, @"--port") ?? config.ReadValue(@"chat:port"); + if(string.IsNullOrEmpty(portArg) || !ushort.TryParse(portArg, out ushort port)) + port = DEFAULT_PORT; + + Win32.IncreaseThreadPrecision(); + + ObjectConstructor serverConstructor = new(); + + Logger.Write(@"Creating context..."); + using(Context ctx = new(config.ScopeTo(@"chat"), databaseBackend, dataProvider)) { + List servers = new(); + + // Crusty temporary solution, just want to have the variable constructor arguments for servers in place already + + string[] serverNames = new[] { + @"sockchat", #if DEBUG - Console.WriteLine(@"============================================ DEBUG =="); + @"irc", #endif + }; - HttpClient.Instance.DefaultUserAgent = @"SharpChat/0.9"; + foreach(string serverName in serverNames) { + Logger.Write($@"Starting {serverName} server..."); + IServer server = serverConstructor.Construct(serverName, ctx, config.ScopeTo(serverName)); + servers.Add(server); - Database.ReadConfig(); + if(server is SockChatServer) + server.Listen(new IPEndPoint(IPAddress.Any, port)); + else if(server is IRCServer) + server.Listen(new IPEndPoint(IPAddress.Any, 6667)); + } - using ManualResetEvent mre = new ManualResetEvent(false); - using SockChatServer scs = new SockChatServer(PORT); - Console.CancelKeyPress += (s, e) => { e.Cancel = true; mre.Set(); }; - mre.WaitOne(); + using ManualResetEvent mre = new(false); + Console.CancelKeyPress += (s, e) => { e.Cancel = true; mre.Set(); }; + mre.WaitOne(); + + foreach(IServer server in servers) + server.Dispose(); + + if(dataProvider is IDisposable dpd) + dpd.Dispose(); + if(databaseBackend is IDisposable dbd) + dbd.Dispose(); + } + + Win32.RestoreThreadPrecision(); + } + + private static void ConvertConfiguration() { + using Stream s = new FileStream(CONFIG, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + s.SetLength(0); + s.Flush(); + + using StreamWriter sw = new(s, new UTF8Encoding(false)); + + sw.WriteLine(@"# and ; can be used at the start of a line for comments."); + sw.WriteLine(); + + sw.WriteLine(@"# General Configuration"); + sw.WriteLine($@"#chat:port {DEFAULT_PORT}"); + sw.WriteLine($@"#chat:messages:maxLength {Messages.MessageManager.DEFAULT_LENGTH_MAX}"); + sw.WriteLine($@"#chat:sessions:timeOut {Sessions.SessionManager.DEFAULT_TIMEOUT}"); + sw.WriteLine($@"#chat:sessions:maxCount {Sessions.SessionManager.DEFAULT_MAX_COUNT}"); + sw.WriteLine(); + + sw.WriteLine(@"# Rate Limiter Configuration"); + sw.WriteLine($@"#chat:rateLimit:userSize {RateLimiting.RateLimitManager.DEFAULT_USER_SIZE}"); + sw.WriteLine($@"#chat:rateLimit:userWarnSize {RateLimiting.RateLimitManager.DEFAULT_USER_WARN_SIZE}"); + sw.WriteLine($@"#chat:rateLimit:connSize {RateLimiting.RateLimitManager.DEFAULT_CONN_SIZE}"); + sw.WriteLine($@"#chat:rateLimit:minDelay {RateLimiting.RateLimitManager.DEFAULT_MINIMUM_DELAY}"); + sw.WriteLine($@"#chat:rateLimit:kickLength {RateLimiting.RateLimitManager.DEFAULT_KICK_LENGTH}"); + sw.WriteLine($@"#chat:rateLimit:kickMultiplier {RateLimiting.RateLimitManager.DEFAULT_KICK_MULTIPLIER}"); + sw.WriteLine(); + + sw.WriteLine(@"# Channels"); + sw.WriteLine(@"chat:channels lounge staff"); + sw.WriteLine(); + + sw.WriteLine(@"# Lounge channel settings"); + sw.WriteLine(@"chat:channels:lounge:autoJoin true"); + sw.WriteLine(); + + sw.WriteLine(@"# Staff channel settings"); + sw.WriteLine(@"chat:channels:staff:minRank 5"); + sw.WriteLine(); + + const string msz_config = @"login_key.txt"; + + sw.WriteLine(@"# Selected DataProvider (misuzu, null)"); + if(!File.Exists(msz_config)) + sw.WriteLine(@"dp null"); + else { + sw.WriteLine(@"dp misuzu"); + sw.WriteLine(); + sw.WriteLine(@"# Misuzu DataProvider settings"); + sw.WriteLine(@"#db:misuzu:userId 61"); + sw.Write(@"dp:misuzu:secret "); + sw.Write(File.ReadAllText(msz_config).Trim()); + sw.WriteLine(); + sw.Write(@"dp:misuzu:endpoint "); +#if DEBUG + sw.Write(@"https://misuzu.misaka.nl/_sockchat"); +#else + sw.Write(@"https://flashii.net/_sockchat"); +#endif + sw.WriteLine(); + } + + sw.WriteLine(); + + const string sql_config = @"sqlite.txt"; + const string mdb_config = @"mariadb.txt"; + + bool hasMDB = File.Exists(mdb_config), + hasSQL = File.Exists(sql_config); + + sw.WriteLine(@"# Selected Database Backend (mariadb, sqlite, null)"); + if(hasMDB) + sw.WriteLine(@"db mariadb"); + else if(hasSQL) + sw.WriteLine(@"db sqlite"); + else + sw.WriteLine(@"db null"); + sw.WriteLine(); + + if(hasMDB) { + string[] mdbCfg = File.ReadAllLines(mdb_config); + sw.WriteLine(@"# MariaDB configuration"); + sw.WriteLine($@"db:mariadb:host {mdbCfg[0]}"); + if(mdbCfg.Length > 1) + sw.WriteLine($@"db:mariadb:user {mdbCfg[1]}"); + else + sw.WriteLine($@"#db:mariadb:user "); + if(mdbCfg.Length > 2) + sw.WriteLine($@"db:mariadb:pass {mdbCfg[2]}"); + else + sw.WriteLine($@"#db:mariadb:pass "); + if(mdbCfg.Length > 3) + sw.WriteLine($@"db:mariadb:db {mdbCfg[3]}"); + else + sw.WriteLine($@"#db:mariadb:db "); + sw.WriteLine(); + } + + if(hasSQL) { + string[] sqlCfg = File.ReadAllLines(sql_config); + sw.WriteLine(@"# SQLite configuration"); + sw.WriteLine($@"db:sqlite:path {sqlCfg[0]}"); + } + + sw.Flush(); } } } diff --git a/SharpChat/SharpChat.csproj b/SharpChat/SharpChat.csproj index 6d2806a..1e527bb 100644 --- a/SharpChat/SharpChat.csproj +++ b/SharpChat/SharpChat.csproj @@ -6,12 +6,13 @@ - - - - - - + + + + + + + diff --git a/SharpChat/SockChatEnums.cs b/SharpChat/SockChatEnums.cs deleted file mode 100644 index 99ea7ca..0000000 --- a/SharpChat/SockChatEnums.cs +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index a9dc4f5..0000000 --- a/SharpChat/SockChatServer.cs +++ /dev/null @@ -1,836 +0,0 @@ -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 deleted file mode 100644 index 605882c..0000000 --- a/SharpChat/UserManager.cs +++ /dev/null @@ -1,90 +0,0 @@ -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); - } - } -} diff --git a/SharpChat/Win32.cs b/SharpChat/Win32.cs new file mode 100644 index 0000000..660607d --- /dev/null +++ b/SharpChat/Win32.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; + +namespace SharpChat { + public static class Win32 { + public static bool RunningOnWindows + => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public static void IncreaseThreadPrecision() { + if(RunningOnWindows) + timeBeginPeriod(1); + } + + public static void RestoreThreadPrecision() { + if(RunningOnWindows) + timeEndPeriod(1); + } + + [DllImport(@"winmm")] + public static extern uint timeBeginPeriod(uint period); + + [DllImport(@"winmm")] + public static extern uint timeEndPeriod(uint period); + } +} diff --git a/debug.bat b/debug.bat new file mode 100644 index 0000000..57ff8a5 --- /dev/null +++ b/debug.bat @@ -0,0 +1,4 @@ +@echo off +SET DOTNET_CLI_TELEMETRY_OPTOUT=1 +SET LANG=en_US.UTF-8 +dotnet run --project SharpChat -c Debug diff --git a/sharpchat.cfg b/sharpchat.cfg new file mode 100644 index 0000000..c1a1c91 --- /dev/null +++ b/sharpchat.cfg @@ -0,0 +1,31 @@ +# and ; can be used at the start of a line for comments. + +# General Configuration +#chat:port 6770 +#chat:messages:maxLength 2100 +#chat:sessions:timeOut 5 +#chat:sessions:maxCount 5 + +# Rate Limiter Configuration +#chat:rateLimit:userSize 15 +#chat:rateLimit:userWarnSize 10 +#chat:rateLimit:connSize 30 +#chat:rateLimit:minDelay 5000 +#chat:rateLimit:kickLength 5 +#chat:rateLimit:kickMultiplier 2 + +# Channels +chat:channels lounge staff + +# Lounge channel settings +chat:channels:lounge:autoJoin true + +# Staff channel settings +chat:channels:staff:minRank 5 + +# Selected DataProvider (misuzu, null) +dp null + +# Selected Database Backend (mariadb, sqlite, null) +db null + diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..78c6d26 --- /dev/null +++ b/start.sh @@ -0,0 +1,3 @@ +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export LANG=en_US.UTF-8 +dotnet run --project SharpChat -c Release