Compare commits
67 commits
master
...
new-master
Author | SHA1 | Date | |
---|---|---|---|
flash | 322500739e | ||
flash | a6a7e56bd1 | ||
flash | 7bcf5acb7e | ||
flash | 38f17c325a | ||
flash | 907711e753 | ||
flash | 8cc00fe1a8 | ||
flash | 3c58e5201a | ||
flash | 795a87fe56 | ||
flash | a6569815af | ||
flash | b95cd06cb1 | ||
flash | 356409eb16 | ||
flash | 1ba94a526c | ||
flash | 0b0de00cc4 | ||
flash | b1fae4bdeb | ||
flash | fc7d428f76 | ||
flash | 54af837c82 | ||
flash | 0d0f2e68b9 | ||
flash | e291a17705 | ||
flash | cd32995367 | ||
flash | 5985f63744 | ||
flash | a9ca3705ad | ||
flash | 294471dcfd | ||
flash | c46d117d15 | ||
flash | 05fcbcb0f8 | ||
flash | 03b3b6b0a3 | ||
flash | a7a05f04bd | ||
flash | dc4989a3cf | ||
flash | 903e39ab76 | ||
flash | 8c19c22736 | ||
flash | 4e0def980f | ||
flash | 82973f7a33 | ||
flash | 8de3ba8dbb | ||
flash | 86a46539f2 | ||
flash | 70df99fe9b | ||
flash | 546e8a2c83 | ||
flash | d268a419dc | ||
flash | 1466562c54 | ||
flash | a5089f14b8 | ||
flash | 13ae843c8d | ||
flash | 06af94e94f | ||
flash | c8a589c1c1 | ||
flash | ea56af0210 | ||
flash | d1f78a7e8b | ||
flash | dbdaaeec9e | ||
flash | 8050a295c1 | ||
flash | c291ef178d | ||
flash | c21605cf3b | ||
flash | e1e3def62c | ||
flash | 56a818254e | ||
flash | 40c7ba4ded | ||
flash | 27c28aafcd | ||
flash | 36f3ff6385 | ||
flash | 5e3eecda8c | ||
flash | 4104e40843 | ||
flash | c9cc5ff23a | ||
flash | 513539319f | ||
flash | d2fef02e08 | ||
flash | 1051a26494 | ||
flash | 6f50ec66a9 | ||
flash | e6dffe06e6 | ||
flash | 9790f77a16 | ||
flash | 08f9a2c5a1 | ||
flash | ea83c8cca0 | ||
flash | bfd1819798 | ||
flash | 2de19035ff | ||
flash | 3f8c2781ee | ||
flash | 23f0bd478f |
4
.editorconfig
Normal file
4
.editorconfig
Normal file
|
@ -0,0 +1,4 @@
|
|||
[*.{cs,vb}]
|
||||
|
||||
# IDE0046: Convert to conditional expression
|
||||
dotnet_style_prefer_conditional_expression_over_return = false
|
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.") { }
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
2
LICENSE
2
LICENSE
|
@ -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
|
||||
|
|
1323
Protocol-draft.md
1323
Protocol-draft.md
File diff suppressed because it is too large
Load diff
1917
Protocol.md
1917
Protocol.md
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,106 +1,59 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
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 class ChatChannel {
|
||||
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 string TargetName => Name;
|
||||
public ChatChannel(
|
||||
ChatUser owner,
|
||||
string name,
|
||||
string? password = null,
|
||||
bool isTemporary = false,
|
||||
int rank = 0
|
||||
) : this(name, password, isTemporary, rank, owner?.UserId ?? 0) {}
|
||||
|
||||
public ChatChannel() {
|
||||
}
|
||||
|
||||
public ChatChannel(string name) {
|
||||
public ChatChannel(
|
||||
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 HasUser(ChatUser user) {
|
||||
lock (Users)
|
||||
return Users.Contains(user);
|
||||
public bool NameEquals(string? name) {
|
||||
return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
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 bool IsOwner(ChatUser user) {
|
||||
return OwnerId > 0
|
||||
&& user != null
|
||||
&& OwnerId == user.UserId;
|
||||
}
|
||||
|
||||
public void UserLeave(ChatUser user) {
|
||||
lock (Users)
|
||||
Users.Remove(user);
|
||||
|
||||
if (user.InChannel(this))
|
||||
user.LeaveChannel(this);
|
||||
public override int GetHashCode() {
|
||||
return Name.GetHashCode();
|
||||
}
|
||||
|
||||
public void Send(IServerPacket packet) {
|
||||
lock (Users) {
|
||||
foreach (ChatUser user in Users)
|
||||
user.Send(packet);
|
||||
}
|
||||
public static bool CheckName(string name) {
|
||||
return !string.IsNullOrWhiteSpace(name) && name.All(CheckNameChar);
|
||||
}
|
||||
|
||||
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();
|
||||
public static bool CheckNameChar(char c) {
|
||||
return char.IsLetter(c) || char.IsNumber(c) || c == '-' || c == '_';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,55 +1,77 @@
|
|||
namespace SharpChat {
|
||||
public class ChatColour {
|
||||
public const int INHERIT = 0x40000000;
|
||||
public readonly struct ChatColour {
|
||||
public readonly byte Red;
|
||||
public readonly byte Green;
|
||||
public readonly byte Blue;
|
||||
public readonly bool Inherits;
|
||||
|
||||
public int Raw { get; set; }
|
||||
public static ChatColour None { get; } = new();
|
||||
|
||||
public ChatColour(bool inherit = true) {
|
||||
Inherit = inherit;
|
||||
public ChatColour() {
|
||||
Red = 0;
|
||||
Green = 0;
|
||||
Blue = 0;
|
||||
Inherits = true;
|
||||
}
|
||||
|
||||
public ChatColour(int colour) {
|
||||
Raw = colour;
|
||||
public ChatColour(byte red, byte green, byte blue) {
|
||||
Red = red;
|
||||
Green = green;
|
||||
Blue = blue;
|
||||
Inherits = false;
|
||||
}
|
||||
|
||||
public bool Inherit {
|
||||
get => (Raw & INHERIT) > 0;
|
||||
set {
|
||||
if (value)
|
||||
Raw |= INHERIT;
|
||||
else
|
||||
Raw &= ~INHERIT;
|
||||
}
|
||||
public override bool Equals(object? obj) {
|
||||
return obj is ChatColour colour && Equals(colour);
|
||||
}
|
||||
|
||||
public int Red {
|
||||
get => (Raw >> 16) & 0xFF;
|
||||
set {
|
||||
Raw &= ~0xFF0000;
|
||||
Raw |= (value & 0xFF) << 16;
|
||||
}
|
||||
public bool Equals(ChatColour other) {
|
||||
return Red == other.Red
|
||||
&& Green == other.Green
|
||||
&& Blue == other.Blue
|
||||
&& Inherits == other.Inherits;
|
||||
}
|
||||
|
||||
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 int GetHashCode() {
|
||||
return ToMisuzu();
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
if (Inherit)
|
||||
return @"inherit";
|
||||
return string.Format(@"#{0:X6}", Raw);
|
||||
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 ChatColour 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 ChatColour FromMisuzu(int raw) {
|
||||
return (raw & MSZ_INHERIT) > 0
|
||||
? None
|
||||
: FromRawRGB(raw);
|
||||
}
|
||||
|
||||
public static bool operator ==(ChatColour left, ChatColour right) {
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(ChatColour left, ChatColour right) {
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
53
SharpChat/ChatCommandContext.cs
Normal file
53
SharpChat/ChatCommandContext.cs
Normal file
|
@ -0,0 +1,53 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatCommandContext {
|
||||
public string Name { get; }
|
||||
public string[] Args { get; }
|
||||
public ChatContext Chat { get; }
|
||||
public ChatUser User { get; }
|
||||
public ChatConnection Connection { get; }
|
||||
public ChatChannel Channel { get; }
|
||||
|
||||
public ChatCommandContext(
|
||||
string text,
|
||||
ChatContext chat,
|
||||
ChatUser user,
|
||||
ChatConnection connection,
|
||||
ChatChannel channel
|
||||
) {
|
||||
if(text == null)
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
|
||||
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
User = user ?? throw new ArgumentNullException(nameof(user));
|
||||
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
|
||||
string[] parts = text[1..].Split(' ');
|
||||
Name = parts.First().Replace(".", string.Empty);
|
||||
Args = parts.Skip(1).ToArray();
|
||||
}
|
||||
|
||||
public ChatCommandContext(
|
||||
string name,
|
||||
string[] args,
|
||||
ChatContext chat,
|
||||
ChatUser user,
|
||||
ChatConnection connection,
|
||||
ChatChannel channel
|
||||
) {
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Args = args ?? throw new ArgumentNullException(nameof(args));
|
||||
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
User = user ?? throw new ArgumentNullException(nameof(user));
|
||||
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
}
|
||||
|
||||
public bool NameEquals(string name) {
|
||||
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
92
SharpChat/ChatConnection.cs
Normal file
92
SharpChat/ChatConnection.cs
Normal file
|
@ -0,0 +1,92 @@
|
|||
using Fleck;
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatConnection : 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 ChatUser? 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 ChatConnection(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(IServerPacket 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;
|
||||
}
|
||||
|
||||
~ChatConnection() {
|
||||
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;
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return Id.GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,116 +1,310 @@
|
|||
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 record ChannelUserAssoc(long UserId, string ChannelName);
|
||||
|
||||
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 readonly SemaphoreSlim ContextAccess = new(1, 1);
|
||||
|
||||
public string TargetName => @"@broadcast";
|
||||
public HashSet<ChatChannel> Channels { get; } = new();
|
||||
public HashSet<ChatConnection> Connections { get; } = new();
|
||||
public HashSet<ChatUser> Users { get; } = new();
|
||||
public IEventStorage Events { get; }
|
||||
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = new();
|
||||
public Dictionary<long, RateLimiter> UserRateLimiters { get; } = new();
|
||||
public Dictionary<long, ChatChannel> UserLastChannel { get; } = new();
|
||||
|
||||
public ChatContext(SockChatServer server) {
|
||||
Server = server;
|
||||
Bans = new BanManager(this);
|
||||
Users = new UserManager(this);
|
||||
Channels = new ChannelManager(this);
|
||||
Events = new ChatEventManager(this);
|
||||
public ChatContext(IEventStorage evtStore) {
|
||||
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
|
||||
}
|
||||
|
||||
BumpTimer = new Timer(e => FlashiiBump.Submit(Users.WithActiveConnections()), null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
|
||||
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
|
||||
|
||||
// this entire routine is garbage, channels should probably in the db
|
||||
if(mce.ChannelName?.StartsWith("@") != true)
|
||||
return;
|
||||
|
||||
IEnumerable<long> uids = mce.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1);
|
||||
if(uids.Count() != 2)
|
||||
return;
|
||||
|
||||
IEnumerable<ChatUser> users = Users.Where(u => uids.Any(uid => uid == u.UserId));
|
||||
ChatUser? target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
|
||||
if(target == null)
|
||||
return;
|
||||
|
||||
foreach(ChatUser user in users)
|
||||
SendTo(user, new MessageAddPacket(
|
||||
mce.MessageId,
|
||||
DateTimeOffset.Now,
|
||||
mce.SenderId,
|
||||
mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText,
|
||||
mce.IsAction,
|
||||
true
|
||||
));
|
||||
} else {
|
||||
ChatChannel? channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName));
|
||||
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(ChatConnection conn in Connections)
|
||||
if(!conn.IsDisposed && conn.HasTimedOut) {
|
||||
conn.Dispose();
|
||||
Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}.");
|
||||
}
|
||||
|
||||
Connections.RemoveWhere(conn => conn.IsDisposed);
|
||||
|
||||
foreach(ChatUser user in Users)
|
||||
if(!Connections.Any(conn => conn.User == user)) {
|
||||
HandleDisconnect(user, ChatUserDisconnectReason.TimeOut);
|
||||
Logger.Write($"Timed out {user} (no more connections).");
|
||||
}
|
||||
}
|
||||
|
||||
public void BanUser(ChatUser user, DateTimeOffset? until = null, bool banIPs = false, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
|
||||
if (until.HasValue && until.Value <= DateTimeOffset.UtcNow)
|
||||
until = null;
|
||||
public void SafeUpdate() {
|
||||
ContextAccess.Wait();
|
||||
try {
|
||||
Update();
|
||||
} finally {
|
||||
ContextAccess.Release();
|
||||
}
|
||||
}
|
||||
|
||||
if (until.HasValue) {
|
||||
user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Banned, until.Value));
|
||||
Bans.Add(user, until.Value);
|
||||
public bool IsInChannel(ChatUser? user, ChatChannel? channel) {
|
||||
return user != null
|
||||
&& channel != null
|
||||
&& ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
|
||||
}
|
||||
|
||||
if (banIPs) {
|
||||
foreach (IPAddress ip in user.RemoteAddresses)
|
||||
Bans.Add(ip, until.Value);
|
||||
}
|
||||
public string[] GetUserChannelNames(ChatUser user) {
|
||||
return ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName).ToArray();
|
||||
}
|
||||
|
||||
public ChatChannel[] GetUserChannels(ChatUser user) {
|
||||
string[] names = GetUserChannelNames(user);
|
||||
return Channels.Where(c => names.Any(n => c.NameEquals(n))).ToArray();
|
||||
}
|
||||
|
||||
public long[] GetChannelUserIds(ChatChannel channel) {
|
||||
return ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId).ToArray();
|
||||
}
|
||||
|
||||
public ChatUser[] GetChannelUsers(ChatChannel channel) {
|
||||
long[] ids = GetChannelUserIds(channel);
|
||||
return Users.Where(u => ids.Contains(u.UserId)).ToArray();
|
||||
}
|
||||
|
||||
public void UpdateUser(
|
||||
ChatUser user,
|
||||
string? userName = null,
|
||||
string? nickName = null,
|
||||
ChatColour? colour = null,
|
||||
ChatUserStatus? status = null,
|
||||
string? statusText = null,
|
||||
int? rank = null,
|
||||
ChatUserPermissions? perms = null,
|
||||
bool? isSuper = null,
|
||||
bool silent = false
|
||||
) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
|
||||
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 != 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, user.LegacyNameWithStatus));
|
||||
|
||||
SendToUserChannels(user, new UserUpdatePacket(
|
||||
user.UserId,
|
||||
user.LegacyNameWithStatus,
|
||||
user.Colour,
|
||||
user.Rank,
|
||||
user.Permissions
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public void BanUser(ChatUser user, TimeSpan duration, ChatUserDisconnectReason reason = ChatUserDisconnectReason.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(ChatConnection conn in Connections)
|
||||
if(conn.User == user)
|
||||
conn.Dispose();
|
||||
Connections.RemoveWhere(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));
|
||||
}
|
||||
|
||||
sess.Send(new AuthSuccessPacket(user, chan, sess));
|
||||
sess.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
|
||||
|
||||
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);
|
||||
|
||||
foreach(IChatEvent msg in msgs)
|
||||
sess.Send(new ContextMessagePacket(msg));
|
||||
|
||||
sess.Send(new ContextChannelsPacket(Channels.OfHierarchy(user.Rank)));
|
||||
|
||||
if (!chan.HasUser(user))
|
||||
chan.UserJoin(user);
|
||||
|
||||
if (!Users.Contains(user))
|
||||
Users.Add(user);
|
||||
public void HandleChannelEventLog(string channelName, Action<IServerPacket> handler) {
|
||||
foreach(StoredEventInfo msg in Events.GetChannelEventLog(channelName))
|
||||
handler(msg.Type switch {
|
||||
"user:connect" => new UserConnectLogPacket(msg.Created, msg.Sender?.LegacyName ?? string.Empty),
|
||||
"user:disconnect" => new UserDisconnectLogPacket(
|
||||
msg.Created,
|
||||
msg.Sender?.LegacyNameWithStatus ?? string.Empty,
|
||||
(ChatUserDisconnectReason)msg.Data.RootElement.GetProperty("reason").GetByte()
|
||||
),
|
||||
_ => new MessagePopulatePacket(msg),
|
||||
});
|
||||
}
|
||||
|
||||
public void UserLeave(ChatChannel chan, ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
|
||||
user.Status = ChatUserStatus.Offline;
|
||||
|
||||
if (chan == null) {
|
||||
foreach(ChatChannel channel in user.GetChannels()) {
|
||||
UserLeave(channel, user, reason);
|
||||
}
|
||||
return;
|
||||
public void HandleJoin(ChatUser user, ChatChannel chan, ChatConnection conn, int maxMsgLength) {
|
||||
if(!IsInChannel(user, chan)) {
|
||||
SendTo(chan, new UserConnectPacket(
|
||||
DateTimeOffset.Now,
|
||||
user.UserId,
|
||||
user.LegacyNameWithStatus,
|
||||
user.Colour,
|
||||
user.Rank,
|
||||
user.Permissions
|
||||
));
|
||||
Events.AddEvent("user:connect", user, chan, flags: StoredEventFlags.Log);
|
||||
}
|
||||
|
||||
if (chan.IsTemporary && chan.Owner == user)
|
||||
Channels.Remove(chan);
|
||||
conn.Send(new AuthSuccessPacket(
|
||||
user.UserId,
|
||||
user.LegacyNameWithStatus,
|
||||
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, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions, true)
|
||||
).OrderByDescending(user => user.Rank).ToArray()));
|
||||
|
||||
chan.UserLeave(user);
|
||||
chan.Send(new UserDisconnectPacket(DateTimeOffset.Now, user, reason));
|
||||
Events.Add(new UserDisconnectEvent(DateTimeOffset.Now, user, chan, reason));
|
||||
HandleChannelEventLog(chan.Name, p => conn.Send(p));
|
||||
|
||||
conn.Send(new ChannelsPopulatePacket(Channels.Where(c => c.Rank <= user.Rank).Select(
|
||||
channel => new ChannelsPopulatePacket.ListEntry(channel.Name, channel.HasPassword, channel.IsTemporary)
|
||||
).ToArray()));
|
||||
|
||||
Users.Add(user);
|
||||
|
||||
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
|
||||
UserLastChannel[user.UserId] = chan;
|
||||
}
|
||||
|
||||
public void HandleDisconnect(ChatUser user, ChatUserDisconnectReason reason = ChatUserDisconnectReason.Leave) {
|
||||
UpdateUser(user, status: ChatUserStatus.Offline);
|
||||
Users.Remove(user);
|
||||
UserLastChannel.Remove(user.UserId);
|
||||
|
||||
ChatChannel[] channels = GetUserChannels(user);
|
||||
|
||||
foreach(ChatChannel chan in channels) {
|
||||
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, chan.Name));
|
||||
|
||||
SendTo(chan, new UserDisconnectPacket(DateTimeOffset.Now, user.UserId, user.LegacyNameWithStatus, reason));
|
||||
Events.AddEvent("user:disconnect", user, chan, new { reason = (int)reason }, StoredEventFlags.Log);
|
||||
|
||||
if(chan.IsTemporary && chan.IsOwner(user))
|
||||
RemoveChannel(chan);
|
||||
}
|
||||
}
|
||||
|
||||
public void SwitchChannel(ChatUser user, ChatChannel chan, string password) {
|
||||
if (user.CurrentChannel == chan) {
|
||||
//user.Send(true, @"samechan", chan.Name);
|
||||
user.ForceChannel();
|
||||
if(UserLastChannel.TryGetValue(user.UserId, out ChatChannel? ulc) && chan == ulc) {
|
||||
ForceChannel(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.Owner != user) {
|
||||
if (chan.Rank > user.Rank) {
|
||||
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
|
||||
user.ForceChannel();
|
||||
if(!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.IsOwner(user)) {
|
||||
if(chan.Rank > user.Rank) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -119,72 +313,137 @@ namespace SharpChat {
|
|||
}
|
||||
|
||||
public void ForceChannelSwitch(ChatUser user, ChatChannel chan) {
|
||||
if (!Channels.Contains(chan))
|
||||
if(!Channels.Contains(chan))
|
||||
return;
|
||||
|
||||
ChatChannel oldChan = user.CurrentChannel;
|
||||
ChatChannel oldChan = UserLastChannel[user.UserId];
|
||||
|
||||
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));
|
||||
SendTo(oldChan, new UserChannelLeavePacket(user.UserId));
|
||||
Events.AddEvent("chan:leave", user, oldChan, flags: StoredEventFlags.Log);
|
||||
SendTo(chan, new UserChannelJoinPacket(user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
|
||||
Events.AddEvent("chan:join", user, oldChan, flags: StoredEventFlags.Log);
|
||||
|
||||
user.Send(new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
|
||||
user.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
|
||||
SendTo(user, new ContextClearPacket(ContextClearPacket.ClearMode.MessagesUsers));
|
||||
SendTo(user, new UsersPopulatePacket(GetChannelUsers(chan).Except(new[] { user }).Select(
|
||||
user => new UsersPopulatePacket.ListEntry(user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions, true)
|
||||
).OrderByDescending(u => u.Rank).ToArray()));
|
||||
|
||||
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);
|
||||
HandleChannelEventLog(chan.Name, p => SendTo(user, p));
|
||||
ForceChannel(user, chan);
|
||||
|
||||
foreach (IChatEvent msg in msgs)
|
||||
user.Send(new ContextMessagePacket(msg));
|
||||
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
|
||||
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
|
||||
UserLastChannel[user.UserId] = chan;
|
||||
|
||||
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);
|
||||
}
|
||||
if(oldChan.IsTemporary && oldChan.IsOwner(user))
|
||||
RemoveChannel(oldChan);
|
||||
}
|
||||
|
||||
public void Send(IServerPacket packet) {
|
||||
foreach (ChatUser user in Users.All())
|
||||
user.Send(packet);
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
foreach(ChatConnection conn in Connections)
|
||||
if(conn.IsAuthed)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
~ChatContext()
|
||||
=> Dispose(false);
|
||||
public void SendTo(ChatUser user, IServerPacket packet) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
public void Dispose()
|
||||
=> Dispose(true);
|
||||
foreach(ChatConnection conn in Connections)
|
||||
if(conn.IsAlive && conn.User == user)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing) {
|
||||
if (IsDisposed)
|
||||
public void SendTo(ChatChannel channel, IServerPacket packet) {
|
||||
if(channel == null)
|
||||
throw new ArgumentNullException(nameof(channel));
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
// might be faster to grab the users first and then cascade into that SendTo
|
||||
IEnumerable<ChatConnection> conns = Connections.Where(c => c.IsAuthed && IsInChannel(c.User, channel));
|
||||
foreach(ChatConnection conn in conns)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
public void SendToUserChannels(ChatUser user, IServerPacket packet) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
IEnumerable<ChatChannel> chans = Channels.Where(c => IsInChannel(user, c));
|
||||
IEnumerable<ChatConnection> conns = Connections.Where(conn => conn.IsAuthed && ChannelUsers.Any(cu => cu.UserId == conn.User?.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName))));
|
||||
foreach(ChatConnection conn in conns)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
public IPAddress[] GetRemoteAddresses(ChatUser user) {
|
||||
return Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct().ToArray();
|
||||
}
|
||||
|
||||
public void ForceChannel(ChatUser user, ChatChannel? chan = null) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
|
||||
if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan))
|
||||
throw new ArgumentException("no channel???");
|
||||
|
||||
SendTo(user, new UserChannelForceJoinPacket(chan.Name));
|
||||
}
|
||||
|
||||
public void UpdateChannel(
|
||||
ChatChannel channel,
|
||||
bool? temporary = null,
|
||||
int? minRank = 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;
|
||||
|
||||
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
|
||||
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank)) {
|
||||
SendTo(user, new ChannelUpdatePacket(prevName, channel.Name, channel.HasPassword, channel.IsTemporary));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveChannel(ChatChannel channel) {
|
||||
if(channel == null || !Channels.Any())
|
||||
return;
|
||||
IsDisposed = true;
|
||||
|
||||
BumpTimer?.Dispose();
|
||||
Events?.Dispose();
|
||||
Channels?.Dispose();
|
||||
Users?.Dispose();
|
||||
Bans?.Dispose();
|
||||
ChatChannel? defaultChannel = Channels.FirstOrDefault();
|
||||
if(defaultChannel == null)
|
||||
return;
|
||||
|
||||
if (disposing)
|
||||
GC.SuppressFinalize(this);
|
||||
// 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 GetChannelUsers(channel))
|
||||
SwitchChannel(user, defaultChannel, string.Empty);
|
||||
|
||||
// Broadcast deletion of channel
|
||||
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank))
|
||||
SendTo(user, new ChannelDeletePacket(channel.Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
27
SharpChat/ChatPacketHandlerContext.cs
Normal file
27
SharpChat/ChatPacketHandlerContext.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using System;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatPacketHandlerContext {
|
||||
public string Text { get; }
|
||||
public ChatContext Chat { get; }
|
||||
public ChatConnection Connection { get; }
|
||||
|
||||
public ChatPacketHandlerContext(
|
||||
string text,
|
||||
ChatContext chat,
|
||||
ChatConnection connection
|
||||
) {
|
||||
Text = text ?? throw new ArgumentNullException(nameof(text));
|
||||
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
}
|
||||
|
||||
public bool CheckPacketId(string packetId) {
|
||||
return Text == packetId || Text.StartsWith(packetId + '\t');
|
||||
}
|
||||
|
||||
public string[] SplitText(int expect) {
|
||||
return Text.Split('\t', expect + 1);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,216 +1,87 @@
|
|||
using SharpChat.Flashii;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat {
|
||||
public class BasicUser : IEquatable<BasicUser> {
|
||||
private const int RANK_NO_FLOOD = 9;
|
||||
public class ChatUser : IEquatable<ChatUser> {
|
||||
public const int DEFAULT_SIZE = 30;
|
||||
public const int DEFAULT_MINIMUM_DELAY = 10000;
|
||||
public const int DEFAULT_RISKY_OFFSET = 5;
|
||||
|
||||
public long UserId { get; set; }
|
||||
public string Username { get; set; }
|
||||
public long UserId { get; }
|
||||
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 IsSuper { get; set; }
|
||||
public string NickName { get; set; }
|
||||
public ChatUserStatus Status { get; set; }
|
||||
public string StatusText { get; set; }
|
||||
|
||||
public bool HasFloodProtection
|
||||
=> Rank < RANK_NO_FLOOD;
|
||||
public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
|
||||
|
||||
public bool Equals([AllowNull] BasicUser other)
|
||||
=> UserId == other.UserId;
|
||||
|
||||
public string DisplayName {
|
||||
public string LegacyNameWithStatus {
|
||||
get {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
StringBuilder sb = new();
|
||||
|
||||
if(Status == ChatUserStatus.Away)
|
||||
sb.AppendFormat(@"<{0}>_", StatusMessage.Substring(0, Math.Min(StatusMessage.Length, 5)).ToUpperInvariant());
|
||||
sb.AppendFormat("<{0}>_", StatusText[..Math.Min(StatusText.Length, 5)].ToUpperInvariant());
|
||||
|
||||
if(string.IsNullOrWhiteSpace(Nickname))
|
||||
sb.Append(Username);
|
||||
else {
|
||||
sb.Append('~');
|
||||
sb.Append(Nickname);
|
||||
}
|
||||
sb.Append(LegacyName);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public ChatUser(
|
||||
long userId,
|
||||
string? userName,
|
||||
ChatColour colour,
|
||||
int rank,
|
||||
ChatUserPermissions perms,
|
||||
string? nickName = null,
|
||||
ChatUserStatus status = ChatUserStatus.Online,
|
||||
string? statusText = null,
|
||||
bool isSuper = false
|
||||
) {
|
||||
UserId = userId;
|
||||
UserName = userName ?? throw new ArgumentNullException(nameof(userName));
|
||||
Colour = colour;
|
||||
Rank = rank;
|
||||
Permissions = perms;
|
||||
NickName = nickName ?? string.Empty;
|
||||
Status = status;
|
||||
StatusText = statusText ?? string.Empty;
|
||||
IsSuper = isSuper;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
public bool NameEquals(string? name) {
|
||||
return string.Equals(name, UserName, StringComparison.InvariantCultureIgnoreCase)
|
||||
|| string.Equals(name, NickName, StringComparison.InvariantCultureIgnoreCase)
|
||||
|| string.Equals(name, LegacyName, StringComparison.InvariantCultureIgnoreCase)
|
||||
|| string.Equals(name, LegacyNameWithStatus, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
// 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 override int GetHashCode() {
|
||||
return UserId.GetHashCode();
|
||||
}
|
||||
|
||||
public int SessionCount {
|
||||
get {
|
||||
lock (Sessions)
|
||||
return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Count();
|
||||
}
|
||||
public override bool Equals(object? obj) {
|
||||
return Equals(obj as ChatUser);
|
||||
}
|
||||
|
||||
public IEnumerable<IPAddress> RemoteAddresses {
|
||||
get {
|
||||
lock(Sessions)
|
||||
return Sessions.Select(c => c.RemoteAddress);
|
||||
}
|
||||
public bool Equals(ChatUser? other) {
|
||||
return UserId == other?.UserId;
|
||||
}
|
||||
|
||||
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();
|
||||
public static string GetDMChannelName(ChatUser user1, ChatUser user2) {
|
||||
return user1.UserId < user2.UserId
|
||||
? $"@{user1.UserId}-{user2.UserId}"
|
||||
: $"@{user2.UserId}-{user1.UserId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
8
SharpChat/ChatUserDisconnectReason.cs
Normal file
8
SharpChat/ChatUserDisconnectReason.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace SharpChat {
|
||||
public enum ChatUserDisconnectReason {
|
||||
Leave,
|
||||
TimeOut,
|
||||
Kicked,
|
||||
Flood,
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ namespace SharpChat {
|
|||
public enum ChatUserPermissions : int {
|
||||
KickUser = 0x00000001,
|
||||
BanUser = 0x00000002,
|
||||
SilenceUser = 0x00000004,
|
||||
//SilenceUser = 0x00000004,
|
||||
Broadcast = 0x00000008,
|
||||
SetOwnNickname = 0x00000010,
|
||||
SetOthersNickname = 0x00000020,
|
||||
|
@ -21,5 +21,6 @@ namespace SharpChat {
|
|||
EditOwnMessage = 0x00002000,
|
||||
EditAnyMessage = 0x00004000,
|
||||
SeeIPAddress = 0x00008000,
|
||||
ViewLogs = 0x00040000,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
35
SharpChat/Commands/BanListCommand.cs
Normal file
35
SharpChat/Commands/BanListCommand.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
using SharpChat.Misuzu;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class BanListCommand : IChatCommand {
|
||||
private readonly MisuzuClient Misuzu;
|
||||
|
||||
public BanListCommand(MisuzuClient msz) {
|
||||
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
|
||||
}
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("bans")
|
||||
|| ctx.NameEquals("banned");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
64
SharpChat/Commands/ChannelCreateCommand.cs
Normal file
64
SharpChat/Commands/ChannelCreateCommand.cs
Normal file
|
@ -0,0 +1,64 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class ChannelCreateCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("create");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.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(!ChatChannel.CheckName(createChanName)) {
|
||||
ctx.Chat.SendTo(ctx.User, new ChannelNameFormatErrorPacket());
|
||||
return;
|
||||
}
|
||||
|
||||
if(ctx.Chat.Channels.Any(c => c.NameEquals(createChanName))) {
|
||||
ctx.Chat.SendTo(ctx.User, new ChannelNameInUseErrorPacket(createChanName));
|
||||
return;
|
||||
}
|
||||
|
||||
ChatChannel createChan = new(
|
||||
ctx.User, createChanName,
|
||||
isTemporary: !ctx.User.Can(ChatUserPermissions.SetChannelPermanent),
|
||||
rank: createChanHierarchy
|
||||
);
|
||||
|
||||
ctx.Chat.Channels.Add(createChan);
|
||||
foreach(ChatUser ccu in ctx.Chat.Users.Where(u => u.Rank >= 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));
|
||||
}
|
||||
}
|
||||
}
|
36
SharpChat/Commands/ChannelDeleteCommand.cs
Normal file
36
SharpChat/Commands/ChannelDeleteCommand.cs
Normal file
|
@ -0,0 +1,36 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class ChannelDeleteCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("delchan") || (
|
||||
ctx.NameEquals("delete")
|
||||
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.Args.Any() || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
|
||||
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
|
||||
return;
|
||||
}
|
||||
|
||||
string delChanName = string.Join('_', ctx.Args);
|
||||
ChatChannel? delChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(delChanName));
|
||||
|
||||
if(delChan == null) {
|
||||
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorPacket(delChanName));
|
||||
return;
|
||||
}
|
||||
|
||||
if(!ctx.User.Can(ChatUserPermissions.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));
|
||||
}
|
||||
}
|
||||
}
|
23
SharpChat/Commands/ChannelJoinCommand.cs
Normal file
23
SharpChat/Commands/ChannelJoinCommand.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class ChannelJoinCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("join");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
string joinChanStr = ctx.Args.FirstOrDefault() ?? string.Empty;
|
||||
ChatChannel? joinChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(joinChanStr));
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
25
SharpChat/Commands/ChannelPasswordCommand.cs
Normal file
25
SharpChat/Commands/ChannelPasswordCommand.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using SharpChat.Packet;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class ChannelPasswordCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("pwd")
|
||||
|| ctx.NameEquals("password");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.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());
|
||||
}
|
||||
}
|
||||
}
|
27
SharpChat/Commands/ChannelRankCommand.cs
Normal file
27
SharpChat/Commands/ChannelRankCommand.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class ChannelRankCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("rank")
|
||||
|| ctx.NameEquals("privilege")
|
||||
|| ctx.NameEquals("priv");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.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());
|
||||
}
|
||||
}
|
||||
}
|
83
SharpChat/Commands/KickBanCommand.cs
Normal file
83
SharpChat/Commands/KickBanCommand.cs
Normal file
|
@ -0,0 +1,83 @@
|
|||
using SharpChat.Misuzu;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class KickBanCommand : IChatCommand {
|
||||
private readonly MisuzuClient Misuzu;
|
||||
|
||||
public KickBanCommand(MisuzuClient msz) {
|
||||
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
|
||||
}
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("kick")
|
||||
|| ctx.NameEquals("ban");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
bool isBanning = ctx.NameEquals("ban");
|
||||
|
||||
if(!ctx.User.Can(isBanning ? ChatUserPermissions.BanUser : ChatUserPermissions.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;
|
||||
ChatUser? banUser = null;
|
||||
|
||||
if(string.IsNullOrEmpty(banUserTarget) || (banUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(banUserTarget))) == 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(banUser.LegacyName));
|
||||
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(banUser.LegacyName));
|
||||
return;
|
||||
}
|
||||
|
||||
await Misuzu.CreateBanAsync(
|
||||
userId, userIp,
|
||||
ctx.User.UserId.ToString(), ctx.Connection.RemoteAddress.ToString(),
|
||||
duration, banReason
|
||||
);
|
||||
|
||||
ctx.Chat.BanUser(banUser, duration);
|
||||
}).Wait();
|
||||
}
|
||||
}
|
||||
}
|
30
SharpChat/Commands/MessageActionCommand.cs
Normal file
30
SharpChat/Commands/MessageActionCommand.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
using SharpChat.Events;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class MessageActionCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("action")
|
||||
|| ctx.NameEquals("me");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext 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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
28
SharpChat/Commands/MessageBroadcastCommand.cs
Normal file
28
SharpChat/Commands/MessageBroadcastCommand.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using SharpChat.Events;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class MessageBroadcastCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("say")
|
||||
|| ctx.NameEquals("broadcast");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
41
SharpChat/Commands/MessageDeleteCommand.cs
Normal file
41
SharpChat/Commands/MessageDeleteCommand.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
using SharpChat.EventStorage;
|
||||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands
|
||||
{
|
||||
public class MessageDeleteCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("delmsg") || (
|
||||
ctx.NameEquals("delete")
|
||||
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
bool deleteAnyMessage = ctx.User.Can(ChatUserPermissions.DeleteAnyMessage);
|
||||
|
||||
if(!deleteAnyMessage && !ctx.User.Can(ChatUserPermissions.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));
|
||||
}
|
||||
}
|
||||
}
|
40
SharpChat/Commands/MessageWhisperCommand.cs
Normal file
40
SharpChat/Commands/MessageWhisperCommand.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
using SharpChat.Events;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class MessageWhisperCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("whisper")
|
||||
|| ctx.NameEquals("msg");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(ctx.Args.Length < 2) {
|
||||
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorPacket());
|
||||
return;
|
||||
}
|
||||
|
||||
string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
|
||||
ChatUser? whisperUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(whisperUserStr));
|
||||
|
||||
if(whisperUser == null) {
|
||||
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorPacket(whisperUserStr));
|
||||
return;
|
||||
}
|
||||
|
||||
if(whisperUser == ctx.User)
|
||||
return;
|
||||
|
||||
ctx.Chat.DispatchEvent(new MessageCreateEvent(
|
||||
SharpId.Next(),
|
||||
ChatUser.GetDMChannelName(ctx.User, whisperUser),
|
||||
ctx.User,
|
||||
DateTimeOffset.Now,
|
||||
string.Join(' ', ctx.Args.Skip(1)),
|
||||
true, false, false
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
51
SharpChat/Commands/PardonAddressCommand.cs
Normal file
51
SharpChat/Commands/PardonAddressCommand.cs
Normal file
|
@ -0,0 +1,51 @@
|
|||
using SharpChat.Misuzu;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class PardonAddressCommand : IChatCommand {
|
||||
private readonly MisuzuClient Misuzu;
|
||||
|
||||
public PardonAddressCommand(MisuzuClient msz) {
|
||||
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
|
||||
}
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("pardonip")
|
||||
|| ctx.NameEquals("unbanip");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
58
SharpChat/Commands/PardonUserCommand.cs
Normal file
58
SharpChat/Commands/PardonUserCommand.cs
Normal file
|
@ -0,0 +1,58 @@
|
|||
using SharpChat.Misuzu;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class PardonUserCommand : IChatCommand {
|
||||
private readonly MisuzuClient Misuzu;
|
||||
|
||||
public PardonUserCommand(MisuzuClient msz) {
|
||||
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
|
||||
}
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("pardon")
|
||||
|| ctx.NameEquals("unban");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
|
||||
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;
|
||||
}
|
||||
|
||||
ChatUser? unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget));
|
||||
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId)) {
|
||||
unbanUserTargetIsName = false;
|
||||
unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == 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();
|
||||
}
|
||||
}
|
||||
}
|
37
SharpChat/Commands/ShutdownRestartCommand.cs
Normal file
37
SharpChat/Commands/ShutdownRestartCommand.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class ShutdownRestartCommand : IChatCommand {
|
||||
private readonly ManualResetEvent WaitHandle;
|
||||
private readonly Func<bool> ShutdownCheck;
|
||||
|
||||
public ShutdownRestartCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) {
|
||||
WaitHandle = waitHandle ?? throw new ArgumentNullException(nameof(waitHandle));
|
||||
ShutdownCheck = shutdownCheck ?? throw new ArgumentNullException(nameof(shutdownCheck));
|
||||
}
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("shutdown")
|
||||
|| ctx.NameEquals("restart");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(ctx.User.UserId != 1) {
|
||||
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
|
||||
return;
|
||||
}
|
||||
|
||||
if(!ShutdownCheck())
|
||||
return;
|
||||
|
||||
if(ctx.NameEquals("restart"))
|
||||
foreach(ChatConnection conn in ctx.Chat.Connections)
|
||||
conn.PrepareForRestart();
|
||||
|
||||
ctx.Chat.Update();
|
||||
WaitHandle?.Set();
|
||||
}
|
||||
}
|
||||
}
|
30
SharpChat/Commands/UserAFKCommand.cs
Normal file
30
SharpChat/Commands/UserAFKCommand.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class UserAFKCommand : IChatCommand {
|
||||
private const string DEFAULT = "AFK";
|
||||
private const int MAX_LENGTH = 5;
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("afk");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext 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: ChatUserStatus.Away,
|
||||
statusText: statusText
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
54
SharpChat/Commands/UserNickCommand.cs
Normal file
54
SharpChat/Commands/UserNickCommand.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class UserNickCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("nick");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
bool setOthersNick = ctx.User.Can(ChatUserPermissions.SetOthersNickname);
|
||||
|
||||
if(!setOthersNick && !ctx.User.Can(ChatUserPermissions.SetOwnNickname)) {
|
||||
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
|
||||
return;
|
||||
}
|
||||
|
||||
ChatUser? targetUser = null;
|
||||
int offset = 0;
|
||||
|
||||
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
|
||||
targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == 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.Any(u => u.NameEquals(nickStr))) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
40
SharpChat/Commands/WhoCommand.cs
Normal file
40
SharpChat/Commands/WhoCommand.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class WhoCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("who");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
string? channelName = ctx.Args.FirstOrDefault();
|
||||
|
||||
if(string.IsNullOrEmpty(channelName)) {
|
||||
ctx.Chat.SendTo(ctx.User, new WhoServerResponsePacket(
|
||||
ctx.Chat.Users.Select(u => u.LegacyName).ToArray(),
|
||||
ctx.User.LegacyName
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
ChatChannel? channel = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(channelName));
|
||||
|
||||
if(channel == null) {
|
||||
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorPacket(channelName));
|
||||
return;
|
||||
}
|
||||
|
||||
if(channel.Rank > ctx.User.Rank || (channel.HasPassword && !ctx.User.Can(ChatUserPermissions.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 => user.LegacyName).ToArray(),
|
||||
ctx.User.LegacyName
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
30
SharpChat/Commands/WhoisCommand.cs
Normal file
30
SharpChat/Commands/WhoisCommand.cs
Normal file
|
@ -0,0 +1,30 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class WhoisCommand : IChatCommand {
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("ip")
|
||||
|| ctx.NameEquals("whois");
|
||||
}
|
||||
|
||||
public void Dispatch(ChatCommandContext ctx) {
|
||||
if(!ctx.User.Can(ChatUserPermissions.SeeIPAddress)) {
|
||||
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorPacket(ctx.Name));
|
||||
return;
|
||||
}
|
||||
|
||||
string ipUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
|
||||
ChatUser? ipUser;
|
||||
|
||||
if(string.IsNullOrWhiteSpace(ipUserStr) || (ipUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(ipUserStr))) == 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()));
|
||||
}
|
||||
}
|
||||
}
|
47
SharpChat/Config/CachedValue.cs
Normal file
47
SharpChat/Config/CachedValue.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
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) {
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Lifetime = lifetime;
|
||||
Fallback = fallback;
|
||||
if(string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("Name cannot be empty.", nameof(name));
|
||||
}
|
||||
|
||||
public void Refresh() {
|
||||
LastRead = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
return Value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
16
SharpChat/Config/ConfigExceptions.cs
Normal file
16
SharpChat/Config/ConfigExceptions.cs
Normal 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) { }
|
||||
}
|
||||
}
|
35
SharpChat/Config/IConfig.cs
Normal file
35
SharpChat/Config/IConfig.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
45
SharpChat/Config/ScopedConfig.cs
Normal file
45
SharpChat/Config/ScopedConfig.cs
Normal file
|
@ -0,0 +1,45 @@
|
|||
using System;
|
||||
|
||||
namespace SharpChat.Config {
|
||||
public class ScopedConfig : IConfig {
|
||||
private IConfig Config { get; }
|
||||
private string Prefix { get; }
|
||||
|
||||
public ScopedConfig(IConfig config, string prefix) {
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
Prefix = prefix ?? throw new ArgumentNullException(nameof(prefix));
|
||||
if(string.IsNullOrWhiteSpace(prefix))
|
||||
throw new ArgumentException("Prefix must exist.", nameof(prefix));
|
||||
if(Prefix[^1] != ':')
|
||||
Prefix += ':';
|
||||
}
|
||||
|
||||
private string GetName(string name) {
|
||||
return Prefix + name;
|
||||
}
|
||||
|
||||
public string? ReadValue(string name, string? fallback = null) {
|
||||
return Config.ReadValue(GetName(name), fallback);
|
||||
}
|
||||
|
||||
public T? ReadValue<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);
|
||||
}
|
||||
}
|
||||
}
|
112
SharpChat/Config/StreamConfig.cs
Normal file
112
SharpChat/Config/StreamConfig.cs
Normal 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 ?? throw new ArgumentNullException(nameof(stream));
|
||||
if(!Stream.CanRead)
|
||||
throw new ArgumentException("Provided stream must be readable.", nameof(stream));
|
||||
if(!Stream.CanSeek)
|
||||
throw new ArgumentException("Provided stream must be seekable.", nameof(stream));
|
||||
StreamReader = new StreamReader(stream, new UTF8Encoding(false), false);
|
||||
Lock = new Mutex();
|
||||
}
|
||||
|
||||
public string? ReadValue(string name, string? fallback = null) {
|
||||
if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong
|
||||
throw new ConfigLockException();
|
||||
|
||||
try {
|
||||
Stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
string? line;
|
||||
while((line = StreamReader.ReadLine()) != null) {
|
||||
if(string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
line = line.TrimStart();
|
||||
if(line.StartsWith(";") || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if(parts.Length < 2 || !string.Equals(parts[0], name))
|
||||
continue;
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
} finally {
|
||||
Lock.ReleaseMutex();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public T? ReadValue<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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
36
SharpChat/EventStorage/IEventStorage.cs
Normal file
36
SharpChat/EventStorage/IEventStorage.cs
Normal 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, ChatColour senderColour, int senderRank, string? senderNick, ChatUserPermissions senderPerms,
|
||||
object? data = null,
|
||||
StoredEventFlags flags = StoredEventFlags.None
|
||||
);
|
||||
void AddEvent(
|
||||
long id, string type,
|
||||
string? channelName,
|
||||
long senderId, string? senderName, ChatColour senderColour, int senderRank, string? senderNick, ChatUserPermissions senderPerms,
|
||||
object? data = null,
|
||||
StoredEventFlags flags = StoredEventFlags.None
|
||||
);
|
||||
|
||||
long AddEvent(string type, ChatUser user, ChatChannel 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);
|
||||
}
|
||||
}
|
183
SharpChat/EventStorage/MariaDBEventStorage.cs
Normal file
183
SharpChat/EventStorage/MariaDBEventStorage.cs
Normal file
|
@ -0,0 +1,183 @@
|
|||
using MySqlConnector;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace SharpChat.EventStorage
|
||||
{
|
||||
public partial class MariaDBEventStorage : IEventStorage {
|
||||
private string ConnectionString { get; }
|
||||
|
||||
public MariaDBEventStorage(string connString) {
|
||||
ConnectionString = connString ?? throw new ArgumentNullException(nameof(connString));
|
||||
}
|
||||
|
||||
public void AddEvent(
|
||||
long id, string type,
|
||||
object? data = null,
|
||||
StoredEventFlags flags = StoredEventFlags.None
|
||||
) {
|
||||
AddEvent(id, type, null, 0, null, ChatColour.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, ChatColour.None, 0, null, 0, data, flags);
|
||||
}
|
||||
|
||||
public void AddEvent(
|
||||
long id, string type,
|
||||
long senderId, string? senderName, ChatColour senderColour, int senderRank, string? senderNick, ChatUserPermissions 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, ChatColour senderColour, int senderRank, string? senderNick, ChatUserPermissions senderPerms,
|
||||
object? data = null,
|
||||
StoredEventFlags flags = StoredEventFlags.None
|
||||
) {
|
||||
if(type == null)
|
||||
throw new ArgumentNullException(nameof(type));
|
||||
|
||||
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, ChatUser user, ChatChannel channel, object? data = null, StoredEventFlags flags = StoredEventFlags.None) {
|
||||
if(type == null)
|
||||
throw new ArgumentNullException(nameof(type));
|
||||
|
||||
long id = SharpId.Next();
|
||||
|
||||
AddEvent(
|
||||
id, type,
|
||||
channel?.Name,
|
||||
user?.UserId ?? 0,
|
||||
user?.UserName ?? string.Empty,
|
||||
user?.Colour ?? ChatColour.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 ChatUser(
|
||||
reader.GetInt64("event_sender"),
|
||||
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
|
||||
ChatColour.FromMisuzu(reader.GetInt32("event_sender_colour")),
|
||||
reader.GetInt32("event_sender_rank"),
|
||||
(ChatUserPermissions)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) {
|
||||
if(evt == null)
|
||||
throw new ArgumentNullException(nameof(evt));
|
||||
RunCommand(
|
||||
"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
|
||||
new MySqlParameter("id", evt.Id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
82
SharpChat/EventStorage/MariaDBEventStorage_Database.cs
Normal file
82
SharpChat/EventStorage/MariaDBEventStorage_Database.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
77
SharpChat/EventStorage/MariaDBEventStorage_Migrations.cs
Normal file
77
SharpChat/EventStorage/MariaDBEventStorage_Migrations.cs
Normal 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;"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
14
SharpChat/EventStorage/StoredEventFlags.cs
Normal file
14
SharpChat/EventStorage/StoredEventFlags.cs
Normal 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,
|
||||
}
|
||||
}
|
35
SharpChat/EventStorage/StoredEventInfo.cs
Normal file
35
SharpChat/EventStorage/StoredEventInfo.cs
Normal 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 ChatUser? 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,
|
||||
ChatUser? 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;
|
||||
}
|
||||
}
|
||||
}
|
100
SharpChat/EventStorage/VirtualEventStorage.cs
Normal file
100
SharpChat/EventStorage/VirtualEventStorage.cs
Normal file
|
@ -0,0 +1,100 @@
|
|||
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, ChatColour.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, ChatColour.None, 0, null, 0, data, flags);
|
||||
}
|
||||
|
||||
public void AddEvent(
|
||||
long id, string type,
|
||||
long senderId, string? senderName, ChatColour senderColour, int senderRank, string? senderNick, ChatUserPermissions 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, ChatColour senderColour, int senderRank, string? senderNick, ChatUserPermissions senderPerms,
|
||||
object? data = null,
|
||||
StoredEventFlags flags = StoredEventFlags.None
|
||||
) {
|
||||
if(type == null)
|
||||
throw new ArgumentNullException(nameof(type));
|
||||
|
||||
// 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 ChatUser(
|
||||
senderId,
|
||||
senderName,
|
||||
senderColour,
|
||||
senderRank,
|
||||
senderPerms,
|
||||
senderNick
|
||||
), DateTimeOffset.Now, null, channelName, hack, flags));
|
||||
}
|
||||
|
||||
public long AddEvent(string type, ChatUser user, ChatChannel channel, object? data = null, StoredEventFlags flags = StoredEventFlags.None) {
|
||||
if(type == null)
|
||||
throw new ArgumentNullException(nameof(type));
|
||||
|
||||
long id = SharpId.Next();
|
||||
|
||||
AddEvent(
|
||||
id, type,
|
||||
channel?.Name,
|
||||
user?.UserId ?? 0,
|
||||
user?.UserName,
|
||||
user?.Colour ?? ChatColour.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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace SharpChat.Events {
|
||||
[Flags]
|
||||
public enum ChatMessageFlags {
|
||||
None = 0,
|
||||
Action = 1,
|
||||
Broadcast = 1 << 1,
|
||||
Log = 1 << 2,
|
||||
Private = 1 << 3,
|
||||
}
|
||||
|
||||
public interface IChatEvent {
|
||||
DateTimeOffset DateTime { get; set; }
|
||||
BasicUser Sender { get; set; }
|
||||
IPacketTarget Target { get; set; }
|
||||
string TargetName { get; set; }
|
||||
ChatMessageFlags Flags { get; set; }
|
||||
long SequenceId { get; set; }
|
||||
}
|
||||
|
||||
public interface IChatMessage : IChatEvent {
|
||||
string Text { get; }
|
||||
}
|
||||
}
|
||||
|
|
94
SharpChat/Events/MessageCreateEvent.cs
Normal file
94
SharpChat/Events/MessageCreateEvent.cs
Normal 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 ChatColour SenderColour { get; }
|
||||
public int SenderRank { get; }
|
||||
public string? SenderNickName { get; }
|
||||
public ChatUserPermissions 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,
|
||||
ChatColour senderColour,
|
||||
int senderRank,
|
||||
string? senderNickName,
|
||||
ChatUserPermissions 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,
|
||||
ChatUser? sender,
|
||||
DateTimeOffset msgCreated,
|
||||
string msgText,
|
||||
bool isPrivate,
|
||||
bool isAction,
|
||||
bool isBroadcast
|
||||
) : this(
|
||||
msgId,
|
||||
channelName,
|
||||
sender?.UserId ?? -1,
|
||||
sender?.UserName ?? null,
|
||||
sender?.Colour ?? ChatColour.None,
|
||||
sender?.Rank ?? 0,
|
||||
sender?.NickName ?? null,
|
||||
sender?.Permissions ?? 0,
|
||||
msgCreated,
|
||||
msgText,
|
||||
isPrivate,
|
||||
isAction,
|
||||
isBroadcast
|
||||
) { }
|
||||
|
||||
public MessageCreateEvent(
|
||||
long msgId,
|
||||
ChatChannel channel,
|
||||
ChatUser sender,
|
||||
DateTimeOffset msgCreated,
|
||||
string msgText,
|
||||
bool isPrivate,
|
||||
bool isAction,
|
||||
bool isBroadcast
|
||||
) : this(
|
||||
msgId,
|
||||
channel?.Name ?? null,
|
||||
sender,
|
||||
msgCreated,
|
||||
msgText,
|
||||
isPrivate,
|
||||
isAction,
|
||||
isBroadcast
|
||||
) { }
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
using SharpChat.Events;
|
||||
|
||||
namespace SharpChat {
|
||||
namespace SharpChat {
|
||||
public interface IChatCommand {
|
||||
bool IsMatch(string name);
|
||||
IChatMessage Dispatch(IChatCommandContext context);
|
||||
bool IsMatch(ChatCommandContext ctx);
|
||||
void Dispatch(ChatCommandContext ctx);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
6
SharpChat/IChatPacketHandler.cs
Normal file
6
SharpChat/IChatPacketHandler.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace SharpChat {
|
||||
public interface IChatPacketHandler {
|
||||
bool IsMatch(ChatPacketHandlerContext ctx);
|
||||
void Handle(ChatPacketHandlerContext ctx);
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
namespace SharpChat {
|
||||
public interface IPacketTarget {
|
||||
string TargetName { get; }
|
||||
void Send(IServerPacket packet);
|
||||
}
|
||||
}
|
|
@ -1,22 +1,17 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpChat {
|
||||
namespace SharpChat {
|
||||
public interface IServerPacket {
|
||||
long SequenceId { get; }
|
||||
IEnumerable<string> Pack();
|
||||
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);
|
||||
SequenceId = sequenceId > 0 ? sequenceId : SharpId.Next();
|
||||
}
|
||||
|
||||
public abstract IEnumerable<string> Pack();
|
||||
public abstract string Pack();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
32
SharpChat/Misuzu/MisuzuAuthInfo.cs
Normal file
32
SharpChat/Misuzu/MisuzuAuthInfo.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SharpChat.Misuzu {
|
||||
public class MisuzuAuthInfo {
|
||||
[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; }
|
||||
|
||||
public ChatColour Colour => ChatColour.FromMisuzu(ColourRaw);
|
||||
|
||||
[JsonPropertyName("hierarchy")]
|
||||
public int Rank { get; set; }
|
||||
|
||||
[JsonPropertyName("perms")]
|
||||
public ChatUserPermissions Permissions { get; set; }
|
||||
|
||||
[JsonPropertyName("super")]
|
||||
public bool IsSuper { get; set; }
|
||||
}
|
||||
}
|
32
SharpChat/Misuzu/MisuzuBanInfo.cs
Normal file
32
SharpChat/Misuzu/MisuzuBanInfo.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SharpChat.Misuzu {
|
||||
public class MisuzuBanInfo {
|
||||
[JsonPropertyName("is_ban")]
|
||||
public bool IsBanned { get; set; }
|
||||
|
||||
[JsonPropertyName("user_id")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("ip_addr")]
|
||||
public string? RemoteAddress { get; set; }
|
||||
|
||||
[JsonPropertyName("is_perma")]
|
||||
public bool IsPermanent { get; set; }
|
||||
|
||||
[JsonPropertyName("expires")]
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
// only populated in list request
|
||||
[JsonPropertyName("user_name")]
|
||||
public string? UserName { get; set; }
|
||||
|
||||
[JsonPropertyName("user_colour")]
|
||||
public int UserColourRaw { get; set; }
|
||||
|
||||
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
|
||||
public ChatColour UserColour => ChatColour.FromMisuzu(UserColourRaw);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue