Compare commits

...

75 commits

Author SHA1 Message Date
flash fa8c416b77 Use server start timestamp for welcome MOTD message and MOTD file last write for the other one. 2024-05-20 02:27:46 +00:00
flash 1d781bd72c Added base class for packets with timestamp. 2024-05-20 02:16:38 +00:00
flash 042b6ddbd6 Removed IServerPacket interface. 2024-05-20 01:35:39 +00:00
flash c490dcf128 Extracted all log packets into their own ones. 2024-05-20 01:35:33 +00:00
flash 549c80740d Rewrote user and channel collections. 2024-05-19 21:02:17 +00:00
flash 1a8c44a4ba Cleaned up the names of some of the base classes. 2024-05-19 02:17:51 +00:00
flash bd23d3aa15 Some cleanups (snapshot, don't run this). 2024-05-19 01:53:32 +00:00
flash 68a523f76a Use HasFlag instead of custom Can method. 2024-05-17 23:50:22 +00:00
flash 322500739e Moved some things out of the MessagePopulatePacket class. 2024-05-14 22:56:56 +00:00
flash a6a7e56bd1 Drew the rest of the fucking owl. 2024-05-14 22:17:25 +00:00
flash 7bcf5acb7e Created more discrete error/response packets. 2024-05-14 19:11:09 +00:00
flash 38f17c325a Apparently markdown doesn't have underlining. 2024-05-14 17:54:59 +00:00
flash 907711e753 Split some packets out of LegacyCommandResponse. 2024-05-13 20:55:54 +00:00
flash 8cc00fe1a8 Updated protocol information document. 2024-05-10 23:50:40 +00:00
flash 3c58e5201a Updated LICENSE year. 2024-05-10 19:29:03 +00:00
flash 795a87fe56 Added migration to update event types of older messages. 2024-05-10 19:23:19 +00:00
flash a6569815af Enabled explicit nullable. 2024-05-10 19:18:55 +00:00
flash b95cd06cb1 Reduce usage of working objects in packet objects as much as possible. 2024-05-10 18:29:48 +00:00
flash 356409eb16 Simplified packet building. 2024-05-10 17:28:52 +00:00
flash 1ba94a526c Removed unused method from ChatMessageAddPacket. 2024-05-10 15:25:50 +00:00
flash 0b0de00cc4 Updated MySQL connector library. 2024-05-10 15:24:56 +00:00
flash b1fae4bdeb Simplified Pack method return type. 2024-05-10 15:24:43 +00:00
flash fc7d428f76 Split name change notification out of UserUpdatePacket. 2024-05-10 15:07:56 +00:00
flash 54af837c82 Fixed various inconsistencies with Sock Chat. 2024-05-09 21:31:19 +00:00
flash 0d0f2e68b9 client -> server 2024-04-17 20:44:23 +00:00
flash e291a17705 Updated Pong packet documentation, removed column used for version info and reverted 'Sequence ID' back to 'Message ID'. 2024-04-17 20:41:15 +00:00
flash cd32995367 Removed mention of versioning. 2024-04-17 20:31:25 +00:00
flash 5985f63744 Allow super users to kick anyone regardless of ranking. 2023-11-07 14:50:55 +00:00
flash a9ca3705ad Keep track of super user status. 2023-11-07 14:49:12 +00:00
flash 294471dcfd Fixed inverted permission for /create.
For the love of god remember to update the permissions table and recalculate before starting back up.
2023-11-07 14:33:07 +00:00
flash c46d117d15 Merge branch 'new-master' of git.flash.moe:flashii/sharp-chat into new-master 2023-11-04 23:37:13 +00:00
flash 05fcbcb0f8 Fixed issues relating to event_sender_name being nullable for some reason. 2023-11-04 23:37:02 +00:00
flash 03b3b6b0a3 Fixed issue when starting without any configuration data present. 2023-10-01 04:54:30 +02:00
flash a7a05f04bd Don't remove AFK status when opening a new connection. 2023-08-09 14:59:29 +00:00
flash dc4989a3cf Fixed connection error when issuing a permanent ban. 2023-07-23 21:45:10 +00:00
flash 903e39ab76 Removed any remaining references to silencing. 2023-07-23 21:36:22 +00:00
flash 8c19c22736 Reworking event dispatching... I think?
I did this make in february but left it uncommitted. Hopefully it's stable!
2023-07-23 21:31:13 +00:00
flash 4e0def980f Revised event storage to use less magic classes. 2023-02-23 22:46:49 +01:00
flash 82973f7a33 Improved user updating but also other things that were already local. 2023-02-22 01:28:53 +01:00
flash 8de3ba8dbb Even slightly lesser aggressive question mark 2023-02-19 23:47:53 +01:00
flash 86a46539f2 Less haphazard locking (perhaps too?) 2023-02-19 23:27:08 +01:00
flash 70df99fe9b Significantly less stupid connection resolving. 2023-02-17 23:17:24 +01:00
flash 546e8a2c83 Simplify rate limiter, disabled silencing and merged BasicUser and ChatUser. 2023-02-17 22:47:44 +01:00
flash d268a419dc Cleaned up channel/user association logic. 2023-02-17 20:02:35 +01:00
flash 1466562c54 Use virtual channel name for DMs. 2023-02-16 23:56:50 +01:00
flash a5089f14b8 Undid the IPacketTarget system. 2023-02-16 23:47:30 +01:00
flash 13ae843c8d No longer keep track of connections within the ChatUser class. 2023-02-16 23:33:48 +01:00
flash 06af94e94f Renamed 'Sessions' to 'Connections' 2023-02-16 22:25:41 +01:00
flash c8a589c1c1 Un-switch packet handlers. 2023-02-16 22:16:06 +01:00
flash ea56af0210 Turned commands into classes instead of a shitty switch. 2023-02-16 21:34:59 +01:00
flash d1f78a7e8b Actually send the message deletion packet. 2023-02-16 17:10:30 +01:00
flash dbdaaeec9e Better session ID generation code. 2023-02-10 08:06:07 +01:00
flash 8050a295c1 Updated protocol documentation to indicate that IDs should not be treated as numbers. 2023-02-10 07:28:36 +01:00
flash c291ef178d Fixed the backlog being sent in reverse order. 2023-02-10 07:18:38 +01:00
flash c21605cf3b Marginal improvements to cross thread access. 2023-02-10 07:07:59 +01:00
flash e1e3def62c Ported the config system from old master. 2023-02-09 00:53:42 +01:00
flash 56a818254e Backported SharpId class 2023-02-08 04:32:12 +01:00
flash 40c7ba4ded Removed a bunch of @ prefixes that aren't needed. 2023-02-08 04:17:07 +01:00
flash 27c28aafcd Don't use a stinky Timer for user bumps and exclude AFK. 2023-02-08 01:01:55 +01:00
flash 36f3ff6385 Removed internal ban handling and integrate with Misuzu. 2023-02-07 23:28:06 +01:00
flash 5e3eecda8c Convert Colour class to a struct. 2023-02-07 16:13:38 +01:00
flash 4104e40843 Code style updates. 2023-02-07 16:01:56 +01:00
flash c9cc5ff23a Removed protocol enums. 2023-02-07 15:34:31 +01:00
flash 513539319f Better HttpClient handling. 2023-02-06 21:14:50 +01:00
flash d2fef02e08 Added Protocol doc to new master branch. 2023-02-06 19:38:16 +00:00
flash 1051a26494 Fixed dumb shit in SharpChat. 2022-11-04 20:02:16 +00:00
flash 6f50ec66a9 Added /shutdown and /restart commands for server maintenance. 2022-08-30 18:29:11 +00:00
flash e6dffe06e6 Removed documentation files from the stable branch. 2022-08-30 16:05:07 +00:00
flash 9790f77a16 Removed Sock Chat v2 crust. 2022-08-30 16:04:38 +00:00
flash 08f9a2c5a1 Fixed /who <channel> result being incorrectly formatted. 2022-08-30 15:52:03 +00:00
flash ea83c8cca0 Don't print auth url to console... 2022-08-30 15:51:10 +00:00
flash bfd1819798 OOPS!!!!!!!!!!!!!!!!!!!!!!!!! 2022-08-30 17:44:33 +02:00
flash 2de19035ff Allow changing the Misuzu base URL. 2022-08-30 17:42:03 +02:00
flash 3f8c2781ee Cleaned things up again. 2022-08-30 17:28:46 +02:00
flash 23f0bd478f Apparently this is what's actually running on the server. 2022-08-30 15:21:00 +00:00
185 changed files with 6017 additions and 7787 deletions

4
.editorconfig Normal file
View file

@ -0,0 +1,4 @@
[*.{cs,vb}]
# IDE0046: Convert to conditional expression
dotnet_style_prefer_conditional_expression_over_return = false

9
.gitignore vendored
View file

@ -1,6 +1,15 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
welcome.txt
mariadb.txt
login_key.txt
http-motd.txt
_webdb.txt
msz_url.txt
sharpchat.cfg
SharpChat/version.txt
# User-specific files
*.suo
*.user

View file

@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -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<HttpEncoding> encodings) {
Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray();
}
}
}

View file

@ -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));
}
}
}

View file

@ -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));
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -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),
};
}
}
}

View file

@ -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;
}
}
}

View file

@ -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<string> 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<string> 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;
}
}
}
}

View file

@ -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));
}
}
}

View file

@ -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<HttpEncoding> encodings) {
Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray();
}
}
}

View file

@ -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));
}
}
}

View file

@ -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);
}
}
}

View file

@ -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<HttpEncoding> AcceptedEncodings { get; set; } = new[] { HttpEncoding.GZip, HttpEncoding.Deflate, HttpEncoding.Brotli };
public HttpClient() {
Connections = new HttpConnectionManager();
Tasks = new HttpTaskManager();
}
public HttpTask CreateTask(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> 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<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> 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<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> 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();
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<HttpConnection> 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<HttpConnection> 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();
}
}
}

View file

@ -1,69 +0,0 @@
using System;
using System.Globalization;
using System.Text;
namespace Hamakaze {
public readonly struct HttpEncoding : IComparable<HttpEncoding?>, IEquatable<HttpEncoding?> {
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);
}
}
}

View file

@ -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.") { }
}
}

View file

@ -1,159 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Hamakaze {
public readonly struct HttpMediaType : IComparable<HttpMediaType?>, IEquatable<HttpMediaType?> {
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<Param> Params { get; }
public HttpMediaType(string type, string subtype, string suffix = null, IEnumerable<Param> args = null) {
Type = type ?? throw new ArgumentNullException(nameof(type));
Subtype = subtype ?? throw new ArgumentNullException(nameof(subtype));
Suffix = suffix ?? string.Empty;
Params = args ?? Enumerable.Empty<Param>();
}
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<Param> 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<Param?>, IEquatable<Param?> {
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);
}
}
}
}

View file

@ -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<HttpHeader> Headers { get; }
public abstract Stream Body { get; }
public virtual bool HasBody => Body != null;
protected bool OwnsBodyStream { get; set; }
public virtual IEnumerable<HttpHeader> 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();
}
}
}

View file

@ -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<HttpHeader> Headers => HeaderList;
private List<HttpHeader> 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<HttpEncoding> AcceptedEncodings {
get => HeaderList.Where(x => x.Name == HttpAcceptEncodingHeader.NAME).Cast<HttpAcceptEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<HttpEncoding>();
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<HttpContentTypeHeader>().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<long, long> 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);
}
}
}
}
}

View file

@ -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<HttpHeader> 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<HttpDateHeader>().FirstOrDefault()?.DateTime ?? DateTimeOffset.MinValue;
public HttpMediaType ContentType
=> Headers.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast<HttpContentTypeHeader>().FirstOrDefault()?.MediaType
?? HttpMediaType.OctetStream;
public Encoding ResponseEncoding
=> Encoding.GetEncoding(ContentType.GetParamValue(@"charset") ?? @"iso8859-1");
public IEnumerable<string> ContentEncodings
=> Headers.Where(x => x.Name == HttpContentEncodingHeader.NAME).Cast<HttpContentEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<string>();
public IEnumerable<string> TransferEncodings
=> Headers.Where(x => x.Name == HttpTransferEncodingHeader.NAME).Cast<HttpTransferEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<string>();
public HttpResponseMessage(
int statusCode, string statusMessage, string protocolVersion,
IEnumerable<HttpHeader> 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<string> 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<long, long> 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<string> transferEncodings = null;
Stack<string> 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<HttpHeader> 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<string>(hteh.Encodings);
else if(header is HttpContentEncodingHeader hceh)
contentEncodings = new Stack<string>(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);
}
}
}

View file

@ -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<IPAddress> Addresses { get; set; }
private HttpConnection Connection { get; set; }
public bool DisposeRequest { get; set; }
public bool DisposeResponse { get; set; }
public event Action<HttpTask, HttpResponseMessage> OnComplete;
public event Action<HttpTask, Exception> OnError;
public event Action<HttpTask> OnCancel;
public event Action<HttpTask, long, long> OnUploadProgress;
public event Action<HttpTask, long, long> OnDownloadProgress;
public event Action<HttpTask, TaskState> 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<HttpKeepAliveHeader>().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,
}
}
}

View file

@ -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;
}
}
}

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019-2022 flashwave
Copyright (c) 2019-2024 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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,20 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29025.244
# Visual Studio Version 17
VisualStudioVersion = 17.2.32630.192
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("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DF7A7073-A67A-4D93-92C6-F9D0F95E2359}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitattributes = .gitattributes
.gitignore = .gitignore
LICENSE = LICENSE
Protocol.md = Protocol.md
README.md = README.md
start.sh = start.sh
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -17,10 +26,6 @@ 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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -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<IBan> BanList = new List<IBan>();
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<BannedUser>().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<BannedIPAddress>().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<BannedUser>().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<BannedIPAddress>().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<BannedUser>().FirstOrDefault(x => x.Username.ToLowerInvariant() == username.ToLowerInvariant() || (userId > 0 && x.UserId == userId));
}
public BannedIPAddress GetIPAddress(IPAddress addr) {
lock (BanList)
return BanList.OfType<BannedIPAddress>().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<BannedUser>().Any(x => x.UserId == fb.UserId))
Add(new BannedUser(fb));
if(!BanList.OfType<BannedIPAddress>().Any(x => x.Address.ToString() == fb.UserIP))
Add(new BannedIPAddress(fb));
}
}
}, ex => Logger.Write($@"Ban Refresh: {ex}"));
}
public IEnumerable<IBan> 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);
}
}
}

35
SharpChat/BuildInfo.cs Normal file
View file

@ -0,0 +1,35 @@
using System.IO;
using System.Reflection;
namespace SharpChat {
public static class BuildInfo {
private const string NAME = @"SharpChat";
private const string UNKNOWN = @"XXXXXXX";
public static string VersionString { get; }
public static string VersionStringShort { get; }
public static bool IsDebugBuild { get; }
public static string ProgramName { get; }
static BuildInfo() {
#if DEBUG
IsDebugBuild = true;
#endif
try {
using Stream? s = Assembly.GetExecutingAssembly().GetManifestResourceStream(@"SharpChat.version.txt");
if(s != null) {
using StreamReader sr = new(s);
VersionString = sr.ReadLine()?.Trim() ?? string.Empty;
} else
VersionString = string.Empty;
VersionStringShort = VersionString.Length > 10 ? VersionString[..10] : VersionString;
} catch {
VersionStringShort = VersionString = UNKNOWN;
}
ProgramName = string.Format(@"{0}/{1}", NAME, VersionStringShort);
}
}
}

35
SharpChat/ChannelInfo.cs Normal file
View file

@ -0,0 +1,35 @@
namespace SharpChat {
public class ChannelInfo {
public string Name { get; }
public string Password { get; set; }
public bool IsTemporary { get; set; }
public int Rank { get; set; }
public long OwnerId { get; set; }
public bool HasPassword
=> !string.IsNullOrWhiteSpace(Password);
public bool IsPublic
=> !IsTemporary && Rank < 1 && !HasPassword;
public ChannelInfo(
string name,
string? password = null,
bool isTemporary = false,
int rank = 0,
long ownerId = 0
) {
Name = name;
Password = password ?? string.Empty;
IsTemporary = isTemporary;
Rank = rank;
OwnerId = ownerId;
}
public bool IsOwner(UserInfo user) {
return OwnerId > 0
&& user != null
&& OwnerId == user.UserId;
}
}
}

View file

@ -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<ChatChannel> Channels = new List<ChatChannel>();
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<ChatChannel> GetUser(ChatUser user) {
if (user == null)
return null;
return Channels.Where(x => x.HasUser(user));
}
public IEnumerable<ChatChannel> 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);
}
}
}

View file

@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class ChannelsContext {
private readonly List<ChannelInfo> Channels = new();
public ChannelInfo? MainChannel { get; private set; }
public int TotalCount { get; private set; }
public int PublicCount { get; private set; }
public ChannelInfo[] All => Channels.ToArray();
public ChannelInfo? Get(
string? name,
Func<string, string>? sanitise = null
) {
if(string.IsNullOrWhiteSpace(name))
return null;
foreach(ChannelInfo info in Channels) {
string chanName = info.Name;
if(sanitise != null)
chanName = sanitise(chanName);
if(!chanName.Equals(name, StringComparison.InvariantCultureIgnoreCase))
continue;
return info;
}
return null;
}
public ChannelInfo[] GetMany(
string[]? names = null,
Func<string, string>? sanitiseName = null,
int minRank = 0,
bool? isPublic = null
) {
List<ChannelInfo> chans = new();
names ??= Array.Empty<string>();
for(int i = 0; i < names.Length; ++i)
names[i] = names[i].ToLowerInvariant();
foreach(ChannelInfo info in Channels) {
if(info.Rank > minRank)
continue;
if(isPublic != null && info.IsPublic != isPublic)
continue;
if(names?.Length > 0) {
string chanName = info.Name;
if(sanitiseName != null)
chanName = sanitiseName(chanName);
bool match = false;
foreach(string name in names)
if(match = chanName.Equals(name, StringComparison.InvariantCultureIgnoreCase))
break;
if(!match)
continue;
}
chans.Add(info);
}
return chans.ToArray();
}
public void Add(
ChannelInfo info,
bool forceMain = false,
Func<string, string>? sanitiseName = null
) {
if(Get(info.Name, sanitiseName) != null)
throw new ArgumentException("A channel with this name has already been registered.", nameof(info));
if(string.IsNullOrWhiteSpace(info.Name))
throw new ArgumentException("Channel names may not be blank.", nameof(info));
// todo: there should be more restrictions on channel names
Channels.Add(info);
++TotalCount;
if(info.IsPublic)
++PublicCount;
if(forceMain || MainChannel == null)
MainChannel = info;
}
public void Remove(
ChannelInfo info,
Func<string, string>? sanitiseName = null
) {
Remove(info.Name, sanitiseName);
}
public void Remove(
string? name,
Func<string, string>? sanitise = null
) {
if(string.IsNullOrWhiteSpace(name))
return;
ChannelInfo? info = Get(name, sanitise);
if(info == null)
return;
Channels.Remove(info);
--TotalCount;
if(info.IsPublic)
--PublicCount;
if(MainChannel == info)
MainChannel = Channels.FirstOrDefault(c => !c.IsPublic);
}
}
}

View file

@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class ChannelsUsersContext {
private readonly Dictionary<string, HashSet<long>> ChannelUsers = new();
private readonly Dictionary<long, HashSet<string>> UserChannels = new();
private readonly Dictionary<long, string> UserLastChannel = new();
public string GetUserLastChannel(long userId) {
return UserLastChannel.ContainsKey(userId)
? UserLastChannel[userId]
: string.Empty;
}
public string GetUserLastChannel(UserInfo userInfo) {
return GetUserLastChannel(userInfo.UserId);
}
public void SetUserLastChannel(long userId, string channelName) {
channelName = channelName.ToLowerInvariant();
if(UserLastChannel.ContainsKey(userId))
UserLastChannel[userId] = channelName;
else
UserLastChannel.Add(userId, channelName);
}
public void SetUserLastChannel(UserInfo userInfo, ChannelInfo channelInfo) {
SetUserLastChannel(userInfo.UserId, channelInfo.Name);
}
public void DeleteUserLastChannel(long userId) {
if(UserLastChannel.ContainsKey(userId))
UserLastChannel.Remove(userId);
}
public void DeleteUserLastChannel(UserInfo userInfo) {
DeleteUserLastChannel(userInfo.UserId);
}
public bool IsUserLastChannel(long userId, string channelName) {
return !string.IsNullOrWhiteSpace(channelName)
&& GetUserLastChannel(userId).Equals(channelName, StringComparison.InvariantCultureIgnoreCase);
}
public bool IsUserLastChannel(UserInfo userInfo, ChannelInfo channelInfo) {
return IsUserLastChannel(userInfo.UserId, channelInfo.Name);
}
public string[] GetUserChannelNames(long userId) {
if(!UserChannels.ContainsKey(userId))
return Array.Empty<string>();
return UserChannels[userId].ToArray();
}
public string[] GetUserChannelNames(UserInfo userInfo) {
return GetUserChannelNames(userInfo.UserId);
}
public long[] GetChannelUserIds(string channelName) {
channelName = channelName.ToLowerInvariant();
if(!ChannelUsers.ContainsKey(channelName))
return Array.Empty<long>();
return ChannelUsers[channelName].ToArray();
}
public long[] GetChannelUserIds(string channelName, Func<string, string> sanitise) {
foreach(KeyValuePair<string, HashSet<long>> kvp in ChannelUsers)
if(sanitise(kvp.Key).Equals(channelName, StringComparison.InvariantCultureIgnoreCase))
return kvp.Value.ToArray();
return Array.Empty<long>();
}
public long[] GetChannelUserIds(ChannelInfo channelInfo) {
return GetChannelUserIds(channelInfo.Name);
}
public void Join(string channelName, long userId) {
channelName = channelName.ToLowerInvariant();
if(ChannelUsers.ContainsKey(channelName))
ChannelUsers[channelName].Add(userId);
else
ChannelUsers.Add(channelName, new HashSet<long> { userId });
if(UserChannels.ContainsKey(userId))
UserChannels[userId].Add(channelName);
else
UserChannels.Add(userId, new HashSet<string> { channelName });
SetUserLastChannel(userId, channelName);
}
public void Join(ChannelInfo channelInfo, UserInfo userInfo) {
Join(channelInfo.Name, userInfo.UserId);
}
public void Leave(string channelName, long userId) {
channelName = channelName.ToLowerInvariant();
if(ChannelUsers.ContainsKey(channelName)) {
if(ChannelUsers[channelName].Count < 2)
ChannelUsers.Remove(channelName);
else
ChannelUsers[channelName].Remove(userId);
}
if(UserChannels.ContainsKey(userId)) {
if(UserChannels[userId].Count < 2)
UserChannels.Remove(userId);
else
UserChannels[userId].Remove(channelName);
}
if(IsUserLastChannel(userId, channelName))
DeleteUserLastChannel(userId);
}
public void Leave(ChannelInfo channelInfo, UserInfo userInfo) {
Leave(channelInfo.Name, userInfo.UserId);
}
public bool Has(string channelName, long userId) {
channelName = channelName.ToLowerInvariant();
return ChannelUsers.ContainsKey(channelName)
&& ChannelUsers[channelName].Contains(userId);
}
public bool Has(ChannelInfo channelInfo, UserInfo userInfo) {
return Has(channelInfo.Name, userInfo.UserId);
}
public long[] FilterUsers(string channelName, long[] userIds) {
if(userIds.Length < 1)
return userIds;
channelName = channelName.ToLowerInvariant();
if(!ChannelUsers.ContainsKey(channelName))
return Array.Empty<long>();
List<long> filtered = new();
HashSet<long> channelUserIds = ChannelUsers[channelName];
foreach(long userId in userIds)
if(channelUserIds.Contains(userId))
filtered.Add(userId);
return filtered.ToArray();
}
public UserInfo[] FilterUsers(ChannelInfo channelInfo, UserInfo[] userInfos) {
if(userInfos.Length < 1)
return userInfos;
long[] filteredIds = FilterUsers(channelInfo.Name, userInfos.Select(u => u.UserId).ToArray());
if(filteredIds.Length < 1)
return Array.Empty<UserInfo>();
return userInfos.Where(u => filteredIds.Contains(u.UserId)).ToArray();
}
public bool HasUsers(string channelName, long[] userIds) {
return FilterUsers(channelName, userIds).SequenceEqual(userIds);
}
public bool HasUsers(ChannelInfo channelInfo, UserInfo[] userInfos) {
return HasUsers(channelInfo.Name, userInfos.Select(u => u.UserId).ToArray());
}
public string[] FilterChannels(long userId, string[] channelNames) {
if(channelNames.Length < 1)
return channelNames;
if(!UserChannels.ContainsKey(userId))
return Array.Empty<string>();
List<string> filtered = new();
HashSet<string> userChannelNames = UserChannels[userId];
foreach(string channelName in userChannelNames)
if(userChannelNames.Contains(channelName))
filtered.Add(channelName);
return filtered.ToArray();
}
public ChannelInfo[] FilterChannels(UserInfo userInfo, ChannelInfo[] channelInfos) {
if(channelInfos.Length < 1)
return channelInfos;
string[] filteredNames = FilterChannels(userInfo.UserId, channelInfos.Select(c => c.Name).ToArray());
if(filteredNames.Length < 1)
return Array.Empty<ChannelInfo>();
return channelInfos.Where(c => filteredNames.Contains(c.Name.ToLowerInvariant())).ToArray();
}
public bool HasChannels(long userId, string[] channelNames) {
if(!UserChannels.ContainsKey(userId))
return false;
HashSet<string> userChannelNames = UserChannels[userId];
foreach(string channelName in channelNames)
if(!userChannelNames.Contains(channelName.ToLowerInvariant()))
return false;
return true;
}
public bool HasChannels(UserInfo userInfo, ChannelInfo[] channelInfos) {
return HasChannels(userInfo.UserId, channelInfos.Select(c => c.Name).ToArray());
}
public void DeleteUser(long userId) {
if(!UserChannels.ContainsKey(userId))
return;
HashSet<string> channelNames = UserChannels[userId];
UserChannels.Remove(userId);
DeleteUserLastChannel(userId);
foreach(string channelName in channelNames) {
if(!ChannelUsers.ContainsKey(channelName))
continue;
ChannelUsers[channelName].Remove(userId);
}
}
public void DeleteUser(UserInfo userInfo) {
DeleteUser(userInfo.UserId);
}
public void DeleteChannel(string channelName) {
channelName = channelName.ToLowerInvariant();
if(!ChannelUsers.ContainsKey(channelName))
return;
HashSet<long> userIds = ChannelUsers[channelName];
ChannelUsers.Remove(channelName);
foreach(long userId in userIds) {
if(!UserChannels.ContainsKey(userId))
continue;
UserChannels[userId].Remove(channelName);
if(IsUserLastChannel(userId, channelName))
DeleteUserLastChannel(userId);
}
}
public void DeleteChannel(ChannelInfo channelInfo) {
DeleteChannel(channelInfo.Name);
}
}
}

View file

@ -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<ChatUser> Users { get; } = new List<ChatUser>();
private List<ChatChannelTyping> Typing { get; } = new List<ChatChannelTyping>();
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<ChatUser> GetUsers(IEnumerable<ChatUser> exclude = null) {
lock (Users) {
IEnumerable<ChatUser> 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();
}
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -1,116 +1,327 @@
using SharpChat.Events;
using SharpChat.Flashii;
using SharpChat.EventStorage;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
namespace SharpChat {
public class ChatContext : IDisposable, IPacketTarget {
public bool IsDisposed { get; private set; }
public class ChatContext {
public readonly SemaphoreSlim ContextAccess = new(1, 1);
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 ChannelsContext Channels { get; } = new();
public List<ConnectionInfo> Connections { get; } = new();
public UsersContext Users { get; } = new();
public IEventStorage Events { get; }
public ChannelsUsersContext ChannelsUsers { get; } = new();
public Dictionary<long, RateLimiter> UserRateLimiters { get; } = new();
public string TargetName => @"@broadcast";
public ChatContext(IEventStorage evtStore) {
Events = evtStore;
}
public ChatContext(SockChatServer server) {
Server = server;
Bans = new BanManager(this);
Users = new UserManager(this);
Channels = new ChannelManager(this);
Events = new ChatEventManager(this);
public void DispatchEvent(IChatEvent eventInfo) {
if(eventInfo is MessageCreateEvent mce) {
if(mce.IsBroadcast) {
Send(new MessageBroadcastPacket(mce.MessageText));
} else if(mce.IsPrivate) {
// The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed
// e.g. nook sees @Arysil and Arysil sees @nook
BumpTimer = new Timer(e => FlashiiBump.Submit(Users.WithActiveConnections()), null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
// this entire routine is garbage, channels should probably in the db
if(mce.ChannelName?.StartsWith("@") != true)
return;
long[] targetIds = mce.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1).ToArray();
if(targetIds.Length != 2)
return;
UserInfo[] users = Users.GetMany(targetIds);
UserInfo? target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
if(target == null)
return;
foreach(UserInfo user in users)
SendTo(user, new MessageAddPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.SenderId == user.UserId ? $"{SockChatUtility.GetUserName(target)} {mce.MessageText}" : mce.MessageText,
mce.IsAction,
true
));
} else {
ChannelInfo? channel = Channels.Get(mce.ChannelName, SockChatUtility.SanitiseChannelName);
if(channel != null)
SendTo(channel, new MessageAddPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.MessageText,
mce.IsAction,
false
));
}
Events.AddEvent(
mce.MessageId, "msg:add",
mce.ChannelName,
mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms,
new { text = mce.MessageText },
(mce.IsBroadcast ? StoredEventFlags.Broadcast : 0)
| (mce.IsAction ? StoredEventFlags.Action : 0)
| (mce.IsPrivate ? StoredEventFlags.Private : 0)
);
return;
}
}
public void Update() {
Bans.RemoveExpired();
CheckPings();
foreach(ConnectionInfo conn in Connections)
if(!conn.IsDisposed && conn.HasTimedOut) {
conn.Dispose();
Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}.");
}
public void BanUser(ChatUser user, DateTimeOffset? until = null, bool banIPs = false, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
if (until.HasValue && until.Value <= DateTimeOffset.UtcNow)
until = null;
int removed = Connections.RemoveAll(conn => conn.IsDisposed);
if(removed > 0)
Logger.Write($"Removed {removed} nuked connections from the list.");
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);
foreach(UserInfo user in Users.All)
if(!Connections.Any(conn => conn.User == user)) {
HandleDisconnect(user, UserDisconnectReason.TimeOut);
Logger.Write($"Timed out {user} (no more connections).");
}
}
public void SafeUpdate() {
ContextAccess.Wait();
try {
Update();
} finally {
ContextAccess.Release();
}
}
public ChannelInfo[] GetUserChannels(UserInfo user) {
return Channels.GetMany(ChannelsUsers.GetUserChannelNames(user));
}
public UserInfo[] GetChannelUsers(ChannelInfo channel) {
return Users.GetMany(ChannelsUsers.GetChannelUserIds(channel));
}
public void UpdateUser(
UserInfo user,
string? userName = null,
string? nickName = null,
Colour? colour = null,
UserStatus? status = null,
string? statusText = null,
int? rank = null,
UserPermissions? perms = null,
bool? isSuper = null,
bool silent = false
) {
bool hasChanged = false;
string? previousName = null;
if(userName != null && !user.UserName.Equals(userName)) {
user.UserName = userName;
hasChanged = true;
}
if(nickName != null && !user.NickName.Equals(nickName)) {
if(!silent)
previousName = string.IsNullOrWhiteSpace(user.NickName) ? user.UserName : user.NickName;
user.NickName = nickName;
hasChanged = true;
}
if(colour.HasValue && user.Colour.Equals(colour.Value)) {
user.Colour = colour.Value;
hasChanged = true;
}
if(status.HasValue && user.Status != status.Value) {
user.Status = status.Value;
hasChanged = true;
}
if(statusText != null && !user.StatusText.Equals(statusText)) {
user.StatusText = statusText;
hasChanged = true;
}
if(rank != null && user.Rank != rank) {
user.Rank = (int)rank;
hasChanged = true;
}
if(perms.HasValue && user.Permissions != perms) {
user.Permissions = perms.Value;
hasChanged = true;
}
if(isSuper.HasValue) {
user.IsSuper = isSuper.Value;
hasChanged = true;
}
if(hasChanged) {
if(previousName != null)
SendToUserChannels(user, new UserUpdateNotificationPacket(previousName, SockChatUtility.GetUserNameWithStatus(user)));
SendToUserChannels(user, new UserUpdatePacket(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
user.Colour,
user.Rank,
user.Permissions
));
}
}
public void BanUser(UserInfo user, TimeSpan duration, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
if(duration > TimeSpan.Zero) {
DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration;
SendTo(user, new ForceDisconnectPacket(expires));
} else
user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
SendTo(user, new ForceDisconnectPacket());
user.Close();
UserLeave(user.Channel, user, reason);
foreach(ConnectionInfo conn in Connections)
if(conn.User == user)
conn.Dispose();
Connections.RemoveAll(conn => conn.IsDisposed);
HandleDisconnect(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));
public void HandleChannelEventLog(string channelName, Action<SockChatS2CPacket> handler) {
foreach(StoredEventInfo msg in Events.GetChannelEventLog(channelName))
handler(msg.Type switch {
"msg:add" => new MessageAddLogPacket(
msg.Id,
msg.Created,
msg.Sender?.UserId ?? -1,
msg.Sender == null ? "ChatBot" : SockChatUtility.GetUserName(msg.Sender),
msg.Sender?.Colour ?? Colour.None,
msg.Sender?.Rank ?? 0,
msg.Sender?.Permissions ?? 0,
msg.Data.RootElement.GetProperty("text").GetString() ?? string.Empty,
msg.Flags.HasFlag(StoredEventFlags.Action),
msg.Flags.HasFlag(StoredEventFlags.Private),
msg.Flags.HasFlag(StoredEventFlags.Broadcast),
false
),
"user:connect" => new UserConnectLogPacket(
msg.Id,
msg.Created,
msg.Sender == null ? string.Empty : SockChatUtility.GetUserName(msg.Sender)
),
"user:disconnect" => new UserDisconnectLogPacket(
msg.Id,
msg.Created,
msg.Sender == null ? string.Empty : SockChatUtility.GetUserNameWithStatus(msg.Sender),
(UserDisconnectReason)msg.Data.RootElement.GetProperty("reason").GetByte()
),
"chan:join" => new UserChannelJoinLogPacket(
msg.Id,
msg.Created,
msg.Sender == null ? string.Empty : SockChatUtility.GetUserName(msg.Sender)
),
"chan:leave" => new UserChannelLeaveLogPacket(
msg.Id,
msg.Created,
msg.Sender == null ? string.Empty : SockChatUtility.GetUserName(msg.Sender)
),
_ => throw new Exception($"Unsupported backlog type: {msg.Type}"),
});
}
sess.Send(new AuthSuccessPacket(user, chan, sess));
sess.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
public void HandleJoin(UserInfo user, ChannelInfo chan, ConnectionInfo conn, int maxMsgLength) {
if(!ChannelsUsers.Has(chan, user)) {
SendTo(chan, new UserConnectPacket(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
user.Colour,
user.Rank,
user.Permissions
));
Events.AddEvent("user:connect", user, chan, flags: StoredEventFlags.Log);
}
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);
conn.Send(new AuthSuccessPacket(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
user.Colour,
user.Rank,
user.Permissions,
chan.Name,
maxMsgLength
));
conn.Send(new UsersPopulatePacket(GetChannelUsers(chan).Except(new[] { user }).Select(
user => new UsersPopulatePacket.ListEntry(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
user.Colour,
user.Rank,
user.Permissions,
true
)
).OrderByDescending(user => user.Rank).ToArray()));
foreach(IChatEvent msg in msgs)
sess.Send(new ContextMessagePacket(msg));
HandleChannelEventLog(chan.Name, p => conn.Send(p));
sess.Send(new ContextChannelsPacket(Channels.OfHierarchy(user.Rank)));
conn.Send(new ChannelsPopulatePacket(Channels.GetMany(isPublic: true, minRank: user.Rank).Select(
channel => new ChannelsPopulatePacket.ListEntry(channel.Name, channel.HasPassword, channel.IsTemporary)
).ToArray()));
if (!chan.HasUser(user))
chan.UserJoin(user);
if (!Users.Contains(user))
if(Users.Get(userId: user.UserId) == null)
Users.Add(user);
ChannelsUsers.Join(chan.Name, user.UserId);
}
public void UserLeave(ChatChannel chan, ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
user.Status = ChatUserStatus.Offline;
public void HandleDisconnect(UserInfo user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
UpdateUser(user, status: UserStatus.Offline);
Users.Remove(user.UserId);
if (chan == null) {
foreach(ChatChannel channel in user.GetChannels()) {
UserLeave(channel, user, reason);
ChannelInfo[] channels = GetUserChannels(user);
ChannelsUsers.DeleteUser(user);
foreach(ChannelInfo chan in channels) {
SendTo(chan, new UserDisconnectPacket(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
reason
));
Events.AddEvent("user:disconnect", user, chan, new { reason = (int)reason }, StoredEventFlags.Log);
if(chan.IsTemporary && chan.IsOwner(user))
RemoveChannel(chan);
}
}
public void SwitchChannel(UserInfo user, ChannelInfo chan, string password) {
if(ChannelsUsers.IsUserLastChannel(user, chan)) {
ForceChannel(user);
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(!user.Permissions.HasFlag(UserPermissions.JoinAnyChannel) && chan.IsOwner(user)) {
if(chan.Rank > user.Rank) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
user.ForceChannel();
SendTo(user, new ChannelRankTooLowErrorPacket(chan.Name));
ForceChannel(user);
return;
}
if (chan.Password != password) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
user.ForceChannel();
if(!string.IsNullOrEmpty(chan.Password) && chan.Password.Equals(password)) {
SendTo(user, new ChannelPasswordWrongErrorPacket(chan.Name));
ForceChannel(user);
return;
}
}
@ -118,73 +329,125 @@ namespace SharpChat {
ForceChannelSwitch(user, chan);
}
public void ForceChannelSwitch(ChatUser user, ChatChannel chan) {
if (!Channels.Contains(chan))
public void ForceChannelSwitch(UserInfo user, ChannelInfo chan) {
ChannelInfo? oldChan = Channels.Get(ChannelsUsers.GetUserLastChannel(user));
if(oldChan != null) {
SendTo(oldChan, new UserChannelLeavePacket(user.UserId));
Events.AddEvent("chan:leave", user, oldChan, flags: StoredEventFlags.Log);
}
SendTo(chan, new UserChannelJoinPacket(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
user.Colour,
user.Rank,
user.Permissions
));
if(oldChan != null)
Events.AddEvent("chan:join", user, oldChan, flags: StoredEventFlags.Log);
SendTo(user, new ContextClearPacket(ContextClearPacket.ClearMode.MessagesUsers));
SendTo(user, new UsersPopulatePacket(GetChannelUsers(chan).Except(new[] { user }).Select(
user => new UsersPopulatePacket.ListEntry(
user.UserId,
SockChatUtility.GetUserNameWithStatus(user),
user.Colour,
user.Rank,
user.Permissions,
true
)
).OrderByDescending(u => u.Rank).ToArray()));
HandleChannelEventLog(chan.Name, p => SendTo(user, p));
ForceChannel(user, chan);
if(oldChan != null)
ChannelsUsers.Leave(oldChan, user);
ChannelsUsers.Join(chan, user);
if(oldChan != null && oldChan.IsTemporary && oldChan.IsOwner(user))
RemoveChannel(oldChan);
}
public void Send(SockChatS2CPacket packet) {
foreach(ConnectionInfo conn in Connections)
if(conn.IsAuthed)
conn.Send(packet);
}
public void SendTo(UserInfo user, SockChatS2CPacket packet) {
foreach(ConnectionInfo conn in Connections)
if(conn.IsAuthed && conn.User!.UserId == user.UserId)
conn.Send(packet);
}
public void SendTo(ChannelInfo channel, SockChatS2CPacket packet) {
long[] userIds = ChannelsUsers.GetChannelUserIds(channel);
foreach(ConnectionInfo conn in Connections)
if(conn.IsAuthed && userIds.Contains(conn.User!.UserId))
conn.Send(packet);
}
public void SendToUserChannels(UserInfo user, SockChatS2CPacket packet) {
ChannelInfo[] chans = GetUserChannels(user);
foreach(ChannelInfo chan in chans)
SendTo(chan, packet);
}
public IPAddress[] GetRemoteAddresses(UserInfo user) {
return Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct().ToArray();
}
public void ForceChannel(UserInfo user, ChannelInfo? chan = null) {
chan ??= Channels.Get(ChannelsUsers.GetUserLastChannel(user));
if(chan != null)
SendTo(user, new UserChannelForceJoinPacket(chan.Name));
}
public void UpdateChannel(
ChannelInfo channel,
bool? temporary = null,
int? minRank = null,
string? password = null
) {
string prevName = channel.Name;
if(temporary.HasValue)
channel.IsTemporary = temporary.Value;
if(minRank.HasValue)
channel.Rank = minRank.Value;
if(password != null)
channel.Password = password;
// TODO: Users that no longer have access to the channel/gained access to the channel by the rank change should receive delete and create packets respectively
// the server currently doesn't keep track of what channels a user is already aware of so can't really simulate this yet.
foreach(UserInfo user in Users.GetMany(minRank: channel.Rank))
SendTo(user, new ChannelUpdatePacket(prevName, channel.Name, channel.HasPassword, channel.IsTemporary));
}
public void RemoveChannel(ChannelInfo channel) {
if(channel == null || Channels.PublicCount > 1)
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<IChatEvent> 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<ChatUserSession> 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)
ChannelInfo? defaultChannel = Channels.MainChannel;
if(defaultChannel == null)
return;
IsDisposed = true;
BumpTimer?.Dispose();
Events?.Dispose();
Channels?.Dispose();
Users?.Dispose();
Bans?.Dispose();
// Remove channel from the listing
Channels.Remove(channel);
if (disposing)
GC.SuppressFinalize(this);
// 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(UserInfo user in GetChannelUsers(channel))
SwitchChannel(user, defaultChannel, string.Empty);
// Broadcast deletion of channel
foreach(UserInfo user in Users.GetMany(minRank: channel.Rank))
SendTo(user, new ChannelDeletePacket(channel.Name));
}
}
}

View file

@ -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<IChatEvent> Events = null;
public readonly ChatContext Context;
public bool IsDisposed { get; private set; }
public ChatEventManager(ChatContext context) {
Context = context;
if (!Database.HasDatabase)
Events = new List<IChatEvent>();
}
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<IChatEvent> 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<IChatEvent> 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<IChatEvent>();
}
~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);
}
}
}

View file

@ -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<DateTimeOffset> TimePoints = new Queue<DateTimeOffset>();
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);
}
}
}
}

View file

@ -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<BasicUser> {
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(@"&lt;{0}&gt;_", 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<ChatUserSession> Sessions = new List<ChatUserSession>();
private readonly List<ChatChannel> Channels = new List<ChatChannel>();
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<IPAddress> 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<ChatChannel> 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<ChatUserSession> GetDeadSessions() {
lock (Sessions)
return Sessions.Where(x => x.HasTimedOut || x.IsDisposed).ToList();
}
}
}

View file

@ -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<string> 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);
}
}
}

61
SharpChat/Colour.cs Normal file
View file

@ -0,0 +1,61 @@
namespace SharpChat {
public readonly struct Colour {
public readonly byte Red;
public readonly byte Green;
public readonly byte Blue;
public readonly bool Inherits;
public static Colour None { get; } = new();
public Colour() {
Red = 0;
Green = 0;
Blue = 0;
Inherits = true;
}
public Colour(byte red, byte green, byte blue) {
Red = red;
Green = green;
Blue = blue;
Inherits = false;
}
public bool Equals(Colour other) {
return Inherits == other.Inherits
&& Red == other.Red
&& Green == other.Green
&& Blue == other.Blue;
}
public override string ToString() {
return Inherits
? "inherit"
: string.Format("#{0:x2}{1:x2}{2:x2}", Red, Green, Blue);
}
public int ToRawRGB() {
return (Red << 16) | (Green << 8) | Blue;
}
public static Colour FromRawRGB(int rgb) {
return new(
(byte)((rgb >> 16) & 0xFF),
(byte)((rgb >> 8) & 0xFF),
(byte)(rgb & 0xFF)
);
}
private const int MSZ_INHERIT = 0x40000000;
public int ToMisuzu() {
return Inherits ? MSZ_INHERIT : ToRawRGB();
}
public static Colour FromMisuzu(int raw) {
return (raw & MSZ_INHERIT) > 0
? None
: FromRawRGB(raw);
}
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,36 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class BanListCommand : IUserCommand {
private readonly MisuzuClient Misuzu;
public BanListCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("bans")
|| ctx.NameEquals("banned");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.KickUser)
&& !ctx.User.Permissions.HasFlag(UserPermissions.BanUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
Task.Run(async () => {
ctx.Chat.SendTo(ctx.User, new BanListResponsePacket(
(await Misuzu.GetBanListAsync() ?? Array.Empty<MisuzuBanInfo>()).Select(
ban => string.IsNullOrEmpty(ban.UserName) ? (string.IsNullOrEmpty(ban.RemoteAddress) ? string.Empty : ban.RemoteAddress) : ban.UserName
).ToArray()
));
}).Wait();
}
}
}

View file

@ -0,0 +1,65 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class ChannelCreateCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("create");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.CreateChannel)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
string firstArg = ctx.Args.First();
bool createChanHasHierarchy;
if(!ctx.Args.Any() || (createChanHasHierarchy = firstArg.All(char.IsDigit) && ctx.Args.Length < 2)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
int createChanHierarchy = 0;
if(createChanHasHierarchy)
if(!int.TryParse(firstArg, out createChanHierarchy))
createChanHierarchy = 0;
if(createChanHierarchy > ctx.User.Rank) {
ctx.Chat.SendTo(ctx.User, new ChannelRankTooHighErrorPacket());
return;
}
string createChanName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0));
if(!SockChatUtility.CheckChannelName(createChanName)) {
ctx.Chat.SendTo(ctx.User, new ChannelNameFormatErrorPacket());
return;
}
if(ctx.Chat.Channels.Get(createChanName, SockChatUtility.SanitiseChannelName) != null) {
ctx.Chat.SendTo(ctx.User, new ChannelNameInUseErrorPacket(createChanName));
return;
}
ChannelInfo createChan = new(
createChanName,
isTemporary: !ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPermanent),
rank: createChanHierarchy,
ownerId: ctx.User.UserId
);
ctx.Chat.Channels.Add(createChan, sanitiseName: SockChatUtility.SanitiseChannelName);
foreach(UserInfo ccu in ctx.Chat.Users.GetMany(minRank: ctx.Channel.Rank))
ctx.Chat.SendTo(ccu, new ChannelCreatePacket(
ctx.Channel.Name,
ctx.Channel.HasPassword,
ctx.Channel.IsTemporary
));
ctx.Chat.SwitchChannel(ctx.User, createChan, createChan.Password);
ctx.Chat.SendTo(ctx.User, new ChannelCreateResponsePacket(createChan.Name));
}
}
}

View file

@ -0,0 +1,36 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class ChannelDeleteCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("delchan") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
);
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.Args.Any() || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
string delChanName = string.Join('_', ctx.Args);
ChannelInfo? delChan = ctx.Chat.Channels.Get(delChanName, SockChatUtility.SanitiseChannelName);
if(delChan == null) {
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorPacket(delChanName));
return;
}
if(!ctx.User.Permissions.HasFlag(UserPermissions.DeleteChannel) || !delChan.IsOwner(ctx.User)) {
ctx.Chat.SendTo(ctx.User, new ChannelDeleteNotAllowedErrorPacket(delChan.Name));
return;
}
ctx.Chat.RemoveChannel(delChan);
ctx.Chat.SendTo(ctx.User, new ChannelDeleteResponsePacket(delChan.Name));
}
}
}

View file

@ -0,0 +1,23 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class ChannelJoinCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("join");
}
public void Dispatch(UserCommandContext ctx) {
string joinChanStr = ctx.Args.FirstOrDefault() ?? string.Empty;
ChannelInfo? joinChan = ctx.Chat.Channels.Get(joinChanStr, SockChatUtility.SanitiseChannelName);
if(joinChan == null) {
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorPacket(joinChanStr));
ctx.Chat.ForceChannel(ctx.User);
return;
}
ctx.Chat.SwitchChannel(ctx.User, joinChan, string.Join(' ', ctx.Args.Skip(1)));
}
}
}

View file

@ -0,0 +1,25 @@
using SharpChat.Packet;
namespace SharpChat.Commands {
public class ChannelPasswordCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("pwd")
|| ctx.NameEquals("password");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPassword) || !ctx.Channel.IsOwner(ctx.User)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
string chanPass = string.Join(' ', ctx.Args).Trim();
if(string.IsNullOrWhiteSpace(chanPass))
chanPass = string.Empty;
ctx.Chat.UpdateChannel(ctx.Channel, password: chanPass);
ctx.Chat.SendTo(ctx.User, new ChannelPasswordChangedResponsePacket());
}
}
}

View file

@ -0,0 +1,27 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class ChannelRankCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("rank")
|| ctx.NameEquals("privilege")
|| ctx.NameEquals("priv");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelHierarchy) || !ctx.Channel.IsOwner(ctx.User)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
if(!ctx.Args.Any() || !int.TryParse(ctx.Args.First(), out int chanHierarchy) || chanHierarchy > ctx.User.Rank) {
ctx.Chat.SendTo(ctx.User, new ChannelRankTooHighErrorPacket());
return;
}
ctx.Chat.UpdateChannel(ctx.Channel, minRank: chanHierarchy);
ctx.Chat.SendTo(ctx.User, new ChannelRankChangedResponsePacket());
}
}
}

View file

@ -0,0 +1,84 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class KickBanCommand : IUserCommand {
private readonly MisuzuClient Misuzu;
public KickBanCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("kick")
|| ctx.NameEquals("ban");
}
public void Dispatch(UserCommandContext ctx) {
bool isBanning = ctx.NameEquals("ban");
if(!ctx.User.Permissions.HasFlag(isBanning ? UserPermissions.BanUser : UserPermissions.KickUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
string banUserTarget = ctx.Args.ElementAtOrDefault(0) ?? string.Empty;
string? banDurationStr = ctx.Args.ElementAtOrDefault(1);
int banReasonIndex = 1;
UserInfo? banUser = null;
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(banUserTarget);
if(string.IsNullOrEmpty(name) || (banUser = ctx.Chat.Users.Get(name: name, nameTarget: target)) == null) {
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorPacket(banUserTarget));
return;
}
if(!ctx.User.IsSuper && banUser.Rank >= ctx.User.Rank && banUser != ctx.User) {
ctx.Chat.SendTo(ctx.User, new KickBanNotAllowedErrorPacket(SockChatUtility.GetUserName(banUser)));
return;
}
TimeSpan duration = isBanning ? TimeSpan.MaxValue : TimeSpan.Zero;
if(!string.IsNullOrWhiteSpace(banDurationStr) && double.TryParse(banDurationStr, out double durationSeconds)) {
if(durationSeconds < 0) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
duration = TimeSpan.FromSeconds(durationSeconds);
++banReasonIndex;
}
if(duration <= TimeSpan.Zero) {
ctx.Chat.BanUser(banUser, duration);
return;
}
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
Task.Run(async () => {
string userId = banUser.UserId.ToString();
string userIp = ctx.Chat.GetRemoteAddresses(banUser).FirstOrDefault()?.ToString() ?? string.Empty;
// obviously it makes no sense to only check for one ip address but that's current misuzu limitations
MisuzuBanInfo? fbi = await Misuzu.CheckBanAsync(userId, userIp);
if(fbi != null && fbi.IsBanned && !fbi.HasExpired) {
ctx.Chat.SendTo(ctx.User, new KickBanNotAllowedErrorPacket(SockChatUtility.GetUserName(banUser)));
return;
}
await Misuzu.CreateBanAsync(
userId, userIp,
ctx.User.UserId.ToString(), ctx.Connection.RemoteAddress.ToString(),
duration, banReason
);
ctx.Chat.BanUser(banUser, duration);
}).Wait();
}
}
}

View file

@ -0,0 +1,30 @@
using SharpChat.Events;
using System;
using System.Linq;
namespace SharpChat.Commands {
public class MessageActionCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("action")
|| ctx.NameEquals("me");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.Args.Any())
return;
string actionStr = string.Join(' ', ctx.Args);
if(string.IsNullOrWhiteSpace(actionStr))
return;
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
ctx.Channel,
ctx.User,
DateTimeOffset.Now,
actionStr,
false, true, false
));
}
}
}

View file

@ -0,0 +1,28 @@
using SharpChat.Events;
using SharpChat.Packet;
using System;
namespace SharpChat.Commands {
public class MessageBroadcastCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("say")
|| ctx.NameEquals("broadcast");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.Broadcast)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
string.Empty,
ctx.User,
DateTimeOffset.Now,
string.Join(' ', ctx.Args),
false, false, true
));
}
}
}

View file

@ -0,0 +1,41 @@
using SharpChat.EventStorage;
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands
{
public class MessageDeleteCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("delmsg") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
);
}
public void Dispatch(UserCommandContext ctx) {
bool deleteAnyMessage = ctx.User.Permissions.HasFlag(UserPermissions.DeleteAnyMessage);
if(!deleteAnyMessage && !ctx.User.Permissions.HasFlag(UserPermissions.DeleteOwnMessage)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
string? firstArg = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(firstArg) || !firstArg.All(char.IsDigit) || !long.TryParse(firstArg, out long delSeqId)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
StoredEventInfo? delMsg = ctx.Chat.Events.GetEvent(delSeqId);
if(delMsg == null || delMsg.Sender?.Rank > ctx.User.Rank || (!deleteAnyMessage && delMsg.Sender?.UserId != ctx.User.UserId)) {
ctx.Chat.SendTo(ctx.User, new MessageDeleteNotAllowedErrorPacket());
return;
}
ctx.Chat.Events.RemoveEvent(delMsg);
ctx.Chat.Send(new MessageDeletePacket(delMsg.Id));
}
}
}

View file

@ -0,0 +1,41 @@
using SharpChat.Events;
using SharpChat.Packet;
using System;
using System.Linq;
namespace SharpChat.Commands {
public class MessageWhisperCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("whisper")
|| ctx.NameEquals("msg");
}
public void Dispatch(UserCommandContext ctx) {
if(ctx.Args.Length < 2) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(whisperUserStr);
UserInfo? whisperUser = ctx.Chat.Users.Get(name: name, nameTarget: target);
if(whisperUser == null) {
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorPacket(whisperUserStr));
return;
}
if(whisperUser == ctx.User)
return;
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
UserInfo.GetDMChannelName(ctx.User, whisperUser),
ctx.User,
DateTimeOffset.Now,
string.Join(' ', ctx.Args.Skip(1)),
true, false, false
));
}
}
}

View file

@ -0,0 +1,51 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class PardonAddressCommand : IUserCommand {
private readonly MisuzuClient Misuzu;
public PardonAddressCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("pardonip")
|| ctx.NameEquals("unbanip");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.KickUser)
&& !ctx.User.Permissions.HasFlag(UserPermissions.BanUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
string? unbanAddrTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress? unbanAddr)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
unbanAddrTarget = unbanAddr.ToString();
Task.Run(async () => {
MisuzuBanInfo? banInfo = await Misuzu.CheckBanAsync(ipAddr: unbanAddrTarget);
if(banInfo?.IsBanned != true || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorPacket(unbanAddrTarget));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.RemoteAddress);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new PardonResponsePacket(unbanAddrTarget));
else
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorPacket(unbanAddrTarget));
}).Wait();
}
}
}

View file

@ -0,0 +1,59 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class PardonUserCommand : IUserCommand {
private readonly MisuzuClient Misuzu;
public PardonUserCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("pardon")
|| ctx.NameEquals("unban");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.KickUser)
&& !ctx.User.Permissions.HasFlag(UserPermissions.BanUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
bool unbanUserTargetIsName = true;
string? unbanUserTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(unbanUserTarget);
UserInfo? unbanUser = ctx.Chat.Users.Get(name: name, nameTarget: target);
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId)) {
unbanUserTargetIsName = false;
unbanUser = ctx.Chat.Users.Get(unbanUserId);
}
if(unbanUser != null)
unbanUserTarget = unbanUser.UserId.ToString();
Task.Run(async () => {
MisuzuBanInfo? banInfo = await Misuzu.CheckBanAsync(unbanUserTarget, userIdIsName: unbanUserTargetIsName);
if(banInfo?.IsBanned != true || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorPacket(unbanUserTarget));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.UserId);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new PardonResponsePacket(unbanUserTarget));
else
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorPacket(unbanUserTarget));
}).Wait();
}
}
}

View file

@ -0,0 +1,37 @@
using SharpChat.Packet;
using System;
using System.Threading;
namespace SharpChat.Commands {
public class ShutdownRestartCommand : IUserCommand {
private readonly ManualResetEvent WaitHandle;
private readonly Func<bool> ShutdownCheck;
public ShutdownRestartCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) {
WaitHandle = waitHandle;
ShutdownCheck = shutdownCheck;
}
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("shutdown")
|| ctx.NameEquals("restart");
}
public void Dispatch(UserCommandContext ctx) {
if(ctx.User.UserId != 1) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
if(!ShutdownCheck())
return;
if(ctx.NameEquals("restart"))
foreach(ConnectionInfo conn in ctx.Chat.Connections)
conn.PrepareForRestart();
ctx.Chat.Update();
WaitHandle?.Set();
}
}
}

View file

@ -0,0 +1,29 @@
using System.Linq;
namespace SharpChat.Commands {
public class UserAFKCommand : IUserCommand {
private const string DEFAULT = "AFK";
private const int MAX_LENGTH = 5;
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("afk");
}
public void Dispatch(UserCommandContext ctx) {
string? statusText = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
statusText = statusText.Trim();
if(statusText.Length > MAX_LENGTH)
statusText = statusText[..MAX_LENGTH].Trim();
}
ctx.Chat.UpdateUser(
ctx.User,
status: UserStatus.Away,
statusText: statusText
);
}
}
}

View file

@ -0,0 +1,54 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class UserNickCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("nick");
}
public void Dispatch(UserCommandContext ctx) {
bool setOthersNick = ctx.User.Permissions.HasFlag(UserPermissions.SetOthersNickname);
if(!setOthersNick && !ctx.User.Permissions.HasFlag(UserPermissions.SetOwnNickname)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
UserInfo? targetUser = null;
int offset = 0;
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
targetUser = ctx.Chat.Users.Get(targetUserId);
++offset;
}
targetUser ??= ctx.User;
if(ctx.Args.Length < offset) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
return;
}
string nickStr = string.Join('_', ctx.Args.Skip(offset))
.Replace("\n", string.Empty).Replace("\r", string.Empty)
.Replace("\f", string.Empty).Replace("\t", string.Empty)
.Replace(' ', '_').Trim();
if(nickStr == targetUser.UserName)
nickStr = string.Empty;
else if(nickStr.Length > 15)
nickStr = nickStr[..15];
else if(string.IsNullOrEmpty(nickStr))
nickStr = string.Empty;
if(!string.IsNullOrWhiteSpace(nickStr) && ctx.Chat.Users.Get(name: nickStr, nameTarget: UsersContext.NameTarget.UserAndNickName) != null) {
ctx.Chat.SendTo(ctx.User, new UserNameInUseErrorPacket(nickStr));
return;
}
string? previousName = targetUser == ctx.User ? (targetUser.NickName ?? targetUser.UserName) : null;
ctx.Chat.UpdateUser(targetUser, nickName: nickStr, silent: previousName == null);
}
}
}

View file

@ -0,0 +1,40 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class WhoCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("who");
}
public void Dispatch(UserCommandContext ctx) {
string? channelName = ctx.Args.FirstOrDefault();
if(string.IsNullOrEmpty(channelName)) {
ctx.Chat.SendTo(ctx.User, new WhoServerResponsePacket(
ctx.Chat.Users.All.Select(u => SockChatUtility.GetUserNameWithStatus(u)).ToArray(),
SockChatUtility.GetUserName(ctx.User)
));
return;
}
ChannelInfo? channel = ctx.Chat.Channels.Get(channelName, SockChatUtility.SanitiseChannelName);
if(channel == null) {
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorPacket(channelName));
return;
}
if(channel.Rank > ctx.User.Rank || (channel.HasPassword && !ctx.User.Permissions.HasFlag(UserPermissions.JoinAnyChannel))) {
ctx.Chat.SendTo(ctx.User, new WhoChannelNotFoundErrorPacket(channelName));
return;
}
ctx.Chat.SendTo(ctx.User, new WhoChannelResponsePacket(
channel.Name,
ctx.Chat.GetChannelUsers(channel).Select(user => SockChatUtility.GetUserNameWithStatus(user)).ToArray(),
SockChatUtility.GetUserNameWithStatus(ctx.User)
));
}
}
}

View file

@ -0,0 +1,31 @@
using SharpChat.Packet;
using System.Linq;
using System.Net;
namespace SharpChat.Commands {
public class WhoisCommand : IUserCommand {
public bool IsMatch(UserCommandContext ctx) {
return ctx.NameEquals("ip")
|| ctx.NameEquals("whois");
}
public void Dispatch(UserCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.SeeIPAddress)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
return;
}
string ipUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
UserInfo? ipUser;
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(ipUserStr);
if(string.IsNullOrWhiteSpace(name) || (ipUser = ctx.Chat.Users.Get(name: name, nameTarget: target)) == null) {
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorPacket(ipUserStr));
return;
}
foreach(IPAddress ip in ctx.Chat.GetRemoteAddresses(ipUser))
ctx.Chat.SendTo(ctx.User, new WhoisResponsePacket(ipUser.UserName, ip.ToString()));
}
}
}

View file

@ -0,0 +1,48 @@
using System;
namespace SharpChat.Config {
public class CachedValue<T> {
private IConfig Config { get; }
private string Name { get; }
private TimeSpan Lifetime { get; }
private T? Fallback { get; }
private object ConfigAccess { get; } = new();
private object? CurrentValue { get; set; }
private DateTimeOffset LastRead { get; set; }
public T? Value {
get {
lock(ConfigAccess) { // this lock doesn't really make sense since it doesn't affect other config calls
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<T> val) => val.Value;
public CachedValue(IConfig config, string name, TimeSpan lifetime, T? fallback) {
if(string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be empty.", nameof(name));
Config = config;
Name = name;
Lifetime = lifetime;
Fallback = fallback;
}
public void Refresh() {
LastRead = DateTimeOffset.MinValue;
}
public override string ToString() {
return Value?.ToString() ?? string.Empty;
}
}
}

View file

@ -0,0 +1,16 @@
using System;
namespace SharpChat.Config {
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) { }
}
}

View file

@ -0,0 +1,31 @@
using System;
namespace SharpChat.Config {
public interface IConfig : IDisposable {
/// <summary>
/// Creates a proxy object that forces all names to start with the given prefix.
/// </summary>
IConfig ScopeTo(string prefix);
/// <summary>
/// Reads a raw (string) value from the config.
/// </summary>
string? ReadValue(string name, string? fallback = null);
/// <summary>
/// Reads and casts value from the config.
/// </summary>
/// <exception cref="ConfigTypeException">Type conversion failed.</exception>
T? ReadValue<T>(string name, T? fallback = default);
/// <summary>
/// Reads and casts a value from the config. Returns fallback when type conversion fails.
/// </summary>
T? SafeReadValue<T>(string name, T fallback);
/// <summary>
/// Creates an object that caches the read value for a certain amount of time, avoiding disk reads for frequently used non-static values.
/// </summary>
CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null);
}
}

View file

@ -0,0 +1,47 @@
using System;
namespace SharpChat.Config {
public class ScopedConfig : IConfig {
private IConfig Config { get; }
private string Prefix { get; }
public ScopedConfig(IConfig config, string prefix) {
if(string.IsNullOrWhiteSpace(prefix))
throw new ArgumentException("Prefix must exist.", nameof(prefix));
Config = config;
Prefix = 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<T>(string name, T? fallback = default) {
return Config.ReadValue(GetName(name), fallback);
}
public T? SafeReadValue<T>(string name, T fallback) {
return Config.SafeReadValue(GetName(name), fallback);
}
public IConfig ScopeTo(string prefix) {
return Config.ScopeTo(GetName(prefix));
}
public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
return Config.ReadCached(GetName(name), fallback, lifetime);
}
public void Dispose() {
GC.SuppressFinalize(this);
}
}
}

View file

@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
namespace SharpChat.Config {
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;
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<T>(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<T>(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<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
return new CachedValue<T>(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();
}
}
}

View file

@ -0,0 +1,88 @@
using Fleck;
using System;
using System.Net;
namespace SharpChat {
public class ConnectionInfo : IDisposable {
public const int ID_LENGTH = 20;
#if DEBUG
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1);
#else
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5);
#endif
public IWebSocketConnection Socket { get; }
public string Id { get; }
public bool IsDisposed { get; private set; }
public DateTimeOffset LastPing { get; set; } = DateTimeOffset.Now;
public UserInfo? User { get; set; }
private int CloseCode { get; set; } = 1000;
public IPAddress RemoteAddress { get; }
public ushort RemotePort { get; }
public bool IsAlive => !IsDisposed && !HasTimedOut;
public bool IsAuthed => IsAlive && User is not null;
public ConnectionInfo(IWebSocketConnection sock) {
Socket = sock;
Id = RNG.SecureRandomString(ID_LENGTH);
if(!IPAddress.TryParse(sock.ConnectionInfo.ClientIpAddress, out IPAddress? addr))
throw new Exception("Unable to parse remote address?????");
if(IPAddress.IsLoopback(addr)
&& sock.ConnectionInfo.Headers.ContainsKey("X-Real-IP")
&& IPAddress.TryParse(sock.ConnectionInfo.Headers["X-Real-IP"], out IPAddress? realAddr))
addr = realAddr;
RemoteAddress = addr;
RemotePort = (ushort)sock.ConnectionInfo.ClientPort;
}
public void Send(SockChatS2CPacket packet) {
if(!Socket.IsAvailable)
return;
string data = packet.Pack();
if(!string.IsNullOrWhiteSpace(data))
Socket.Send(data);
}
public void BumpPing() {
LastPing = DateTimeOffset.Now;
}
public bool HasTimedOut
=> DateTimeOffset.Now - LastPing > SessionTimeOut;
public void PrepareForRestart() {
CloseCode = 1012;
}
~ConnectionInfo() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Socket.Close(CloseCode);
}
public override string ToString() {
return Id;
}
}
}

View file

@ -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<IChatEvent> GetEvents(IPacketTarget target, int amount, int offset) {
List<IChatEvent> events = new List<IChatEvent>();
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;
}
}
}

View file

@ -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;"
);
}
}
}

View file

@ -0,0 +1,36 @@
using System.Collections.Generic;
namespace SharpChat.EventStorage
{
public interface IEventStorage {
void AddEvent(
long id, string type,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
);
void AddEvent(
long id, string type,
string? channelName,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
);
void AddEvent(
long id, string type,
long senderId, string? senderName, Colour senderColour, int senderRank, string? senderNick, UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
);
void AddEvent(
long id, string type,
string? channelName,
long senderId, string? senderName, Colour senderColour, int senderRank, string? senderNick, UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
);
long AddEvent(string type, UserInfo user, ChannelInfo channel, object? data = null, StoredEventFlags flags = StoredEventFlags.None);
void RemoveEvent(StoredEventInfo evt);
StoredEventInfo? GetEvent(long seqId);
IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0);
}
}

View file

@ -0,0 +1,172 @@
using MySqlConnector;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage : IEventStorage {
private string ConnectionString { get; }
public MariaDBEventStorage(string connString) {
ConnectionString = connString;
}
public void AddEvent(
long id, string type,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, 0, null, Colour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
string? channelName,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, channelName, 0, null, Colour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
long senderId, string? senderName, Colour senderColour, int senderRank, string? senderNick, UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, senderId, senderName, senderColour, senderRank, senderNick, senderPerms, data, flags);
}
public void AddEvent(
long id, string type,
string? channelName,
long senderId, string? senderName, Colour senderColour, int senderRank, string? senderNick, UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
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, NOW(), @type, @target, @flags, @data"
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter("id", id),
new MySqlParameter("type", type),
new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new MySqlParameter("flags", (byte)flags),
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
new MySqlParameter("sender", senderId < 1 ? null : senderId),
new MySqlParameter("sender_name", senderName),
new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
new MySqlParameter("sender_rank", senderRank),
new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
new MySqlParameter("sender_perms", senderPerms)
);
}
public long AddEvent(string type, UserInfo user, ChannelInfo channel, object? data = null, StoredEventFlags flags = StoredEventFlags.None) {
long id = SharpId.Next();
AddEvent(
id, type,
channel?.Name,
user?.UserId ?? 0,
user?.UserName ?? string.Empty,
user?.Colour ?? Colour.None,
user?.Rank ?? 0,
user?.NickName,
user?.Permissions ?? 0,
data,
flags
);
return id;
}
public StoredEventInfo? 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`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_id` = @id",
new MySqlParameter("id", seqId)
);
if(reader != null)
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
return evt;
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private static StoredEventInfo ReadEvent(MySqlDataReader reader) {
return new StoredEventInfo(
reader.GetInt64("event_id"),
Encoding.ASCII.GetString((byte[])reader["event_type"]),
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new UserInfo(
reader.GetInt64("event_sender"),
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
Colour.FromMisuzu(reader.GetInt32("event_sender_colour")),
reader.GetInt32("event_sender_rank"),
(UserPermissions)reader.GetInt32("event_sender_perms"),
reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? null : reader.GetString("event_sender_nick")
),
DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")),
reader.IsDBNull(reader.GetOrdinal("event_target")) ? null : Encoding.ASCII.GetString((byte[])reader["event_target"]),
JsonDocument.Parse(Encoding.ASCII.GetString((byte[])reader["event_data"])),
(StoredEventFlags)reader.GetByte("event_flags")
);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
List<StoredEventInfo> events = new();
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`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_deleted` IS NULL AND (`event_target` = @target OR `event_target` IS NULL)"
+ " AND `event_id` > @offset"
+ " ORDER BY `event_id` DESC"
+ " LIMIT @amount",
new MySqlParameter("target", channelName),
new MySqlParameter("amount", amount),
new MySqlParameter("offset", offset)
);
if(reader != null)
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
events.Add(evt);
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
events.Reverse();
return events;
}
public void RemoveEvent(StoredEventInfo evt) {
RunCommand(
"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
new MySqlParameter("id", evt.Id)
);
}
}
}

View file

@ -0,0 +1,82 @@
using MySqlConnector;
using SharpChat.Config;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage {
public static string BuildConnString(IConfig config) {
return BuildConnString(
config.ReadValue("host", "localhost") ?? string.Empty,
config.ReadValue("user") ?? string.Empty,
config.ReadValue("pass") ?? string.Empty,
config.ReadValue("db", "sharpchat") ?? string.Empty
);
}
public static string BuildConnString(string host, string username, string password, string database) {
return new MySqlConnectionStringBuilder {
Server = host,
UserID = username,
Password = password,
Database = database,
OldGuids = false,
TreatTinyAsBoolean = false,
CharacterSet = "utf8mb4",
SslMode = MySqlSslMode.None,
ForceSynchronous = true,
ConnectionTimeout = 5,
}.ToString();
}
private MySqlConnection GetConnection() {
MySqlConnection conn = new(ConnectionString);
conn.Open();
return conn;
}
private int RunCommand(string command, params MySqlParameter[] parameters) {
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 MySqlDataReader? RunQuery(string command, params MySqlParameter[] parameters) {
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 object? RunQueryValue(string command, params MySqlParameter[] parameters) {
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;
}
}
}

View file

@ -0,0 +1,77 @@
using MySqlConnector;
using System;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage {
private 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)
);
}
}
public 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);
DoMigration("allow_null_target", AllowNullTarget);
DoMigration("update_event_type_names", UpdateEventTypeNames);
}
private void UpdateEventTypeNames() {
RunCommand(@"UPDATE sqc_events SET event_type = ""msg:add"" WHERE event_type = ""SharpChat.Events.ChatMessage""");
RunCommand(@"UPDATE sqc_events SET event_type = ""user:connect"" WHERE event_type = ""SharpChat.Events.UserConnectEvent""");
RunCommand(@"UPDATE sqc_events SET event_type = ""user:disconnect"" WHERE event_type = ""SharpChat.Events.UserDisconnectEvent""");
RunCommand(@"UPDATE sqc_events SET event_type = ""chan:join"" WHERE event_type = ""SharpChat.Events.UserChannelJoinEvent""");
RunCommand(@"UPDATE sqc_events SET event_type = ""chan:leave"" WHERE event_type = ""SharpChat.Events.UserChannelLeaveEvent""");
}
private void AllowNullTarget() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_target` `event_target` VARBINARY(255) NULL AFTER `event_type`;"
);
}
private 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;"
);
}
}
}

View file

@ -0,0 +1,14 @@
using System;
namespace SharpChat.EventStorage
{
[Flags]
public enum StoredEventFlags
{
None = 0,
Action = 1,
Broadcast = 1 << 1,
Log = 1 << 2,
Private = 1 << 3,
}
}

View file

@ -0,0 +1,35 @@
using System;
using System.Text.Json;
namespace SharpChat.EventStorage {
public class StoredEventInfo {
public long Id { get; set; }
public string Type { get; set; }
public UserInfo? Sender { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? Deleted { get; set; }
public string? ChannelName { get; set; }
public StoredEventFlags Flags { get; set; }
public JsonDocument Data { get; set; }
public StoredEventInfo(
long id,
string type,
UserInfo? sender,
DateTimeOffset created,
DateTimeOffset? deleted,
string? channelName,
JsonDocument data,
StoredEventFlags flags
) {
Id = id;
Type = type;
Sender = sender;
Created = created;
Deleted = deleted;
ChannelName = channelName;
Data = data;
Flags = flags;
}
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace SharpChat.EventStorage {
public class VirtualEventStorage : IEventStorage {
private readonly Dictionary<long, StoredEventInfo> Events = new();
public void AddEvent(
long id, string type,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, 0, null, Colour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
string? channelName,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, channelName, 0, null, Colour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
long senderId, string? senderName, Colour senderColour, int senderRank, string? senderNick, UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, senderId, senderName, senderColour, senderRank, senderNick, senderPerms, data, flags);
}
public void AddEvent(
long id, string type,
string? channelName,
long senderId, string? senderName, Colour senderColour, int senderRank, string? senderNick, UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
// VES is meant as an emergency fallback but this is something else
JsonDocument hack = JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data));
Events.Add(id, new(id, type, senderId < 1 ? null : new UserInfo(
senderId,
senderName ?? string.Empty,
senderColour,
senderRank,
senderPerms,
senderNick
), DateTimeOffset.Now, null, channelName, hack, flags));
}
public long AddEvent(string type, UserInfo user, ChannelInfo channel, object? data = null, StoredEventFlags flags = StoredEventFlags.None) {
long id = SharpId.Next();
AddEvent(
id, type,
channel?.Name,
user?.UserId ?? 0,
user?.UserName,
user?.Colour ?? Colour.None,
user?.Rank ?? 0,
user?.NickName,
user?.Permissions ?? 0,
data,
flags
);
return id;
}
public StoredEventInfo? GetEvent(long seqId) {
return Events.TryGetValue(seqId, out StoredEventInfo? evt) ? evt : null;
}
public void RemoveEvent(StoredEventInfo evt) {
Events.Remove(evt.Id);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
IEnumerable<StoredEventInfo> subset = Events.Values.Where(ev => ev.ChannelName == channelName);
int start = subset.Count() - offset - amount;
if(start < 0) {
amount += start;
start = 0;
}
return subset.Skip(start).Take(amount).ToArray();
}
}
}

View file

@ -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);
}
}
}

View file

@ -1,25 +1,4 @@
using System;
namespace SharpChat.Events {
[Flags]
public enum ChatMessageFlags {
None = 0,
Action = 1,
Broadcast = 1 << 1,
Log = 1 << 2,
Private = 1 << 3,
}
namespace SharpChat.Events {
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; }
}
}

View file

@ -0,0 +1,94 @@
using System;
namespace SharpChat.Events {
public class MessageCreateEvent : IChatEvent {
public long MessageId { get; }
public string? ChannelName { get; }
public long SenderId { get; }
public string? SenderName { get; }
public Colour SenderColour { get; }
public int SenderRank { get; }
public string? SenderNickName { get; }
public UserPermissions SenderPerms { get; }
public DateTimeOffset MessageCreated { get; }
public string MessageText { get; }
public bool IsPrivate { get; }
public bool IsAction { get; }
public bool IsBroadcast { get; }
public MessageCreateEvent(
long msgId,
string? channelName,
long senderId,
string? senderName,
Colour senderColour,
int senderRank,
string? senderNickName,
UserPermissions senderPerms,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) {
MessageId = msgId;
ChannelName = channelName;
SenderId = senderId;
SenderName = senderName;
SenderColour = senderColour;
SenderRank = senderRank;
SenderNickName = senderNickName;
SenderPerms = senderPerms;
MessageCreated = msgCreated;
MessageText = msgText;
IsPrivate = isPrivate;
IsAction = isAction;
IsBroadcast = isBroadcast;
}
public MessageCreateEvent(
long msgId,
string? channelName,
UserInfo? sender,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) : this(
msgId,
channelName,
sender?.UserId ?? -1,
sender?.UserName ?? null,
sender?.Colour ?? Colour.None,
sender?.Rank ?? 0,
sender?.NickName ?? null,
sender?.Permissions ?? 0,
msgCreated,
msgText,
isPrivate,
isAction,
isBroadcast
) { }
public MessageCreateEvent(
long msgId,
ChannelInfo channel,
UserInfo sender,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) : this(
msgId,
channel?.Name ?? null,
sender,
msgCreated,
msgText,
isPrivate,
isAction,
isBroadcast
) { }
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<FlashiiAuth> onComplete, Action<Exception> 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<FlashiiAuth>(r.GetBodyBytes()));
} catch(Exception ex) {
onError(ex);
}
}, (t, e) => onError(e));
}
}
}

View file

@ -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<IEnumerable<FlashiiBan>> onComplete, Action<Exception> 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<IEnumerable<FlashiiBan>>(r.GetBodyBytes()));
} catch(Exception ex) {
onError(ex);
}
}, (t, e) => onError(e));
}
}
}

View file

@ -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<ChatUser> users) {
List<FlashiiBump> 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<FlashiiBump> 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}"));
}
}
}

View file

@ -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";
}
}

View file

@ -1,8 +0,0 @@
using SharpChat.Events;
namespace SharpChat {
public interface IChatCommand {
bool IsMatch(string name);
IChatMessage Dispatch(IChatCommandContext context);
}
}

View file

@ -1,22 +0,0 @@
using System;
using System.Collections.Generic;
namespace SharpChat {
public interface IChatCommandContext {
IEnumerable<string> Args { get; }
ChatUser User { get; }
ChatChannel Channel { get; }
}
public class ChatCommandContext : IChatCommandContext {
public IEnumerable<string> Args { get; }
public ChatUser User { get; }
public ChatChannel Channel { get; }
public ChatCommandContext(IEnumerable<string> 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));
}
}
}

View file

@ -0,0 +1,6 @@
namespace SharpChat {
public interface IPacketHandler {
bool IsMatch(PacketHandlerContext ctx);
void Handle(PacketHandlerContext ctx);
}
}

View file

@ -1,6 +0,0 @@
namespace SharpChat {
public interface IPacketTarget {
string TargetName { get; }
void Send(IServerPacket packet);
}
}

View file

@ -1,22 +0,0 @@
using System.Collections.Generic;
using System.Threading;
namespace SharpChat {
public interface IServerPacket {
long SequenceId { get; }
IEnumerable<string> 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<string> Pack();
}
}

View file

@ -0,0 +1,6 @@
namespace SharpChat {
public interface IUserCommand {
bool IsMatch(UserCommandContext ctx);
void Dispatch(UserCommandContext ctx);
}
}

View file

@ -4,25 +4,31 @@ using System.Text;
namespace SharpChat {
public static class Logger {
public static void Write(string str)
=> Console.WriteLine(string.Format(@"[{1}] {0}", str, DateTime.Now));
public static void Write(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(byte[] bytes) {
Write(Encoding.UTF8.GetString(bytes));
}
public static void Write(object obj)
=> Write(obj?.ToString() ?? string.Empty);
public static void Write(object obj) {
Write(obj?.ToString() ?? string.Empty);
}
[Conditional(@"DEBUG")]
public static void Debug(string str)
=> Write(str);
[Conditional("DEBUG")]
public static void Debug(string str) {
Write(str);
}
[Conditional(@"DEBUG")]
public static void Debug(byte[] bytes)
=> Write(bytes);
[Conditional("DEBUG")]
public static void Debug(byte[] bytes) {
Write(bytes);
}
[Conditional(@"DEBUG")]
public static void Debug(object obj)
=> Write(obj);
[Conditional("DEBUG")]
public static void Debug(object obj) {
Write(obj);
}
}
}

Some files were not shown because too many files have changed in this diff Show more