Imported stable branch.

This commit is contained in:
flash 2022-08-30 17:00:58 +02:00
commit 4ceffeb48d
94 changed files with 8619 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto

215
.gitignore vendored Normal file
View File

@ -0,0 +1,215 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
# Visual Studio 2015 cache/options directory
.vs/
# JetBrains Rider cache/options directory
.idea/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
## TODO: Comment the next line if you want to checkin your
## web deploy settings but do note that will include unencrypted
## passwords
#*.pubxml
*.publishproj
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
[Ss]tyle[Cc]op.*
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# LightSwitch generated files
GeneratedArtifacts/
_Pvt_Extensions/
ModelManifest.xml

7
Hamakaze/Hamakaze.csproj Normal file
View File

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

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Hamakaze.Headers {
public class HttpAcceptEncodingHeader : HttpHeader {
public const string NAME = @"Accept-Encoding";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public HttpEncoding[] Encodings { get; }
public HttpAcceptEncodingHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) { }
public HttpAcceptEncodingHeader(string[] encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse)
) {}
public HttpAcceptEncodingHeader(IEnumerable<HttpEncoding> encodings) {
Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray();
}
}
}

View File

@ -0,0 +1,17 @@
using System;
namespace Hamakaze.Headers {
public class HttpConnectionHeader : HttpHeader {
public const string NAME = @"Connection";
public override string Name => NAME;
public override object Value { get; }
public const string CLOSE = @"close";
public const string KEEP_ALIVE = @"keep-alive";
public HttpConnectionHeader(string mode) {
Value = mode ?? throw new ArgumentNullException(nameof(mode));
}
}
}

View File

@ -0,0 +1,20 @@
using System;
namespace Hamakaze.Headers {
public class HttpContentEncodingHeader : HttpHeader {
public const string NAME = @"Content-Encoding";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public string[] Encodings { get; }
public HttpContentEncodingHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) { }
public HttpContentEncodingHeader(string[] encodings) {
Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings));
}
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.IO;
namespace Hamakaze.Headers {
public class HttpContentLengthHeader : HttpHeader {
public const string NAME = @"Content-Length";
public override string Name => NAME;
public override object Value => Stream?.Length ?? Length;
private Stream Stream { get; }
private long Length { get; }
public HttpContentLengthHeader(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
if(!stream.CanRead || !stream.CanSeek)
throw new ArgumentException(@"Body must readable and seekable.", nameof(stream));
}
public HttpContentLengthHeader(long length) {
Length = length;
}
public HttpContentLengthHeader(string length) {
if(!long.TryParse(length, out long ll))
throw new ArgumentException(@"Invalid length value.", nameof(length));
Length = ll;
}
}
}

View File

@ -0,0 +1,20 @@
using System;
namespace Hamakaze.Headers {
public class HttpContentTypeHeader : HttpHeader {
public const string NAME = @"Content-Type";
public override string Name => NAME;
public override object Value => MediaType.ToString();
public HttpMediaType MediaType { get; }
public HttpContentTypeHeader(string mediaType) {
MediaType = HttpMediaType.Parse(mediaType ?? throw new ArgumentNullException(nameof(mediaType)));
}
public HttpContentTypeHeader(HttpMediaType mediaType) {
MediaType = mediaType;
}
}
}

View File

@ -0,0 +1,13 @@
using System;
namespace Hamakaze.Headers {
public class HttpCustomHeader : HttpHeader {
public override string Name { get; }
public override object Value { get; }
public HttpCustomHeader(string name, object value) {
Name = NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
Value = value;
}
}
}

View File

@ -0,0 +1,18 @@
using System;
using System.Globalization;
namespace Hamakaze.Headers {
public class HttpDateHeader : HttpHeader {
public const string NAME = @"Date";
public override string Name => NAME;
public override object Value { get; }
public DateTimeOffset DateTime { get; }
public HttpDateHeader(string dateString) {
Value = dateString ?? throw new ArgumentNullException(nameof(dateString));
DateTime = DateTimeOffset.ParseExact(dateString, @"r", CultureInfo.InvariantCulture);
}
}
}

View File

@ -0,0 +1,41 @@
using System;
using System.Globalization;
namespace Hamakaze.Headers {
public abstract class HttpHeader {
public abstract string Name { get; }
public abstract object Value { get; }
public override string ToString() {
return string.Format(@"{0}: {1}", Name, Value);
}
public static string NormaliseName(string name) {
if(string.IsNullOrWhiteSpace(name))
return string.Empty;
string[] parts = name.ToLowerInvariant().Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for(int i = 0; i < parts.Length; ++i)
parts[i] = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(parts[i]);
return string.Join('-', parts);
}
public static HttpHeader Create(string name, object value) {
return name switch {
HttpTeHeader.NAME => new HttpTeHeader(value.ToString()),
HttpDateHeader.NAME => new HttpDateHeader(value.ToString()),
HttpHostHeader.NAME => new HttpHostHeader(value.ToString()),
HttpServerHeader.NAME => new HttpServerHeader(value.ToString()),
HttpUserAgentHeader.NAME => new HttpUserAgentHeader(value.ToString()),
HttpKeepAliveHeader.NAME => new HttpKeepAliveHeader(value.ToString()),
HttpConnectionHeader.NAME => new HttpConnectionHeader(value.ToString()),
HttpContentTypeHeader.NAME => new HttpContentTypeHeader(value.ToString()),
HttpContentLengthHeader.NAME => new HttpContentLengthHeader(value.ToString()),
HttpAcceptEncodingHeader.NAME => new HttpAcceptEncodingHeader(value.ToString()),
HttpContentEncodingHeader.NAME => new HttpContentEncodingHeader(value.ToString()),
HttpTransferEncodingHeader.NAME => new HttpTransferEncodingHeader(value.ToString()),
_ => new HttpCustomHeader(name, value),
};
}
}
}

View File

@ -0,0 +1,37 @@
using System;
using System.Linq;
using System.Text;
namespace Hamakaze.Headers {
public class HttpHostHeader : HttpHeader {
public const string NAME = @"Host";
public override string Name => NAME;
public override object Value {
get {
StringBuilder sb = new();
sb.Append(Host);
if(Port != -1)
sb.AppendFormat(@":{0}", Port);
return sb.ToString();
}
}
public string Host { get; }
public int Port { get; }
public bool IsSecure { get; }
public HttpHostHeader(string host, int port) {
Host = host;
Port = port;
}
public HttpHostHeader(string hostAndPort) {
string[] parts = hostAndPort.Split(':', 2, StringSplitOptions.TrimEntries);
Host = parts.ElementAtOrDefault(0) ?? throw new ArgumentNullException(nameof(hostAndPort));
if(!ushort.TryParse(parts.ElementAtOrDefault(1), out ushort port))
throw new FormatException(@"Host is not in valid format.");
Port = port;
}
}
}

View File

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace Hamakaze.Headers {
public class HttpKeepAliveHeader : HttpHeader {
public const string NAME = @"Keep-Alive";
public override string Name => NAME;
public override object Value {
get {
List<string> parts = new();
if(MaxIdle != TimeSpan.MaxValue)
parts.Add(string.Format(@"timeout={0}", MaxIdle.TotalSeconds));
if(MaxRequests >= 0)
parts.Add(string.Format(@"max={0}", MaxRequests));
return string.Join(@", ", parts);
}
}
public TimeSpan MaxIdle { get; } = TimeSpan.MaxValue;
public int MaxRequests { get; } = -1;
public HttpKeepAliveHeader(string value) {
IEnumerable<string> kvps = (value ?? throw new ArgumentNullException(nameof(value))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach(string kvp in kvps) {
string[] parts = kvp.Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if(parts[0] == @"timeout" && int.TryParse(parts[1], out int timeout))
MaxIdle = TimeSpan.FromSeconds(timeout);
else if(parts[0] == @"max" && int.TryParse(parts[1], out int max))
MaxRequests = max;
}
}
}
}

View File

@ -0,0 +1,14 @@
using System;
namespace Hamakaze.Headers {
public class HttpServerHeader : HttpHeader {
public const string NAME = @"Server";
public override string Name => NAME;
public override object Value { get; }
public HttpServerHeader(string server) {
Value = server ?? throw new ArgumentNullException(nameof(server));
}
}
}

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Hamakaze.Headers {
public class HttpTeHeader : HttpHeader {
public const string NAME = @"TE";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public HttpEncoding[] Encodings { get; }
public HttpTeHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) { }
public HttpTeHeader(string[] encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse)
) { }
public HttpTeHeader(IEnumerable<HttpEncoding> encodings) {
Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray();
}
}
}

View File

@ -0,0 +1,20 @@
using System;
namespace Hamakaze.Headers {
public class HttpTransferEncodingHeader : HttpHeader {
public const string NAME = @"Transfer-Encoding";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public string[] Encodings { get; }
public HttpTransferEncodingHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) {}
public HttpTransferEncodingHeader(string[] encodings) {
Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings));
}
}
}

View File

@ -0,0 +1,20 @@
using System;
namespace Hamakaze.Headers {
public class HttpUserAgentHeader : HttpHeader {
public const string NAME = @"User-Agent";
public override string Name => NAME;
public override object Value { get; }
public HttpUserAgentHeader(string userAgent) {
if(userAgent == null)
throw new ArgumentNullException(nameof(userAgent));
if(string.IsNullOrWhiteSpace(userAgent) || userAgent.Equals(HttpClient.USER_AGENT))
Value = HttpClient.USER_AGENT;
else
Value = string.Format(@"{0} {1}", userAgent, HttpClient.USER_AGENT);
}
}
}

118
Hamakaze/HttpClient.cs Normal file
View File

@ -0,0 +1,118 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
namespace Hamakaze {
public class HttpClient : IDisposable {
public const string PRODUCT_STRING = @"HMKZ";
public const string VERSION_MAJOR = @"1";
public const string VERSION_MINOR = @"0";
public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR;
private static HttpClient InstanceValue { get; set; }
public static HttpClient Instance {
get {
if(InstanceValue == null)
InstanceValue = new HttpClient();
return InstanceValue;
}
}
private HttpConnectionManager Connections { get; }
private HttpTaskManager Tasks { get; }
public string DefaultUserAgent { get; set; } = USER_AGENT;
public bool ReuseConnections { get; set; } = true;
public IEnumerable<HttpEncoding> AcceptedEncodings { get; set; } = new[] { HttpEncoding.GZip, HttpEncoding.Deflate, HttpEncoding.Brotli };
public HttpClient() {
Connections = new HttpConnectionManager();
Tasks = new HttpTaskManager();
}
public HttpTask CreateTask(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
if(request == null)
throw new ArgumentNullException(nameof(request));
if(string.IsNullOrWhiteSpace(request.UserAgent))
request.UserAgent = DefaultUserAgent;
if(!request.HasHeader(HttpAcceptEncodingHeader.NAME))
request.AcceptedEncodings = AcceptedEncodings;
request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE;
HttpTask task = new(Connections, request, disposeRequest, disposeResponse);
if(onComplete != null)
task.OnComplete += onComplete;
if(onError != null)
task.OnError += onError;
if(onCancel != null)
task.OnCancel += onCancel;
if(onDownloadProgress != null)
task.OnDownloadProgress += onDownloadProgress;
if(onUploadProgress != null)
task.OnUploadProgress += onUploadProgress;
if(onStateChange != null)
task.OnStateChange += onStateChange;
return task;
}
public void RunTask(HttpTask task) {
Tasks.RunTask(task);
}
public void SendRequest(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse));
}
public static void Send(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
Instance.SendRequest(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse);
}
private bool IsDisposed;
~HttpClient()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Tasks.Dispose();
Connections.Dispose();
}
}
}

View File

@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading;
namespace Hamakaze {
public class HttpConnection : IDisposable {
public IPEndPoint EndPoint { get; }
public Stream Stream { get; }
public Socket Socket { get; }
public NetworkStream NetworkStream { get; }
public SslStream SslStream { get; }
public string Host { get; }
public bool IsSecure { get; }
public bool HasTimedOut => MaxRequests == 0 || (DateTimeOffset.Now - LastOperation) > MaxIdle;
public int MaxRequests { get; set; } = -1;
public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue;
public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now;
public bool InUse { get; private set; }
public HttpConnection(string host, IPEndPoint endPoint, bool secure) {
Host = host ?? throw new ArgumentNullException(nameof(host));
EndPoint = endPoint ?? throw new ArgumentNullException(nameof(endPoint));
IsSecure = secure;
if(endPoint.AddressFamily is not AddressFamily.InterNetwork and not AddressFamily.InterNetworkV6)
throw new ArgumentException(@"Address must be an IPv4 or IPv6 address.", nameof(endPoint));
Socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) {
NoDelay = true,
Blocking = true,
};
Socket.Connect(endPoint);
NetworkStream = new NetworkStream(Socket, true);
if(IsSecure) {
SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null);
Stream = SslStream;
SslStream.AuthenticateAsClient(Host, null, SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, true);
} else
Stream = NetworkStream;
}
public void MarkUsed() {
LastOperation = DateTimeOffset.Now;
if(MaxRequests > 0)
--MaxRequests;
}
public bool Acquire() {
return !InUse && (InUse = true);
}
public void Release() {
InUse = false;
}
private bool IsDisposed;
~HttpConnection()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Stream.Dispose();
}
}
}

View File

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
namespace Hamakaze {
public class HttpConnectionManager : IDisposable {
private List<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();
}
}
}

69
Hamakaze/HttpEncoding.cs Normal file
View File

@ -0,0 +1,69 @@
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);
}
}
}

40
Hamakaze/HttpException.cs Normal file
View File

@ -0,0 +1,40 @@
using System;
namespace Hamakaze {
public class HttpException : Exception {
public HttpException(string message) : base(message) { }
}
public class HttpConnectionManagerException : HttpException {
public HttpConnectionManagerException(string message) : base(message) { }
}
public class HttpConnectionManagerLockException : HttpConnectionManagerException {
public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { }
}
public class HttpTaskException : HttpException {
public HttpTaskException(string message) : base(message) { }
}
public class HttpTaskAlreadyStartedException : HttpTaskException {
public HttpTaskAlreadyStartedException() : base(@"Task has already started.") { }
}
public class HttpTaskInvalidStateException : HttpTaskException {
public HttpTaskInvalidStateException() : base(@"Task has ended up in an invalid state.") { }
}
public class HttpTaskNoAddressesException : HttpTaskException {
public HttpTaskNoAddressesException() : base(@"Could not find any addresses for this host.") { }
}
public class HttpTaskNoConnectionException : HttpTaskException {
public HttpTaskNoConnectionException() : base(@"Was unable to create a connection with this host.") { }
}
public class HttpTaskRequestFailedException : HttpTaskException {
public HttpTaskRequestFailedException() : base(@"Request failed for unknown reasons.") { }
}
public class HttpTaskManagerException : HttpException {
public HttpTaskManagerException(string message) : base(message) { }
}
public class HttpTaskManagerLockException : HttpTaskManagerException {
public HttpTaskManagerLockException() : base(@"Failed to reserve a thread.") { }
}
}

159
Hamakaze/HttpMediaType.cs Normal file
View File

@ -0,0 +1,159 @@
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);
}
}
}
}

46
Hamakaze/HttpMessage.cs Normal file
View File

@ -0,0 +1,46 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Hamakaze {
public abstract class HttpMessage : IDisposable {
public abstract string ProtocolVersion { get; }
public abstract IEnumerable<HttpHeader> Headers { get; }
public abstract Stream Body { get; }
public virtual bool HasBody => Body != null;
protected bool OwnsBodyStream { get; set; }
public virtual IEnumerable<HttpHeader> GetHeader(string header) {
header = HttpHeader.NormaliseName(header);
return Headers.Where(h => h.Name == header);
}
public virtual bool HasHeader(string header) {
header = HttpHeader.NormaliseName(header);
return Headers.Any(h => h.Name == header);
}
public virtual string GetHeaderLine(string header) {
return string.Join(@", ", GetHeader(header).Select(h => h.Value));
}
private bool IsDisposed;
~HttpMessage()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
protected void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
if(OwnsBodyStream && Body != null)
Body.Dispose();
}
}
}

View File

@ -0,0 +1,190 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Hamakaze {
public class HttpRequestMessage : HttpMessage {
public const string GET = @"GET";
public const string PUT = @"PUT";
public const string HEAD = @"HEAD";
public const string POST = @"POST";
public const string DELETE = @"DELETE";
public override string ProtocolVersion => @"1.1";
public string Method { get; }
public string RequestTarget { get; }
public bool IsSecure { get; }
public string Host { get; }
public ushort Port { get; }
public bool IsDefaultPort { get; }
public override IEnumerable<HttpHeader> Headers => HeaderList;
private List<HttpHeader> HeaderList { get; } = new();
private Stream BodyStream { get; set; }
public override Stream Body {
get {
if(BodyStream == null) {
OwnsBodyStream = true;
SetBody(new MemoryStream());
}
return BodyStream;
}
}
private static readonly string[] HEADERS_READONLY = new[] {
HttpHostHeader.NAME, HttpContentLengthHeader.NAME,
};
private static readonly string[] HEADERS_SINGLE = new[] {
HttpUserAgentHeader.NAME, HttpConnectionHeader.NAME, HttpAcceptEncodingHeader.NAME,
};
public IEnumerable<HttpEncoding> AcceptedEncodings {
get => HeaderList.Where(x => x.Name == HttpAcceptEncodingHeader.NAME).Cast<HttpAcceptEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<HttpEncoding>();
set {
HeaderList.RemoveAll(x => x.Name == HttpAcceptEncodingHeader.NAME);
HeaderList.Add(new HttpAcceptEncodingHeader(value));
}
}
public string UserAgent {
get => HeaderList.FirstOrDefault(x => x.Name == HttpUserAgentHeader.NAME)?.Value.ToString()
?? string.Empty;
set {
HeaderList.RemoveAll(x => x.Name == HttpUserAgentHeader.NAME);
HeaderList.Add(new HttpUserAgentHeader(value));
}
}
public string Connection {
get => HeaderList.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString()
?? string.Empty;
set {
HeaderList.RemoveAll(x => x.Name == HttpConnectionHeader.NAME);
HeaderList.Add(new HttpConnectionHeader(value));
}
}
public HttpMediaType ContentType {
get => HeaderList.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast<HttpContentTypeHeader>().FirstOrDefault()?.MediaType
?? HttpMediaType.OctetStream;
set {
HeaderList.RemoveAll(x => x.Name == HttpContentTypeHeader.NAME);
HeaderList.Add(new HttpContentTypeHeader(value));
}
}
public HttpRequestMessage(string method, string uri) : this(
method, new Uri(uri)
) {}
public const ushort HTTP = 80;
public const ushort HTTPS = 443;
public HttpRequestMessage(string method, Uri uri) {
Method = method ?? throw new ArgumentNullException(nameof(method));
RequestTarget = uri.PathAndQuery;
IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase);
Host = uri.Host;
ushort defaultPort = (IsSecure ? HTTPS : HTTP);
Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port;
IsDefaultPort = Port == defaultPort;
HeaderList.Add(new HttpHostHeader(Host, IsDefaultPort ? -1 : Port));
}
public static bool IsHeaderReadOnly(string name)
=> HEADERS_READONLY.Contains(name ?? throw new ArgumentNullException(nameof(name)));
public static bool IsHeaderSingleInstance(string name)
=> HEADERS_SINGLE.Contains(name ?? throw new ArgumentNullException(nameof(name)));
public void SetHeader(string name, object value) {
name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
if(IsHeaderReadOnly(name))
throw new ArgumentException(@"This header is read-only.", nameof(name));
HeaderList.RemoveAll(x => x.Name == name);
HeaderList.Add(HttpHeader.Create(name, value));
}
public void AddHeader(string name, object value) {
name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
if(IsHeaderReadOnly(name))
throw new ArgumentException(@"This header is read-only.", nameof(name));
if(IsHeaderSingleInstance(name))
HeaderList.RemoveAll(x => x.Name == name);
HeaderList.Add(HttpHeader.Create(name, value));
}
public void RemoveHeader(string name) {
name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
if(IsHeaderReadOnly(name))
throw new ArgumentException(@"This header is read-only.", nameof(name));
HeaderList.RemoveAll(x => x.Name == name);
}
public void SetBody(Stream stream) {
if(stream == null) {
if(OwnsBodyStream)
BodyStream?.Dispose();
OwnsBodyStream = false;
BodyStream = null;
HeaderList.RemoveAll(x => x.Name == HttpContentLengthHeader.NAME);
} else {
if(!stream.CanRead || !stream.CanSeek)
throw new ArgumentException(@"Body must readable and seekable.", nameof(stream));
if(OwnsBodyStream)
BodyStream?.Dispose();
OwnsBodyStream = false;
BodyStream = stream;
HeaderList.Add(new HttpContentLengthHeader(BodyStream));
}
}
public void SetBody(byte[] buffer) {
SetBody(new MemoryStream(buffer));
OwnsBodyStream = true;
}
public void SetBody(string str, Encoding encoding = null) {
SetBody((encoding ?? Encoding.UTF8).GetBytes(str));
}
public void WriteTo(Stream stream, Action<long, long> onProgress = null) {
using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) {
sw.NewLine = "\r\n";
sw.Write(Method);
sw.Write(' ');
sw.Write(RequestTarget);
sw.Write(@" HTTP/");
sw.WriteLine(ProtocolVersion);
foreach(HttpHeader header in Headers)
sw.WriteLine(header);
sw.WriteLine();
sw.Flush();
}
if(BodyStream != null) {
const int bufferSize = 8192;
byte[] buffer = new byte[bufferSize];
int read;
long totalRead = 0;
onProgress?.Invoke(totalRead, BodyStream.Length);
BodyStream.Seek(0, SeekOrigin.Begin);
while((read = BodyStream.Read(buffer, 0, bufferSize)) > 0) {
stream.Write(buffer, 0, read);
totalRead += read;
onProgress?.Invoke(totalRead, BodyStream.Length);
}
}
}
}
}

View File

@ -0,0 +1,265 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
namespace Hamakaze {
public class HttpResponseMessage : HttpMessage {
public override string ProtocolVersion { get; }
public int StatusCode { get; }
public string StatusMessage { get; }
public override IEnumerable<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);
}
}
}

189
Hamakaze/HttpTask.cs Normal file
View File

@ -0,0 +1,189 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
namespace Hamakaze {
public class HttpTask {
public TaskState State { get; private set; } = TaskState.Initial;
public bool IsStarted
=> State != TaskState.Initial;
public bool IsFinished
=> State == TaskState.Finished;
public bool IsCancelled
=> State == TaskState.Cancelled;
public bool IsErrored
=> Exception != null;
public Exception Exception { get; private set; }
public HttpRequestMessage Request { get; }
public HttpResponseMessage Response { get; private set; }
private HttpConnectionManager Connections { get; }
private IEnumerable<IPAddress> Addresses { get; set; }
private HttpConnection Connection { get; set; }
public bool DisposeRequest { get; set; }
public bool DisposeResponse { get; set; }
public event Action<HttpTask, HttpResponseMessage> OnComplete;
public event Action<HttpTask, Exception> OnError;
public event Action<HttpTask> OnCancel;
public event Action<HttpTask, long, long> OnUploadProgress;
public event Action<HttpTask, long, long> OnDownloadProgress;
public event Action<HttpTask, TaskState> OnStateChange;
public HttpTask(HttpConnectionManager conns, HttpRequestMessage request, bool disposeRequest, bool disposeResponse) {
Connections = conns ?? throw new ArgumentNullException(nameof(conns));
Request = request ?? throw new ArgumentNullException(nameof(request));
DisposeRequest = disposeRequest;
DisposeResponse = disposeResponse;
}
public void Run() {
if(IsStarted)
throw new HttpTaskAlreadyStartedException();
while(NextStep());
}
public void Cancel() {
State = TaskState.Cancelled;
OnStateChange?.Invoke(this, State);
OnCancel?.Invoke(this);
if(DisposeResponse)
Response?.Dispose();
if(DisposeRequest)
Request?.Dispose();
}
private void Error(Exception ex) {
Exception = ex;
OnError?.Invoke(this, ex);
Cancel();
}
public bool NextStep() {
if(IsCancelled)
return false;
switch(State) {
case TaskState.Initial:
State = TaskState.Lookup;
OnStateChange?.Invoke(this, State);
DoLookup();
break;
case TaskState.Lookup:
State = TaskState.Request;
OnStateChange?.Invoke(this, State);
DoRequest();
break;
case TaskState.Request:
State = TaskState.Response;
OnStateChange?.Invoke(this, State);
DoResponse();
break;
case TaskState.Response:
State = TaskState.Finished;
OnStateChange?.Invoke(this, State);
OnComplete?.Invoke(this, Response);
if(DisposeResponse)
Response?.Dispose();
if(DisposeRequest)
Request?.Dispose();
return false;
default:
Error(new HttpTaskInvalidStateException());
return false;
}
return true;
}
private void DoLookup() {
try {
Addresses = Dns.GetHostAddresses(Request.Host);
} catch(Exception ex) {
Error(ex);
return;
}
if(!Addresses.Any())
Error(new HttpTaskNoAddressesException());
}
private void DoRequest() {
Exception exception = null;
try {
foreach(IPAddress addr in Addresses) {
int tries = 0;
IPEndPoint endPoint = new(addr, Request.Port);
exception = null;
Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
retry:
++tries;
try {
Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t));
break;
} catch(IOException ex) {
Connection.Dispose();
Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
if(tries < 2)
goto retry;
exception = ex;
continue;
} finally {
Connection.MarkUsed();
}
}
} catch(Exception ex) {
Error(ex);
}
if(exception != null)
Error(exception);
else if(Connection == null)
Error(new HttpTaskNoConnectionException());
}
private void DoResponse() {
try {
Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t));
} catch(Exception ex) {
Error(ex);
return;
}
if(Response.Connection == HttpConnectionHeader.CLOSE)
Connection.Dispose();
if(Response == null)
Error(new HttpTaskRequestFailedException());
HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast<HttpKeepAliveHeader>().FirstOrDefault();
if(hkah != null) {
Connection.MaxIdle = hkah.MaxIdle;
Connection.MaxRequests = hkah.MaxRequests;
}
Connection.Release();
}
public enum TaskState {
Initial = 0,
Lookup = 10,
Request = 20,
Response = 30,
Finished = 40,
Cancelled = -1,
}
}
}

View File

@ -0,0 +1,41 @@
using System;
using System.Threading;
namespace Hamakaze {
public class HttpTaskManager : IDisposable {
private Semaphore Lock { get; set; }
public HttpTaskManager(int maxThreads = 5) {
Lock = new Semaphore(maxThreads, maxThreads);
}
public void RunTask(HttpTask task) {
if(task == null)
throw new ArgumentNullException(nameof(task));
if(!Lock.WaitOne())
throw new HttpTaskManagerLockException();
new Thread(() => {
try {
task.Run();
} finally {
Lock?.Release();
}
}).Start();
}
private bool IsDisposed;
~HttpTaskManager()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Lock.Dispose();
Lock = null;
}
}
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Julian van de Groep
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1323
Protocol-draft.md Normal file

File diff suppressed because it is too large Load Diff

1225
Protocol.md Normal file

File diff suppressed because it is too large Load Diff

10
README.md Normal file
View File

@ -0,0 +1,10 @@
```
_____ __ ________ __
/ ___// /_ ____ __________ / ____/ /_ ____ _/ /_
\__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/
___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_
/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/
/_/
```
Welcome to the repository of the temporary Flashii chat server. This is a reimplementation of the old [PHP based Sock Chat server](https://github.com/flashwave/mahou-chat/) in C#.

31
SharpChat.sln Normal file
View File

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29025.244
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat", "SharpChat\SharpChat.csproj", "{DDB24C19-B802-4C96-AC15-0449C6FC77F2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hamakaze", "Hamakaze\Hamakaze.csproj", "{6059200F-141C-42A5-AA3F-E38C9721AEC8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.Build.0 = Release|Any CPU
{6059200F-141C-42A5-AA3F-E38C9721AEC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6059200F-141C-42A5-AA3F-E38C9721AEC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6059200F-141C-42A5-AA3F-E38C9721AEC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6059200F-141C-42A5-AA3F-E38C9721AEC8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {42279FE1-5980-440A-87F8-25338DFE54CF}
EndGlobalSection
EndGlobal

184
SharpChat/BanManager.cs Normal file
View File

@ -0,0 +1,184 @@
using SharpChat.Flashii;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
namespace SharpChat {
public interface IBan {
DateTimeOffset Expires { get; }
string ToString();
}
public class BannedUser : IBan {
public long UserId { get; set; }
public DateTimeOffset Expires { get; set; }
public string Username { get; set; }
public BannedUser() {
}
public BannedUser(FlashiiBan fb) {
UserId = fb.UserId;
Expires = fb.Expires;
Username = fb.Username;
}
public override string ToString() => Username;
}
public class BannedIPAddress : IBan {
public IPAddress Address { get; set; }
public DateTimeOffset Expires { get; set; }
public BannedIPAddress() {
}
public BannedIPAddress(FlashiiBan fb) {
Address = IPAddress.Parse(fb.UserIP);
Expires = fb.Expires;
}
public override string ToString() => Address.ToString();
}
public class BanManager : IDisposable {
private readonly List<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);
}
}
}

160
SharpChat/ChannelManager.cs Normal file
View File

@ -0,0 +1,160 @@
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class ChannelException : Exception { }
public class ChannelExistException : ChannelException { }
public class ChannelInvalidNameException : ChannelException { }
public class ChannelManager : IDisposable {
private readonly List<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);
}
}
}

106
SharpChat/ChatChannel.cs Normal file
View File

@ -0,0 +1,106 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace SharpChat {
public class ChatChannel : IPacketTarget {
public string Name { get; set; }
public string Password { get; set; } = string.Empty;
public bool IsTemporary { get; set; } = false;
public int Rank { get; set; } = 0;
public ChatUser Owner { get; set; } = null;
private List<ChatUser> Users { get; } = new List<ChatUser>();
private List<ChatChannelTyping> Typing { get; } = new List<ChatChannelTyping>();
public bool HasPassword
=> !string.IsNullOrWhiteSpace(Password);
public string TargetName => Name;
public ChatChannel() {
}
public ChatChannel(string name) {
Name = name;
}
public bool HasUser(ChatUser user) {
lock (Users)
return Users.Contains(user);
}
public void UserJoin(ChatUser user) {
if (!user.InChannel(this)) {
// Remove this, a different means for this should be established for V1 compat.
user.Channel?.UserLeave(user);
user.JoinChannel(this);
}
lock (Users) {
if (!HasUser(user))
Users.Add(user);
}
}
public void UserLeave(ChatUser user) {
lock (Users)
Users.Remove(user);
if (user.InChannel(this))
user.LeaveChannel(this);
}
public void Send(IServerPacket packet) {
lock (Users) {
foreach (ChatUser user in Users)
user.Send(packet);
}
}
public IEnumerable<ChatUser> GetUsers(IEnumerable<ChatUser> exclude = null) {
lock (Users) {
IEnumerable<ChatUser> users = Users.OrderByDescending(x => x.Rank);
if (exclude != null)
users = users.Except(exclude);
return users.ToList();
}
}
public bool IsTyping(ChatUser user) {
if(user == null)
return false;
lock(Typing)
return Typing.Any(x => x.User == user && !x.HasExpired);
}
public bool CanType(ChatUser user) {
if(user == null || !HasUser(user))
return false;
return !IsTyping(user);
}
public ChatChannelTyping RegisterTyping(ChatUser user) {
if(user == null || !HasUser(user))
return null;
ChatChannelTyping typing = new ChatChannelTyping(user);
lock(Typing) {
Typing.RemoveAll(x => x.HasExpired);
Typing.Add(typing);
}
return typing;
}
public string Pack() {
StringBuilder sb = new StringBuilder();
sb.Append(Name);
sb.Append('\t');
sb.Append(string.IsNullOrEmpty(Password) ? '0' : '1');
sb.Append('\t');
sb.Append(IsTemporary ? '1' : '0');
return sb.ToString();
}
}
}

View File

@ -0,0 +1,18 @@
using System;
namespace SharpChat {
public class ChatChannelTyping {
public static TimeSpan Lifetime { get; } = TimeSpan.FromSeconds(5);
public ChatUser User { get; }
public DateTimeOffset Started { get; }
public bool HasExpired
=> DateTimeOffset.Now - Started > Lifetime;
public ChatChannelTyping(ChatUser user) {
User = user ?? throw new ArgumentNullException(nameof(user));
Started = DateTimeOffset.Now;
}
}
}

55
SharpChat/ChatColour.cs Normal file
View File

@ -0,0 +1,55 @@
namespace SharpChat {
public class ChatColour {
public const int INHERIT = 0x40000000;
public int Raw { get; set; }
public ChatColour(bool inherit = true) {
Inherit = inherit;
}
public ChatColour(int colour) {
Raw = colour;
}
public bool Inherit {
get => (Raw & INHERIT) > 0;
set {
if (value)
Raw |= INHERIT;
else
Raw &= ~INHERIT;
}
}
public int Red {
get => (Raw >> 16) & 0xFF;
set {
Raw &= ~0xFF0000;
Raw |= (value & 0xFF) << 16;
}
}
public int Green {
get => (Raw >> 8) & 0xFF;
set {
Raw &= ~0xFF00;
Raw |= (value & 0xFF) << 8;
}
}
public int Blue {
get => Raw & 0xFF;
set {
Raw &= ~0xFF;
Raw |= value & 0xFF;
}
}
public override string ToString() {
if (Inherit)
return @"inherit";
return string.Format(@"#{0:X6}", Raw);
}
}
}

190
SharpChat/ChatContext.cs Normal file
View File

@ -0,0 +1,190 @@
using SharpChat.Events;
using SharpChat.Flashii;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
namespace SharpChat {
public class ChatContext : IDisposable, IPacketTarget {
public bool IsDisposed { get; private set; }
public SockChatServer Server { get; }
public Timer BumpTimer { get; }
public BanManager Bans { get; }
public ChannelManager Channels { get; }
public UserManager Users { get; }
public ChatEventManager Events { get; }
public string TargetName => @"@broadcast";
public ChatContext(SockChatServer server) {
Server = server;
Bans = new BanManager(this);
Users = new UserManager(this);
Channels = new ChannelManager(this);
Events = new ChatEventManager(this);
BumpTimer = new Timer(e => FlashiiBump.Submit(Users.WithActiveConnections()), null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
}
public void Update() {
Bans.RemoveExpired();
CheckPings();
}
public void BanUser(ChatUser user, DateTimeOffset? until = null, bool banIPs = false, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
if (until.HasValue && until.Value <= DateTimeOffset.UtcNow)
until = null;
if (until.HasValue) {
user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Banned, until.Value));
Bans.Add(user, until.Value);
if (banIPs) {
foreach (IPAddress ip in user.RemoteAddresses)
Bans.Add(ip, until.Value);
}
} else
user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
user.Close();
UserLeave(user.Channel, user, reason);
}
public void HandleJoin(ChatUser user, ChatChannel chan, ChatUserSession sess) {
if (!chan.HasUser(user)) {
chan.Send(new UserConnectPacket(DateTimeOffset.Now, user));
Events.Add(new UserConnectEvent(DateTimeOffset.Now, user, chan));
}
sess.Send(new AuthSuccessPacket(user, chan, sess));
sess.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
IEnumerable<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 UserLeave(ChatChannel chan, ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
user.Status = ChatUserStatus.Offline;
if (chan == null) {
foreach(ChatChannel channel in user.GetChannels()) {
UserLeave(channel, user, reason);
}
return;
}
if (chan.IsTemporary && chan.Owner == user)
Channels.Remove(chan);
chan.UserLeave(user);
chan.Send(new UserDisconnectPacket(DateTimeOffset.Now, user, reason));
Events.Add(new UserDisconnectEvent(DateTimeOffset.Now, user, chan, reason));
}
public void SwitchChannel(ChatUser user, ChatChannel chan, string password) {
if (user.CurrentChannel == chan) {
//user.Send(true, @"samechan", chan.Name);
user.ForceChannel();
return;
}
if (!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.Owner != user) {
if (chan.Rank > user.Rank) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
user.ForceChannel();
return;
}
if (chan.Password != password) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
user.ForceChannel();
return;
}
}
ForceChannelSwitch(user, chan);
}
public void ForceChannelSwitch(ChatUser user, ChatChannel chan) {
if (!Channels.Contains(chan))
return;
ChatChannel oldChan = user.CurrentChannel;
oldChan.Send(new UserChannelLeavePacket(user));
Events.Add(new UserChannelLeaveEvent(DateTimeOffset.Now, user, oldChan));
chan.Send(new UserChannelJoinPacket(user));
Events.Add(new UserChannelJoinEvent(DateTimeOffset.Now, user, chan));
user.Send(new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
user.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);
foreach (IChatEvent msg in msgs)
user.Send(new ContextMessagePacket(msg));
user.ForceChannel(chan);
oldChan.UserLeave(user);
chan.UserJoin(user);
if (oldChan.IsTemporary && oldChan.Owner == user)
Channels.Remove(oldChan);
}
public void CheckPings() {
lock(Users)
foreach (ChatUser user in Users.All()) {
IEnumerable<ChatUserSession> timedOut = user.GetDeadSessions();
foreach(ChatUserSession sess in timedOut) {
user.RemoveSession(sess);
sess.Dispose();
Logger.Write($@"Nuked session {sess.Id} from {user.Username} (timeout)");
}
if(!user.HasSessions)
UserLeave(null, user, UserDisconnectReason.TimeOut);
}
}
public void Send(IServerPacket packet) {
foreach (ChatUser user in Users.All())
user.Send(packet);
}
~ChatContext()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
private void Dispose(bool disposing) {
if (IsDisposed)
return;
IsDisposed = true;
BumpTimer?.Dispose();
Events?.Dispose();
Channels?.Dispose();
Users?.Dispose();
Bans?.Dispose();
if (disposing)
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,100 @@
using SharpChat.Events;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class ChatEventManager : IDisposable {
private readonly List<IChatEvent> Events = null;
public readonly ChatContext Context;
public bool IsDisposed { get; private set; }
public ChatEventManager(ChatContext context) {
Context = context;
if (!Database.HasDatabase)
Events = new List<IChatEvent>();
}
public void Add(IChatEvent evt) {
if (evt == null)
throw new ArgumentNullException(nameof(evt));
if(Events != null)
lock(Events)
Events.Add(evt);
if(Database.HasDatabase)
Database.LogEvent(evt);
}
public void Remove(IChatEvent evt) {
if (evt == null)
return;
if (Events != null)
lock (Events)
Events.Remove(evt);
if (Database.HasDatabase)
Database.DeleteEvent(evt);
Context.Send(new ChatMessageDeletePacket(evt.SequenceId));
}
public IChatEvent Get(long seqId) {
if (seqId < 1)
return null;
if (Database.HasDatabase)
return Database.GetEvent(seqId);
if (Events != null)
lock (Events)
return Events.FirstOrDefault(e => e.SequenceId == seqId);
return null;
}
public IEnumerable<IChatEvent> GetTargetLog(IPacketTarget target, int amount = 20, int offset = 0) {
if (Database.HasDatabase)
return Database.GetEvents(target, amount, offset).Reverse();
if (Events != null)
lock (Events) {
IEnumerable<IChatEvent> subset = Events.Where(e => e.Target == target || e.Target == null);
int start = subset.Count() - offset - amount;
if(start < 0) {
amount += start;
start = 0;
}
return subset.Skip(start).Take(amount).ToList();
}
return Enumerable.Empty<IChatEvent>();
}
~ChatEventManager()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
private void Dispose(bool disposing) {
if (IsDisposed)
return;
IsDisposed = true;
Events?.Clear();
if (disposing)
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public enum ChatRateLimitState {
None,
Warning,
Kick,
}
public class ChatRateLimiter {
private const int FLOOD_PROTECTION_AMOUNT = 30;
private const int FLOOD_PROTECTION_THRESHOLD = 10;
private readonly Queue<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);
}
}
}
}

216
SharpChat/ChatUser.cs Normal file
View File

@ -0,0 +1,216 @@
using SharpChat.Flashii;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Text;
namespace SharpChat {
public class BasicUser : IEquatable<BasicUser> {
private const int RANK_NO_FLOOD = 9;
public long UserId { get; set; }
public string Username { get; set; }
public ChatColour Colour { get; set; }
public int Rank { get; set; }
public string Nickname { get; set; }
public ChatUserPermissions Permissions { get; set; }
public ChatUserStatus Status { get; set; } = ChatUserStatus.Online;
public string StatusMessage { get; set; }
public bool HasFloodProtection
=> Rank < RANK_NO_FLOOD;
public bool Equals([AllowNull] BasicUser other)
=> UserId == other.UserId;
public string DisplayName {
get {
StringBuilder sb = new StringBuilder();
if(Status == ChatUserStatus.Away)
sb.AppendFormat(@"&lt;{0}&gt;_", StatusMessage.Substring(0, Math.Min(StatusMessage.Length, 5)).ToUpperInvariant());
if(string.IsNullOrWhiteSpace(Nickname))
sb.Append(Username);
else {
sb.Append('~');
sb.Append(Nickname);
}
return sb.ToString();
}
}
public bool Can(ChatUserPermissions perm, bool strict = false) {
ChatUserPermissions perms = Permissions & perm;
return strict ? perms == perm : perms > 0;
}
public string Pack() {
StringBuilder sb = new StringBuilder();
sb.Append(UserId);
sb.Append('\t');
sb.Append(DisplayName);
sb.Append('\t');
sb.Append(Colour);
sb.Append('\t');
sb.Append(Rank);
sb.Append(' ');
sb.Append(Can(ChatUserPermissions.KickUser) ? '1' : '0');
sb.Append(@" 0 ");
sb.Append(Can(ChatUserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(Can(ChatUserPermissions.CreateChannel | ChatUserPermissions.SetChannelPermanent, true) ? 2 : (
Can(ChatUserPermissions.CreateChannel) ? 1 : 0
));
return sb.ToString();
}
}
public class ChatUser : BasicUser, IPacketTarget {
public DateTimeOffset SilencedUntil { get; set; }
private readonly List<ChatUserSession> Sessions = new List<ChatUserSession>();
private readonly List<ChatChannel> Channels = new List<ChatChannel>();
public readonly ChatRateLimiter RateLimiter = new ChatRateLimiter();
public string TargetName => @"@log";
[Obsolete]
public ChatChannel Channel {
get {
lock(Channels)
return Channels.FirstOrDefault();
}
}
// This needs to be a session thing
public ChatChannel CurrentChannel { get; private set; }
public bool IsSilenced
=> DateTimeOffset.UtcNow - SilencedUntil <= TimeSpan.Zero;
public bool HasSessions {
get {
lock(Sessions)
return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Any();
}
}
public int SessionCount {
get {
lock (Sessions)
return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Count();
}
}
public IEnumerable<IPAddress> RemoteAddresses {
get {
lock(Sessions)
return Sessions.Select(c => c.RemoteAddress);
}
}
public ChatUser() {
}
public ChatUser(FlashiiAuth auth) {
UserId = auth.UserId;
ApplyAuth(auth, true);
}
public void ApplyAuth(FlashiiAuth auth, bool invalidateRestrictions = false) {
Username = auth.Username;
if (Status == ChatUserStatus.Offline)
Status = ChatUserStatus.Online;
Colour = new ChatColour(auth.ColourRaw);
Rank = auth.Rank;
Permissions = auth.Permissions;
if (invalidateRestrictions || !IsSilenced)
SilencedUntil = auth.SilencedUntil;
}
public void Send(IServerPacket packet) {
lock(Sessions)
foreach (ChatUserSession conn in Sessions)
conn.Send(packet);
}
public void Close() {
lock (Sessions) {
foreach (ChatUserSession conn in Sessions)
conn.Dispose();
Sessions.Clear();
}
}
public void ForceChannel(ChatChannel chan = null)
=> Send(new UserChannelForceJoinPacket(chan ?? CurrentChannel));
public void FocusChannel(ChatChannel chan) {
lock(Channels) {
if(InChannel(chan))
CurrentChannel = chan;
}
}
public bool InChannel(ChatChannel chan) {
lock (Channels)
return Channels.Contains(chan);
}
public void JoinChannel(ChatChannel chan) {
lock (Channels) {
if(!InChannel(chan)) {
Channels.Add(chan);
CurrentChannel = chan;
}
}
}
public void LeaveChannel(ChatChannel chan) {
lock(Channels) {
Channels.Remove(chan);
CurrentChannel = Channels.FirstOrDefault();
}
}
public IEnumerable<ChatChannel> GetChannels() {
lock (Channels)
return Channels.ToList();
}
public void AddSession(ChatUserSession sess) {
if (sess == null)
return;
sess.User = this;
lock (Sessions)
Sessions.Add(sess);
}
public void RemoveSession(ChatUserSession sess) {
if (sess == null)
return;
if(!sess.IsDisposed) // this could be possible
sess.User = null;
lock(Sessions)
Sessions.Remove(sess);
}
public IEnumerable<ChatUserSession> GetDeadSessions() {
lock (Sessions)
return Sessions.Where(x => x.HasTimedOut || x.IsDisposed).ToList();
}
}
}

View File

@ -0,0 +1,25 @@
using System;
namespace SharpChat {
[Flags]
public enum ChatUserPermissions : int {
KickUser = 0x00000001,
BanUser = 0x00000002,
SilenceUser = 0x00000004,
Broadcast = 0x00000008,
SetOwnNickname = 0x00000010,
SetOthersNickname = 0x00000020,
CreateChannel = 0x00000040,
DeleteChannel = 0x00010000,
SetChannelPermanent = 0x00000080,
SetChannelPassword = 0x00000100,
SetChannelHierarchy = 0x00000200,
JoinAnyChannel = 0x00020000,
SendMessage = 0x00000400,
DeleteOwnMessage = 0x00000800,
DeleteAnyMessage = 0x00001000,
EditOwnMessage = 0x00002000,
EditAnyMessage = 0x00004000,
SeeIPAddress = 0x00008000,
}
}

View File

@ -0,0 +1,89 @@
using Fleck;
using System;
using System.Collections.Generic;
using System.Net;
namespace SharpChat {
public class ChatUserSession : IDisposable, IPacketTarget {
public const int ID_LENGTH = 32;
#if DEBUG
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1);
#else
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5);
#endif
public IWebSocketConnection Connection { get; }
public string Id { get; private set; }
public bool IsDisposed { get; private set; }
public DateTimeOffset LastPing { get; set; } = DateTimeOffset.MinValue;
public ChatUser User { get; set; }
public string TargetName => @"@log";
private IPAddress _RemoteAddress = null;
public IPAddress RemoteAddress {
get {
if (_RemoteAddress == null) {
if ((Connection.ConnectionInfo.ClientIpAddress == @"127.0.0.1" || Connection.ConnectionInfo.ClientIpAddress == @"::1")
&& Connection.ConnectionInfo.Headers.ContainsKey(@"X-Real-IP"))
_RemoteAddress = IPAddress.Parse(Connection.ConnectionInfo.Headers[@"X-Real-IP"]);
else
_RemoteAddress = IPAddress.Parse(Connection.ConnectionInfo.ClientIpAddress);
}
return _RemoteAddress;
}
}
public ChatUserSession(IWebSocketConnection ws) {
Connection = ws;
Id = GenerateId();
}
private static string GenerateId() {
byte[] buffer = new byte[ID_LENGTH];
RNG.NextBytes(buffer);
return buffer.GetIdString();
}
public void Send(IServerPacket packet) {
if (!Connection.IsAvailable)
return;
IEnumerable<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);
}
}
}

View File

@ -0,0 +1,7 @@
namespace SharpChat {
public enum ChatUserStatus {
Online,
Away,
Offline,
}
}

View File

@ -0,0 +1,31 @@
using SharpChat.Events;
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class AFKCommand : IChatCommand {
private const string DEFAULT = @"AFK";
private const int MAX_LENGTH = 5;
public bool IsMatch(string name) {
return name == @"afk";
}
public IChatMessage Dispatch(IChatCommandContext context) {
string statusText = context.Args.ElementAtOrDefault(1);
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
statusText = statusText.Trim();
if(statusText.Length > MAX_LENGTH)
statusText = statusText.Substring(0, MAX_LENGTH).Trim();
}
context.User.Status = ChatUserStatus.Away;
context.User.StatusMessage = statusText;
context.Channel.Send(new UserUpdatePacket(context.User));
return null;
}
}
}

230
SharpChat/Database.cs Normal file
View File

@ -0,0 +1,230 @@
using MySqlConnector;
using SharpChat.Events;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace SharpChat {
public static partial class Database {
private static string ConnectionString = null;
public static bool HasDatabase
=> !string.IsNullOrWhiteSpace(ConnectionString);
public static void ReadConfig() {
if(!File.Exists(@"mariadb.txt"))
return;
string[] config = File.ReadAllLines(@"mariadb.txt");
if (config.Length < 4)
return;
Init(config[0], config[1], config[2], config[3]);
}
public static void Init(string host, string username, string password, string database) {
ConnectionString = new MySqlConnectionStringBuilder {
Server = host,
UserID = username,
Password = password,
Database = database,
OldGuids = false,
TreatTinyAsBoolean = false,
CharacterSet = @"utf8mb4",
SslMode = MySqlSslMode.None,
ForceSynchronous = true,
ConnectionTimeout = 5,
}.ToString();
RunMigrations();
}
public static void Deinit() {
ConnectionString = null;
}
private static MySqlConnection GetConnection() {
if (!HasDatabase)
return null;
MySqlConnection conn = new MySqlConnection(ConnectionString);
conn.Open();
return conn;
}
private static int RunCommand(string command, params MySqlParameter[] parameters) {
if (!HasDatabase)
return 0;
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if (parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteNonQuery();
} catch (MySqlException ex) {
Logger.Write(ex);
}
return 0;
}
private static MySqlDataReader RunQuery(string command, params MySqlParameter[] parameters) {
if (!HasDatabase)
return null;
try {
MySqlConnection conn = GetConnection();
MySqlCommand cmd = conn.CreateCommand();
if (parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private static object RunQueryValue(string command, params MySqlParameter[] parameters) {
if (!HasDatabase)
return null;
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if (parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
cmd.Prepare();
return cmd.ExecuteScalar();
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private const long ID_EPOCH = 1588377600000;
private static int IdCounter = 0;
public static long GenerateId() {
if (IdCounter > 200)
IdCounter = 0;
long id = 0;
id |= (DateTimeOffset.Now.ToUnixTimeMilliseconds() - ID_EPOCH) << 8;
id |= (ushort)(++IdCounter);
return id;
}
public static void LogEvent(IChatEvent evt) {
if(evt.SequenceId < 1)
evt.SequenceId = GenerateId();
RunCommand(
@"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`"
+ @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
+ @" VALUES (@id, FROM_UNIXTIME(@created), @type, @target, @flags, @data"
+ @", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter(@"id", evt.SequenceId),
new MySqlParameter(@"created", evt.DateTime.ToUnixTimeSeconds()),
new MySqlParameter(@"type", evt.GetType().FullName),
new MySqlParameter(@"target", evt.Target.TargetName),
new MySqlParameter(@"flags", (byte)evt.Flags),
new MySqlParameter(@"data", JsonSerializer.SerializeToUtf8Bytes(evt, evt.GetType())),
new MySqlParameter(@"sender", evt.Sender?.UserId < 1 ? null : (long?)evt.Sender.UserId),
new MySqlParameter(@"sender_name", evt.Sender?.Username),
new MySqlParameter(@"sender_colour", evt.Sender?.Colour.Raw),
new MySqlParameter(@"sender_rank", evt.Sender?.Rank),
new MySqlParameter(@"sender_nick", evt.Sender?.Nickname),
new MySqlParameter(@"sender_perms", evt.Sender?.Permissions)
);
}
public static void DeleteEvent(IChatEvent evt) {
RunCommand(
@"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
new MySqlParameter(@"id", evt.SequenceId)
);
}
private static IChatEvent ReadEvent(MySqlDataReader reader, IPacketTarget target = null) {
Type evtType = Type.GetType(Encoding.ASCII.GetString((byte[])reader[@"event_type"]));
IChatEvent evt = JsonSerializer.Deserialize(Encoding.ASCII.GetString((byte[])reader[@"event_data"]), evtType) as IChatEvent;
evt.SequenceId = reader.GetInt64(@"event_id");
evt.Target = target;
evt.TargetName = target?.TargetName ?? Encoding.ASCII.GetString((byte[])reader[@"event_target"]);
evt.Flags = (ChatMessageFlags)reader.GetByte(@"event_flags");
evt.DateTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32(@"event_created"));
if (!reader.IsDBNull(reader.GetOrdinal(@"event_sender"))) {
evt.Sender = new BasicUser {
UserId = reader.GetInt64(@"event_sender"),
Username = reader.GetString(@"event_sender_name"),
Colour = new ChatColour(reader.GetInt32(@"event_sender_colour")),
Rank = reader.GetInt32(@"event_sender_rank"),
Nickname = reader.IsDBNull(reader.GetOrdinal(@"event_sender_nick")) ? null : reader.GetString(@"event_sender_nick"),
Permissions = (ChatUserPermissions)reader.GetInt32(@"event_sender_perms")
};
}
return evt;
}
public static IEnumerable<IChatEvent> GetEvents(IPacketTarget target, int amount, int offset) {
List<IChatEvent> events = new List<IChatEvent>();
try {
using MySqlDataReader reader = RunQuery(
@"SELECT `event_id`, `event_type`, `event_flags`, `event_data`"
+ @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ @", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ @" FROM `sqc_events`"
+ @" WHERE `event_deleted` IS NULL AND `event_target` = @target"
+ @" AND `event_id` > @offset"
+ @" ORDER BY `event_id` DESC"
+ @" LIMIT @amount",
new MySqlParameter(@"target", target.TargetName),
new MySqlParameter(@"amount", amount),
new MySqlParameter(@"offset", offset)
);
while (reader.Read()) {
IChatEvent evt = ReadEvent(reader, target);
if (evt != null)
events.Add(evt);
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
return events;
}
public static IChatEvent GetEvent(long seqId) {
try {
using MySqlDataReader reader = RunQuery(
@"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ @", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ @" FROM `sqc_events`"
+ @" WHERE `event_id` = @id",
new MySqlParameter(@"id", seqId)
);
while (reader.Read()) {
IChatEvent evt = ReadEvent(reader);
if (evt != null)
return evt;
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
}
}

View File

@ -0,0 +1,60 @@
using MySqlConnector;
using System;
namespace SharpChat {
public static partial class Database {
private static void DoMigration(string name, Action action) {
bool done = (long)RunQueryValue(
@"SELECT COUNT(*) FROM `sqc_migrations` WHERE `migration_name` = @name",
new MySqlParameter(@"name", name)
) > 0;
if (!done) {
Logger.Write($@"Running migration '{name}'...");
action();
RunCommand(
@"INSERT INTO `sqc_migrations` (`migration_name`) VALUES (@name)",
new MySqlParameter(@"name", name)
);
}
}
private static void RunMigrations() {
RunCommand(
@"CREATE TABLE IF NOT EXISTS `sqc_migrations` ("
+ @"`migration_name` VARCHAR(255) NOT NULL,"
+ @"`migration_completed` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ @"UNIQUE INDEX `migration_name` (`migration_name`),"
+ @"INDEX `migration_completed` (`migration_completed`)"
+ @") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
DoMigration(@"create_events_table", CreateEventsTable);
}
private static void CreateEventsTable() {
RunCommand(
@"CREATE TABLE `sqc_events` ("
+ @"`event_id` BIGINT(20) NOT NULL,"
+ @"`event_sender` BIGINT(20) UNSIGNED NULL DEFAULT NULL,"
+ @"`event_sender_name` VARCHAR(255) NULL DEFAULT NULL,"
+ @"`event_sender_colour` INT(11) NULL DEFAULT NULL,"
+ @"`event_sender_rank` INT(11) NULL DEFAULT NULL,"
+ @"`event_sender_nick` VARCHAR(255) NULL DEFAULT NULL,"
+ @"`event_sender_perms` INT(11) NULL DEFAULT NULL,"
+ @"`event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ @"`event_deleted` TIMESTAMP NULL DEFAULT NULL,"
+ @"`event_type` VARBINARY(255) NOT NULL,"
+ @"`event_target` VARBINARY(255) NOT NULL,"
+ @"`event_flags` TINYINT(3) UNSIGNED NOT NULL,"
+ @"`event_data` BLOB NULL DEFAULT NULL,"
+ @"PRIMARY KEY (`event_id`),"
+ @"INDEX `event_target` (`event_target`),"
+ @"INDEX `event_type` (`event_type`),"
+ @"INDEX `event_sender` (`event_sender`),"
+ @"INDEX `event_datetime` (`event_created`),"
+ @"INDEX `event_deleted` (`event_deleted`)"
+ @") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
}
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class ChatMessage : IChatMessage {
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.None;
[JsonIgnore]
public long SequenceId { get; set; }
[JsonPropertyName(@"text")]
public string Text { get; set; }
public static string PackBotMessage(int type, string id, params string[] parts) {
return type.ToString() + '\f' + id + '\f' + string.Join('\f', parts);
}
}
}

View File

@ -0,0 +1,25 @@
using System;
namespace SharpChat.Events {
[Flags]
public enum ChatMessageFlags {
None = 0,
Action = 1,
Broadcast = 1 << 1,
Log = 1 << 2,
Private = 1 << 3,
}
public interface IChatEvent {
DateTimeOffset DateTime { get; set; }
BasicUser Sender { get; set; }
IPacketTarget Target { get; set; }
string TargetName { get; set; }
ChatMessageFlags Flags { get; set; }
long SequenceId { get; set; }
}
public interface IChatMessage : IChatEvent {
string Text { get; }
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserChannelJoinEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
public UserChannelJoinEvent() { }
public UserChannelJoinEvent(DateTimeOffset joined, BasicUser user, IPacketTarget target) {
DateTime = joined;
Sender = user;
Target = target;
TargetName = target?.TargetName;
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserChannelLeaveEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
public UserChannelLeaveEvent() { }
public UserChannelLeaveEvent(DateTimeOffset parted, BasicUser user, IPacketTarget target) {
DateTime = parted;
Sender = user;
Target = target;
TargetName = target?.TargetName;
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserConnectEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
public UserConnectEvent() { }
public UserConnectEvent(DateTimeOffset joined, BasicUser user, IPacketTarget target) {
DateTime = joined;
Sender = user;
Target = target;
TargetName = target?.TargetName;
}
}
}

View File

@ -0,0 +1,38 @@
using SharpChat.Packet;
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserDisconnectEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
[JsonPropertyName(@"reason")]
public UserDisconnectReason Reason { get; set; }
public UserDisconnectEvent() { }
public UserDisconnectEvent(DateTimeOffset parted, BasicUser user, IPacketTarget target, UserDisconnectReason reason) {
DateTime = parted;
Sender = user;
Target = target;
TargetName = target?.TargetName;
Reason = reason;
}
}
}

35
SharpChat/Extensions.cs Normal file
View File

@ -0,0 +1,35 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace SharpChat {
public static class Extensions {
public static string GetSignedHash(this string str, string key = null)
=> Encoding.UTF8.GetBytes(str).GetSignedHash(key);
public static string GetSignedHash(this byte[] bytes, string key = null) {
if (key == null)
key = File.Exists(@"login_key.txt") ? File.ReadAllText(@"login_key.txt") : @"woomy";
StringBuilder sb = new StringBuilder();
using (HMACSHA256 algo = new HMACSHA256(Encoding.UTF8.GetBytes(key))) {
byte[] hash = algo.ComputeHash(bytes);
foreach (byte b in hash)
sb.AppendFormat(@"{0:x2}", b);
}
return sb.ToString();
}
public static string GetIdString(this byte[] buffer) {
const string id_chars = @"abcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZ";
StringBuilder sb = new StringBuilder();
foreach(byte b in buffer)
sb.Append(id_chars[b % id_chars.Length]);
return sb.ToString();
}
}
}

View File

@ -0,0 +1,81 @@
using Hamakaze;
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpChat.Flashii {
public class FlashiiAuthRequest {
[JsonPropertyName(@"user_id")]
public long UserId { get; set; }
[JsonPropertyName(@"token")]
public string Token { get; set; }
[JsonPropertyName(@"ip")]
public string IPAddress { get; set; }
[JsonIgnore]
public string Hash
=> string.Join(@"#", UserId, Token, IPAddress).GetSignedHash();
public byte[] GetJSON()
=> JsonSerializer.SerializeToUtf8Bytes(this);
}
public class FlashiiAuth {
[JsonPropertyName(@"success")]
public bool Success { get; set; }
[JsonPropertyName(@"reason")]
public string Reason { get; set; } = @"none";
[JsonPropertyName(@"user_id")]
public long UserId { get; set; }
[JsonPropertyName(@"username")]
public string Username { get; set; }
[JsonPropertyName(@"colour_raw")]
public int ColourRaw { get; set; }
[JsonPropertyName(@"hierarchy")]
public int Rank { get; set; }
[JsonPropertyName(@"is_silenced")]
public DateTimeOffset SilencedUntil { get; set; }
[JsonPropertyName(@"perms")]
public ChatUserPermissions Permissions { get; set; }
public static void Attempt(FlashiiAuthRequest authRequest, Action<FlashiiAuth> onComplete, Action<Exception> onError) {
if(authRequest == null)
throw new ArgumentNullException(nameof(authRequest));
#if DEBUG
if(authRequest.UserId >= 10000) {
onComplete(new FlashiiAuth {
Success = true,
UserId = authRequest.UserId,
Username = @"Misaka-" + (authRequest.UserId - 10000),
ColourRaw = (RNG.Next(0, 255) << 16) | (RNG.Next(0, 255) << 8) | RNG.Next(0, 255),
Rank = 0,
SilencedUntil = DateTimeOffset.MinValue,
Permissions = ChatUserPermissions.SendMessage | ChatUserPermissions.EditOwnMessage | ChatUserPermissions.DeleteOwnMessage,
});
return;
}
#endif
HttpRequestMessage hrm = new HttpRequestMessage(@"POST", FlashiiUrls.AUTH);
hrm.AddHeader(@"X-SharpChat-Signature", authRequest.Hash);
hrm.SetBody(authRequest.GetJSON());
HttpClient.Send(hrm, (t, r) => {
try {
onComplete(JsonSerializer.Deserialize<FlashiiAuth>(r.GetBodyBytes()));
} catch(Exception ex) {
onError(ex);
}
}, (t, e) => onError(e));
}
}
}

View File

@ -0,0 +1,40 @@
using Hamakaze;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpChat.Flashii {
public class FlashiiBan {
private const string STRING = @"givemethebeans";
[JsonPropertyName(@"id")]
public int UserId { get; set; }
[JsonPropertyName(@"ip")]
public string UserIP { get; set; }
[JsonPropertyName(@"expires")]
public DateTimeOffset Expires { get; set; }
[JsonPropertyName(@"username")]
public string Username { get; set; }
public static void GetList(Action<IEnumerable<FlashiiBan>> onComplete, Action<Exception> onError) {
if(onComplete == null)
throw new ArgumentNullException(nameof(onComplete));
if(onError == null)
throw new ArgumentNullException(nameof(onError));
HttpRequestMessage hrm = new HttpRequestMessage(@"GET", FlashiiUrls.BANS);
hrm.AddHeader(@"X-SharpChat-Signature", STRING.GetSignedHash());
HttpClient.Send(hrm, (t, r) => {
try {
onComplete(JsonSerializer.Deserialize<IEnumerable<FlashiiBan>>(r.GetBodyBytes()));
} catch(Exception ex) {
onError(ex);
}
}, (t, e) => onError(e));
}
}
}

View File

@ -0,0 +1,37 @@
using Hamakaze;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpChat.Flashii {
public class FlashiiBump {
[JsonPropertyName(@"id")]
public long UserId { get; set; }
[JsonPropertyName(@"ip")]
public string UserIP { get; set; }
public static void Submit(IEnumerable<ChatUser> users) {
List<FlashiiBump> bups = users.Where(u => u.HasSessions).Select(x => new FlashiiBump { UserId = x.UserId, UserIP = x.RemoteAddresses.First().ToString() }).ToList();
if(bups.Any())
Submit(bups);
}
public static void Submit(IEnumerable<FlashiiBump> users) {
if(users == null)
throw new ArgumentNullException(nameof(users));
if(!users.Any())
return;
byte[] data = JsonSerializer.SerializeToUtf8Bytes(users);
HttpRequestMessage hrm = new HttpRequestMessage(@"POST", FlashiiUrls.BUMP);
hrm.AddHeader(@"X-SharpChat-Signature", data.GetSignedHash());
hrm.SetBody(data);
HttpClient.Send(hrm, onError: (t, e) => Logger.Write($@"Flashii Bump Error: {e}"));
}
}
}

View File

@ -0,0 +1,14 @@
namespace SharpChat.Flashii {
public static class FlashiiUrls {
public const string BASE_URL =
#if DEBUG
@"https://misuzu.misaka.nl/_sockchat";
#else
@"https://flashii.net/_sockchat";
#endif
public const string AUTH = BASE_URL + @"/verify";
public const string BANS = BASE_URL + @"/bans";
public const string BUMP = BASE_URL + @"/bump";
}
}

View File

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

View File

@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace SharpChat {
public interface IChatCommandContext {
IEnumerable<string> Args { get; }
ChatUser User { get; }
ChatChannel Channel { get; }
}
public class ChatCommandContext : IChatCommandContext {
public IEnumerable<string> Args { get; }
public ChatUser User { get; }
public ChatChannel Channel { get; }
public ChatCommandContext(IEnumerable<string> args, ChatUser user, ChatChannel channel) {
Args = args ?? throw new ArgumentNullException(nameof(args));
User = user ?? throw new ArgumentNullException(nameof(user));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
}
}

View File

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

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Threading;
namespace SharpChat {
public interface IServerPacket {
long SequenceId { get; }
IEnumerable<string> Pack();
}
public abstract class ServerPacket : IServerPacket {
private static long SequenceIdCounter = 0;
public long SequenceId { get; }
public ServerPacket(long sequenceId = 0) {
// Allow sequence id to be manually set for potential message repeats
SequenceId = sequenceId > 0 ? sequenceId : Interlocked.Increment(ref SequenceIdCounter);
}
public abstract IEnumerable<string> Pack();
}
}

28
SharpChat/Logger.cs Normal file
View File

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

View File

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public enum AuthFailReason {
AuthInvalid,
MaxSessions,
Banned,
}
public class AuthFailPacket : ServerPacket {
public AuthFailReason Reason { get; private set; }
public DateTimeOffset Expires { get; private set; }
public AuthFailPacket(AuthFailReason reason, DateTimeOffset? expires = null) {
Reason = reason;
if (reason == AuthFailReason.Banned) {
if (!expires.HasValue)
throw new ArgumentNullException(nameof(expires));
Expires = expires.Value;
}
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserConnect);
sb.Append("\tn\t");
switch (Reason) {
case AuthFailReason.AuthInvalid:
default:
sb.Append(@"authfail");
break;
case AuthFailReason.MaxSessions:
sb.Append(@"sockfail");
break;
case AuthFailReason.Banned:
sb.Append(@"joinfail");
break;
}
if (Reason == AuthFailReason.Banned) {
sb.Append('\t');
if (Expires == DateTimeOffset.MaxValue)
sb.Append(@"-1");
else
sb.Append(Expires.ToUnixTimeSeconds());
}
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class AuthSuccessPacket : ServerPacket {
public ChatUser User { get; private set; }
public ChatChannel Channel { get; private set; }
public ChatUserSession Session { get; private set; }
public AuthSuccessPacket(ChatUser user, ChatChannel channel, ChatUserSession sess) {
User = user ?? throw new ArgumentNullException(nameof(user));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
Session = sess ?? throw new ArgumentNullException(nameof(channel));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserConnect);
sb.Append("\ty\t");
sb.Append(User.Pack());
sb.Append('\t');
sb.Append(Channel.Name);
/*sb.Append('\t');
sb.Append(SockChatServer.EXT_VERSION);
sb.Append('\t');
sb.Append(Session.Id);*/
return new[] { sb.ToString() };
}
}
}

View File

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace SharpChat.Packet {
public class BanListPacket : ServerPacket {
public IEnumerable<IBan> Bans { get; private set; }
public BanListPacket(IEnumerable<IBan> bans) {
Bans = bans ?? throw new ArgumentNullException(nameof(bans));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fbanlist\f");
foreach (IBan ban in Bans)
sb.AppendFormat(@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('/unban '+ this.innerHTML);"">{0}</a>, ", ban);
if (Bans.Any())
sb.Length -= 2;
sb.Append('\t');
sb.Append(SequenceId);
sb.Append("\t10010");
return new[] { sb.ToString() };
}
}
}

View File

@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ChannelCreatePacket : ServerPacket {
public ChatChannel Channel { get; private set; }
public ChannelCreatePacket(ChatChannel channel) {
Channel = channel;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ChannelEvent);
sb.Append('\t');
sb.Append((int)SockChatServerChannelPacket.Create);
sb.Append('\t');
sb.Append(Channel.Pack());
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ChannelDeletePacket : ServerPacket {
public ChatChannel Channel { get; private set; }
public ChannelDeletePacket(ChatChannel channel) {
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ChannelEvent);
sb.Append('\t');
sb.Append((int)SockChatServerChannelPacket.Delete);
sb.Append('\t');
sb.Append(Channel.Name);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ChannelUpdatePacket : ServerPacket {
public string PreviousName { get; private set; }
public ChatChannel Channel { get; private set; }
public ChannelUpdatePacket(string previousName, ChatChannel channel) {
PreviousName = previousName;
Channel = channel;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ChannelEvent);
sb.Append('\t');
sb.Append((int)SockChatServerChannelPacket.Update);
sb.Append('\t');
sb.Append(PreviousName);
sb.Append('\t');
sb.Append(Channel.Pack());
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,57 @@
using SharpChat.Events;
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ChatMessageAddPacket : ServerPacket {
public IChatMessage Message { get; private set; }
public ChatMessageAddPacket(IChatMessage message) : base(message?.SequenceId ?? 0) {
Message = message ?? throw new ArgumentNullException(nameof(message));
if (Message.SequenceId < 1)
Message.SequenceId = SequenceId;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('\t');
sb.Append(Message.DateTime.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(Message.Sender?.UserId ?? -1);
sb.Append('\t');
if (Message.Flags.HasFlag(ChatMessageFlags.Action))
sb.Append(@"<i>");
sb.Append(
Message.Text
.Replace(@"<", @"&lt;")
.Replace(@">", @"&gt;")
.Replace("\n", @" <br/> ")
.Replace("\t", @" ")
);
if (Message.Flags.HasFlag(ChatMessageFlags.Action))
sb.Append(@"</i>");
sb.Append('\t');
sb.Append(SequenceId);
sb.AppendFormat(
"\t1{0}0{1}{2}",
Message.Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0',
Message.Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1',
Message.Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0'
);
sb.Append('\t');
sb.Append(Message.TargetName);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ChatMessageDeletePacket : ServerPacket {
public long EventId { get; private set; }
public ChatMessageDeletePacket(long eventId) {
EventId = eventId;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.MessageDelete);
sb.Append('\t');
sb.Append(EventId);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace SharpChat.Packet {
public class ContextChannelsPacket : ServerPacket {
public IEnumerable<ChatChannel> Channels { get; private set; }
public ContextChannelsPacket(IEnumerable<ChatChannel> channels) {
Channels = channels?.Where(c => c != null) ?? throw new ArgumentNullException(nameof(channels));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ContextPopulate);
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Channels);
sb.Append('\t');
sb.Append(Channels.Count());
foreach (ChatChannel channel in Channels) {
sb.Append('\t');
sb.Append(channel.Pack());
}
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public enum ContextClearMode {
Messages = 0,
Users = 1,
Channels = 2,
MessagesUsers = 3,
MessagesUsersChannels = 4,
}
public class ContextClearPacket : ServerPacket {
public ChatChannel Channel { get; private set; }
public ContextClearMode Mode { get; private set; }
public bool IsGlobal
=> Channel == null;
public ContextClearPacket(ChatChannel channel, ContextClearMode mode) {
Channel = channel;
Mode = mode;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ContextClear);
sb.Append('\t');
sb.Append((int)Mode);
sb.Append('\t');
sb.Append(Channel?.TargetName ?? string.Empty);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,99 @@
using SharpChat.Events;
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ContextMessagePacket : ServerPacket {
public IChatEvent Event { get; private set; }
public bool Notify { get; private set; }
public ContextMessagePacket(IChatEvent evt, bool notify = false) {
Event = evt ?? throw new ArgumentNullException(nameof(evt));
Notify = notify;
}
private const string V1_CHATBOT = "-1\tChatBot\tinherit\t\t";
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ContextPopulate);
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Message);
sb.Append('\t');
sb.Append(Event.DateTime.ToUnixTimeSeconds());
sb.Append('\t');
switch (Event) {
case IChatMessage msg:
sb.Append(Event.Sender.Pack());
sb.Append('\t');
sb.Append(
msg.Text
.Replace(@"<", @"&lt;")
.Replace(@">", @"&gt;")
.Replace("\n", @" <br/> ")
.Replace("\t", @" ")
);
break;
case UserConnectEvent _:
sb.Append(V1_CHATBOT);
sb.Append("0\fjoin\f");
sb.Append(Event.Sender.Username);
break;
case UserChannelJoinEvent _:
sb.Append(V1_CHATBOT);
sb.Append("0\fjchan\f");
sb.Append(Event.Sender.Username);
break;
case UserChannelLeaveEvent _:
sb.Append(V1_CHATBOT);
sb.Append("0\flchan\f");
sb.Append(Event.Sender.Username);
break;
case UserDisconnectEvent ude:
sb.Append(V1_CHATBOT);
sb.Append("0\f");
switch (ude.Reason) {
case UserDisconnectReason.Flood:
sb.Append(@"flood");
break;
case UserDisconnectReason.Kicked:
sb.Append(@"kick");
break;
case UserDisconnectReason.TimeOut:
sb.Append(@"timeout");
break;
case UserDisconnectReason.Leave:
default:
sb.Append(@"leave");
break;
}
sb.Append('\f');
sb.Append(Event.Sender.Username);
break;
}
sb.Append('\t');
sb.Append(Event.SequenceId < 1 ? SequenceId : Event.SequenceId);
sb.Append('\t');
sb.Append(Notify ? '1' : '0');
sb.AppendFormat(
"\t1{0}0{1}{2}",
Event.Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0',
Event.Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1',
Event.Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0'
);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace SharpChat.Packet {
public class ContextUsersPacket : ServerPacket {
public IEnumerable<ChatUser> Users { get; private set; }
public ContextUsersPacket(IEnumerable<ChatUser> users) {
Users = users?.Where(u => u != null) ?? throw new ArgumentNullException(nameof(users));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.ContextPopulate);
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Users);
sb.Append('\t');
sb.Append(Users.Count());
foreach (ChatUser user in Users) {
sb.Append('\t');
sb.Append(user.Pack());
sb.Append('\t');
sb.Append('1'); // visibility flag
}
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class FloodWarningPacket : ServerPacket {
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fflwarn\t");
sb.Append(SequenceId);
sb.Append("\t10010");
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public enum ForceDisconnectReason {
Kicked = 0,
Banned = 1,
}
public class ForceDisconnectPacket : ServerPacket {
public ForceDisconnectReason Reason { get; private set; }
public DateTimeOffset Expires { get; private set; }
public ForceDisconnectPacket(ForceDisconnectReason reason, DateTimeOffset? expires = null) {
Reason = reason;
if (reason == ForceDisconnectReason.Banned) {
if (!expires.HasValue)
throw new ArgumentNullException(nameof(expires));
Expires = expires.Value;
}
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.BAKA);
sb.Append('\t');
sb.Append((int)Reason);
if (Reason == ForceDisconnectReason.Banned) {
sb.Append('\t');
sb.Append(Expires.ToUnixTimeSeconds());
}
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace SharpChat.Packet {
public class LegacyCommandResponse : ServerPacket {
public bool IsError { get; private set; }
public string StringId { get; private set; }
public IEnumerable<object> Arguments { get; private set; }
public LegacyCommandResponse(
string stringId,
bool isError = true,
params object[] args
) {
IsError = isError;
StringId = stringId;
Arguments = args;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
if (StringId == LCR.WELCOME) {
sb.Append((int)SockChatServerPacket.ContextPopulate);
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Message);
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\tChatBot\tinherit\t\t");
} else {
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t");
}
sb.Append(IsError ? '1' : '0');
sb.Append('\f');
sb.Append(StringId == LCR.WELCOME ? LCR.BROADCAST : StringId);
if (Arguments?.Any() == true)
foreach (object arg in Arguments) {
sb.Append('\f');
sb.Append(arg);
}
sb.Append('\t');
if (StringId == LCR.WELCOME) {
sb.Append(StringId);
sb.Append("\t0");
} else
sb.Append(SequenceId);
sb.Append("\t10010");
/*sb.AppendFormat(
"\t1{0}0{1}{2}",
Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0',
Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1',
Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0'
);*/
yield return sb.ToString();
}
}
// Abbreviated class name because otherwise shit gets wide
public static class LCR {
public const string COMMAND_NOT_FOUND = @"nocmd";
public const string COMMAND_NOT_ALLOWED = @"cmdna";
public const string COMMAND_FORMAT_ERROR = @"cmderr";
public const string WELCOME = @"welcome";
public const string BROADCAST = @"say";
public const string IP_ADDRESS = @"ipaddr";
public const string USER_NOT_FOUND = @"usernf";
public const string SILENCE_SELF = @"silself";
public const string SILENCE_HIERARCHY = @"silperr";
public const string SILENCE_ALREADY = @"silerr";
public const string TARGET_SILENCED = @"silok";
public const string SILENCED = @"silence";
public const string UNSILENCED = @"unsil";
public const string TARGET_UNSILENCED = @"usilok";
public const string NOT_SILENCED = @"usilerr";
public const string UNSILENCE_HIERARCHY = @"usilperr";
public const string NAME_IN_USE = @"nameinuse";
public const string CHANNEL_INSUFFICIENT_HIERARCHY = @"ipchan";
public const string CHANNEL_INVALID_PASSWORD = @"ipwchan";
public const string CHANNEL_NOT_FOUND = @"nochan";
public const string CHANNEL_ALREADY_EXISTS = @"nischan";
public const string CHANNEL_NAME_INVALID = "inchan";
public const string CHANNEL_CREATED = @"crchan";
public const string CHANNEL_DELETE_FAILED = @"ndchan";
public const string CHANNEL_DELETED = @"delchan";
public const string CHANNEL_PASSWORD_CHANGED = @"cpwdchan";
public const string CHANNEL_HIERARCHY_CHANGED = @"cprivchan";
public const string USERS_LISTING_ERROR = @"whoerr";
public const string USERS_LISTING_CHANNEL = @"whochan";
public const string USERS_LISTING_SERVER = @"who";
public const string INSUFFICIENT_HIERARCHY = @"rankerr";
public const string MESSAGE_DELETE_ERROR = @"delerr";
public const string KICK_NOT_ALLOWED = @"kickna";
public const string USER_NOT_BANNED = @"notban";
public const string USER_UNBANNED = @"unban";
}
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class PongPacket : ServerPacket {
public DateTimeOffset PongTime { get; private set; }
public PongPacket(DateTimeOffset dto) {
PongTime = dto;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.Pong);
sb.Append('\t');
sb.Append(PongTime.ToUnixTimeSeconds());
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class TypingPacket : ServerPacket {
public ChatChannel Channel { get; }
public ChatChannelTyping TypingInfo { get; }
public TypingPacket(ChatChannel channel, ChatChannelTyping typingInfo) {
Channel = channel;
TypingInfo = typingInfo ?? throw new ArgumentNullException(nameof(typingInfo));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.Typing);
sb.Append('\t');
sb.Append(Channel?.TargetName ?? string.Empty);
sb.Append('\t');
sb.Append(TypingInfo.User.UserId);
sb.Append('\t');
sb.Append(TypingInfo.Started.ToUnixTimeSeconds());
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class UserChannelForceJoinPacket : ServerPacket {
public ChatChannel Channel { get; private set; }
public UserChannelForceJoinPacket(ChatChannel channel) {
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserSwitch);
sb.Append('\t');
sb.Append((int)SockChatServerMovePacket.ForcedMove);
sb.Append('\t');
sb.Append(Channel.Name);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class UserChannelJoinPacket : ServerPacket {
public ChatUser User { get; private set; }
public UserChannelJoinPacket(ChatUser user) {
User = user ?? throw new ArgumentNullException(nameof(user));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserSwitch);
sb.Append('\t');
sb.Append((int)SockChatServerMovePacket.UserJoined);
sb.Append('\t');
sb.Append(User.UserId);
sb.Append('\t');
sb.Append(User.DisplayName);
sb.Append('\t');
sb.Append(User.Colour);
sb.Append('\t');
sb.Append(SequenceId);
return new[] { sb.ToString() };
}
}
}

View File

@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class UserChannelLeavePacket : ServerPacket {
public ChatUser User { get; private set; }
public UserChannelLeavePacket(ChatUser user) {
User = user ?? throw new ArgumentNullException(nameof(user));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserSwitch);
sb.Append('\t');
sb.Append((int)SockChatServerMovePacket.UserLeft);
sb.Append('\t');
sb.Append(User.UserId);
sb.Append('\t');
sb.Append(SequenceId);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class UserConnectPacket : ServerPacket {
public DateTimeOffset Joined { get; private set; }
public ChatUser User { get; private set; }
public UserConnectPacket(DateTimeOffset joined, ChatUser user) {
Joined = joined;
User = user ?? throw new ArgumentNullException(nameof(user));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserConnect);
sb.Append('\t');
sb.Append(Joined.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(User.Pack());
sb.Append('\t');
sb.Append(SequenceId);
yield return sb.ToString();
}
}
}

View File

@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public enum UserDisconnectReason {
Leave,
TimeOut,
Kicked,
Flood,
}
public class UserDisconnectPacket : ServerPacket {
public DateTimeOffset Disconnected { get; private set; }
public ChatUser User { get; private set; }
public UserDisconnectReason Reason { get; private set; }
public UserDisconnectPacket(DateTimeOffset disconnected, ChatUser user, UserDisconnectReason reason) {
Disconnected = disconnected;
User = user ?? throw new ArgumentNullException(nameof(user));
Reason = reason;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.UserDisconnect);
sb.Append('\t');
sb.Append(User.UserId);
sb.Append('\t');
sb.Append(User.DisplayName);
sb.Append('\t');
switch (Reason) {
case UserDisconnectReason.Leave:
default:
sb.Append(@"leave");
break;
case UserDisconnectReason.TimeOut:
sb.Append(@"timeout");
break;
case UserDisconnectReason.Kicked:
sb.Append(@"kick");
break;
case UserDisconnectReason.Flood:
sb.Append(@"flood");
break;
}
sb.Append('\t');
sb.Append(Disconnected.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(SequenceId);
return new[] { sb.ToString() };
}
}
}

View File

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class UserUpdatePacket : ServerPacket {
public ChatUser User { get; private set; }
public string PreviousName { get; private set; }
public UserUpdatePacket(ChatUser user, string previousName = null) {
User = user ?? throw new ArgumentNullException(nameof(user));
PreviousName = previousName;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
bool isSilent = string.IsNullOrEmpty(PreviousName);
if (!isSilent) {
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fnick\f");
sb.Append(PreviousName);
sb.Append('\f');
sb.Append(User.DisplayName);
sb.Append('\t');
sb.Append(SequenceId);
sb.Append("\t10010");
yield return sb.ToString();
sb.Clear();
}
sb.Append((int)SockChatServerPacket.UserUpdate);
sb.Append('\t');
sb.Append(User.Pack());
yield return sb.ToString();
}
}
}

30
SharpChat/Program.cs Normal file
View File

@ -0,0 +1,30 @@
using Hamakaze;
using System;
using System.Threading;
namespace SharpChat {
public class Program {
public const ushort PORT = 6770;
public static void Main(string[] args) {
Console.WriteLine(@" _____ __ ________ __ ");
Console.WriteLine(@" / ___// /_ ____ __________ / ____/ /_ ____ _/ /_");
Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/");
Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ ");
Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ ");
Console.WriteLine(@" / _/ Sock Chat Server");
#if DEBUG
Console.WriteLine(@"============================================ DEBUG ==");
#endif
HttpClient.Instance.DefaultUserAgent = @"SharpChat/0.9";
Database.ReadConfig();
using ManualResetEvent mre = new ManualResetEvent(false);
using SockChatServer scs = new SockChatServer(PORT);
Console.CancelKeyPress += (s, e) => { e.Cancel = true; mre.Set(); };
mre.WaitOne();
}
}
}

30
SharpChat/RNG.cs Normal file
View File

@ -0,0 +1,30 @@
using System;
using System.Security.Cryptography;
namespace SharpChat {
public static class RNG {
private static object Lock { get; } = new object();
private static Random NormalRandom { get; } = new Random();
private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create();
public static int Next() {
lock (Lock)
return NormalRandom.Next();
}
public static int Next(int max) {
lock (Lock)
return NormalRandom.Next(max);
}
public static int Next(int min, int max) {
lock (Lock)
return NormalRandom.Next(min, max);
}
public static void NextBytes(byte[] buffer) {
lock(Lock)
SecureRandom.GetBytes(buffer);
}
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Fleck" Version="1.2.0" />
<PackageReference Include="MySqlConnector" Version="1.3.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hamakaze\Hamakaze.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,166 @@
using Fleck;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
// Near direct reimplementation of Fleck's WebSocketServer with address reusing
// Fleck's Socket wrapper doesn't provide any way to do this with the normally provided APIs
// https://github.com/statianzo/Fleck/blob/1.1.0/src/Fleck/WebSocketServer.cs
namespace SharpChat {
public class SharpChatWebSocketServer : IWebSocketServer {
private readonly string _scheme;
private readonly IPAddress _locationIP;
private Action<IWebSocketConnection> _config;
public SharpChatWebSocketServer(string location, bool supportDualStack = true) {
Uri uri = new Uri(location);
Port = uri.Port;
Location = location;
SupportDualStack = supportDualStack;
_locationIP = ParseIPAddress(uri);
_scheme = uri.Scheme;
Socket socket = new Socket(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
if (SupportDualStack && Type.GetType(@"Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false);
}
ListenerSocket = new SocketWrapper(socket);
SupportedSubProtocols = new string[0];
}
public ISocket ListenerSocket { get; set; }
public string Location { get; private set; }
public bool SupportDualStack { get; }
public int Port { get; private set; }
public X509Certificate2 Certificate { get; set; }
public SslProtocols EnabledSslProtocols { get; set; }
public IEnumerable<string> SupportedSubProtocols { get; set; }
public bool RestartAfterListenError { get; set; }
public bool IsSecure {
get { return _scheme == "wss" && Certificate != null; }
}
public void Dispose() {
ListenerSocket.Dispose();
}
private IPAddress ParseIPAddress(Uri uri) {
string ipStr = uri.Host;
if (ipStr == "0.0.0.0") {
return IPAddress.Any;
} else if (ipStr == "[0000:0000:0000:0000:0000:0000:0000:0000]") {
return IPAddress.IPv6Any;
} else {
try {
return IPAddress.Parse(ipStr);
} catch (Exception ex) {
throw new FormatException("Failed to parse the IP address part of the location. Please make sure you specify a valid IP address. Use 0.0.0.0 or [::] to listen on all interfaces.", ex);
}
}
}
public void Start(Action<IWebSocketConnection> config) {
IPEndPoint ipLocal = new IPEndPoint(_locationIP, Port);
ListenerSocket.Bind(ipLocal);
ListenerSocket.Listen(100);
Port = ((IPEndPoint)ListenerSocket.LocalEndPoint).Port;
FleckLog.Info(string.Format("Server started at {0} (actual port {1})", Location, Port));
if (_scheme == "wss") {
if (Certificate == null) {
FleckLog.Error("Scheme cannot be 'wss' without a Certificate");
return;
}
if (EnabledSslProtocols == SslProtocols.None) {
EnabledSslProtocols = SslProtocols.Tls;
FleckLog.Debug("Using default TLS 1.0 security protocol.");
}
}
ListenForClients();
_config = config;
}
private void ListenForClients() {
ListenerSocket.Accept(OnClientConnect, e => {
FleckLog.Error("Listener socket is closed", e);
if (RestartAfterListenError) {
FleckLog.Info("Listener socket restarting");
try {
ListenerSocket.Dispose();
Socket socket = new Socket(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
ListenerSocket = new SocketWrapper(socket);
Start(_config);
FleckLog.Info("Listener socket restarted");
} catch (Exception ex) {
FleckLog.Error("Listener could not be restarted", ex);
}
}
});
}
private void OnClientConnect(ISocket clientSocket) {
if (clientSocket == null) return; // socket closed
FleckLog.Debug(string.Format("Client connected from {0}:{1}", clientSocket.RemoteIpAddress, clientSocket.RemotePort.ToString()));
ListenForClients();
WebSocketConnection connection = null;
connection = new WebSocketConnection(
clientSocket,
_config,
bytes => RequestParser.Parse(bytes, _scheme),
r => {
try {
return HandlerFactory.BuildHandler(
r, s => connection.OnMessage(s), connection.Close, b => connection.OnBinary(b),
b => connection.OnPing(b), b => connection.OnPong(b)
);
} catch(WebSocketException) {
const string responseMsg = "HTTP/1.1 200 OK\r\n"
+ "Date: {0}\r\n"
+ "Server: SharpChat\r\n"
+ "Content-Length: {1}\r\n"
+ "Content-Type: text/html; charset=utf-8\r\n"
+ "Connection: close\r\n"
+ "\r\n"
+ "{2}";
string responseBody = File.Exists(@"http-motd.txt") ? File.ReadAllText(@"http-motd.txt") : @"SharpChat";
clientSocket.Stream.Write(Encoding.UTF8.GetBytes(string.Format(
responseMsg, DateTimeOffset.Now.ToString(@"r"), Encoding.UTF8.GetByteCount(responseBody), responseBody
)));
clientSocket.Close();
return null;
}
},
s => SubProtocolNegotiator.Negotiate(SupportedSubProtocols, s));
if (IsSecure) {
FleckLog.Debug("Authenticating Secure Connection");
clientSocket
.Authenticate(Certificate,
EnabledSslProtocols,
connection.StartReceiving,
e => FleckLog.Warn("Failed to Authenticate", e));
} else {
connection.StartReceiving();
}
}
}
}

View File

@ -0,0 +1,48 @@
namespace SharpChat {
public enum SockChatClientPacket {
// Version 1
Ping = 0,
Authenticate = 1,
MessageSend = 2,
// Version 2
FocusChannel = 3,
Typing = 4,
}
public enum SockChatServerPacket {
// Version 1
Pong = 0,
UserConnect = 1,
MessageAdd = 2,
UserDisconnect = 3,
ChannelEvent = 4,
UserSwitch = 5,
MessageDelete = 6,
ContextPopulate = 7,
ContextClear = 8,
BAKA = 9,
UserUpdate = 10,
// Version 2
Typing = 11,
}
public enum SockChatServerChannelPacket {
Create = 0,
Update = 1,
Delete = 2,
}
public enum SockChatServerMovePacket {
UserJoined = 0,
UserLeft = 1,
ForcedMove = 2,
}
public enum SockChatServerContextPacket {
Users = 0,
Message = 1,
Channels = 2,
}
}

836
SharpChat/SockChatServer.cs Normal file
View File

@ -0,0 +1,836 @@
using Fleck;
using SharpChat.Commands;
using SharpChat.Events;
using SharpChat.Flashii;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
namespace SharpChat {
public class SockChatServer : IDisposable {
public const int EXT_VERSION = 2;
public const int MSG_LENGTH_MAX = 5000;
#if DEBUG
public const int MAX_CONNECTIONS = 9001;
public const int FLOOD_KICK_LENGTH = 5;
public const bool ENABLE_TYPING_EVENT = true;
#else
public const int MAX_CONNECTIONS = 5;
public const int FLOOD_KICK_LENGTH = 30;
public const bool ENABLE_TYPING_EVENT = false;
#endif
public bool IsDisposed { get; private set; }
public static ChatUser Bot { get; } = new ChatUser {
UserId = -1,
Username = @"ChatBot",
Rank = 0,
Colour = new ChatColour(),
};
public IWebSocketServer Server { get; }
public ChatContext Context { get; }
private IReadOnlyCollection<IChatCommand> Commands { get; } = new IChatCommand[] {
new AFKCommand(),
};
public List<ChatUserSession> Sessions { get; } = new List<ChatUserSession>();
private object SessionsLock { get; } = new object();
public ChatUserSession GetSession(IWebSocketConnection conn) {
lock(SessionsLock)
return Sessions.FirstOrDefault(x => x.Connection == conn);
}
public SockChatServer(ushort port) {
Logger.Write("Starting Sock Chat server...");
Context = new ChatContext(this);
Context.Channels.Add(new ChatChannel(@"Lounge"));
#if DEBUG
Context.Channels.Add(new ChatChannel(@"Programming"));
Context.Channels.Add(new ChatChannel(@"Games"));
Context.Channels.Add(new ChatChannel(@"Splatoon"));
Context.Channels.Add(new ChatChannel(@"Password") { Password = @"meow", });
#endif
Context.Channels.Add(new ChatChannel(@"Staff") { Rank = 5 });
Server = new SharpChatWebSocketServer($@"ws://0.0.0.0:{port}");
Server.Start(sock => {
sock.OnOpen = () => OnOpen(sock);
sock.OnClose = () => OnClose(sock);
sock.OnError = err => OnError(sock, err);
sock.OnMessage = msg => OnMessage(sock, msg);
});
}
private void OnOpen(IWebSocketConnection conn) {
lock(SessionsLock) {
if(!Sessions.Any(x => x.Connection == conn))
Sessions.Add(new ChatUserSession(conn));
}
Context.Update();
}
private void OnClose(IWebSocketConnection conn) {
ChatUserSession sess = GetSession(conn);
// Remove connection from user
if(sess?.User != null) {
// RemoveConnection sets conn.User to null so we must grab a local copy.
ChatUser user = sess.User;
user.RemoveSession(sess);
if(!user.HasSessions)
Context.UserLeave(null, user);
}
// Update context
Context.Update();
// Remove connection from server
lock(SessionsLock)
Sessions.Remove(sess);
sess?.Dispose();
}
private void OnError(IWebSocketConnection conn, Exception ex) {
ChatUserSession sess = GetSession(conn);
string sessId = sess?.Id ?? new string('0', ChatUserSession.ID_LENGTH);
Logger.Write($@"[{sessId} {conn.ConnectionInfo.ClientIpAddress}] {ex}");
Context.Update();
}
private void OnMessage(IWebSocketConnection conn, string msg) {
Context.Update();
ChatUserSession sess = GetSession(conn);
if(sess == null) {
conn.Close();
return;
}
if(sess.User is ChatUser && sess.User.HasFloodProtection) {
sess.User.RateLimiter.AddTimePoint();
if(sess.User.RateLimiter.State == ChatRateLimitState.Kick) {
Context.BanUser(sess.User, DateTimeOffset.UtcNow.AddSeconds(FLOOD_KICK_LENGTH), false, UserDisconnectReason.Flood);
return;
} else if(sess.User.RateLimiter.State == ChatRateLimitState.Warning)
sess.User.Send(new FloodWarningPacket()); // make it so this thing only sends once
}
string[] args = msg.Split('\t');
if(args.Length < 1 || !Enum.TryParse(args[0], out SockChatClientPacket opCode))
return;
switch(opCode) {
case SockChatClientPacket.Ping:
if(!int.TryParse(args[1], out int pTime))
break;
sess.BumpPing();
sess.Send(new PongPacket(sess.LastPing));
break;
case SockChatClientPacket.Authenticate:
if(sess.User != null)
break;
DateTimeOffset aBanned = Context.Bans.Check(sess.RemoteAddress);
if(aBanned > DateTimeOffset.UtcNow) {
sess.Send(new AuthFailPacket(AuthFailReason.Banned, aBanned));
sess.Dispose();
break;
}
if(args.Length < 3 || !long.TryParse(args[1], out long aUserId))
break;
FlashiiAuth.Attempt(new FlashiiAuthRequest {
UserId = aUserId,
Token = args[2],
IPAddress = sess.RemoteAddress.ToString(),
}, auth => {
if(!auth.Success) {
Logger.Debug($@"<{sess.Id}> Auth fail: {auth.Reason}");
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
sess.Dispose();
return;
}
ChatUser aUser = Context.Users.Get(auth.UserId);
if(aUser == null)
aUser = new ChatUser(auth);
else {
aUser.ApplyAuth(auth);
aUser.Channel?.Send(new UserUpdatePacket(aUser));
}
aBanned = Context.Bans.Check(aUser);
if(aBanned > DateTimeOffset.Now) {
sess.Send(new AuthFailPacket(AuthFailReason.Banned, aBanned));
sess.Dispose();
return;
}
// Enforce a maximum amount of connections per user
if(aUser.SessionCount >= MAX_CONNECTIONS) {
sess.Send(new AuthFailPacket(AuthFailReason.MaxSessions));
sess.Dispose();
return;
}
// Bumping the ping to prevent upgrading
sess.BumpPing();
aUser.AddSession(sess);
sess.Send(new LegacyCommandResponse(LCR.WELCOME, false, $@"Welcome to Flashii Chat, {aUser.Username}!"));
if(File.Exists(@"welcome.txt")) {
IEnumerable<string> lines = File.ReadAllLines(@"welcome.txt").Where(x => !string.IsNullOrWhiteSpace(x));
string line = lines.ElementAtOrDefault(RNG.Next(lines.Count()));
if(!string.IsNullOrWhiteSpace(line))
sess.Send(new LegacyCommandResponse(LCR.WELCOME, false, line));
}
Context.HandleJoin(aUser, Context.Channels.DefaultChannel, sess);
}, ex => {
Logger.Write($@"<{sess.Id}> Auth task fail: {ex}");
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
sess.Dispose();
});
break;
case SockChatClientPacket.MessageSend:
if(args.Length < 3)
break;
ChatUser mUser = sess.User;
// No longer concats everything after index 1 with \t, no previous implementation did that either
string messageText = args.ElementAtOrDefault(2);
if(mUser == null || !mUser.Can(ChatUserPermissions.SendMessage) || string.IsNullOrWhiteSpace(messageText))
break;
#if !DEBUG
// Extra validation step, not necessary at all but enforces proper formatting in SCv1.
if (!long.TryParse(args[1], out long mUserId) || mUser.UserId != mUserId)
break;
#endif
ChatChannel mChannel = mUser.CurrentChannel;
if(mChannel == null
|| !mUser.InChannel(mChannel)
|| (mUser.IsSilenced && !mUser.Can(ChatUserPermissions.SilenceUser)))
break;
if(mUser.Status != ChatUserStatus.Online) {
mUser.Status = ChatUserStatus.Online;
mChannel.Send(new UserUpdatePacket(mUser));
}
if(messageText.Length > MSG_LENGTH_MAX)
messageText = messageText.Substring(0, MSG_LENGTH_MAX);
messageText = messageText.Trim();
#if DEBUG
Logger.Write($@"<{sess.Id} {mUser.Username}> {messageText}");
#endif
IChatMessage message = null;
if(messageText[0] == '/') {
message = HandleV1Command(messageText, mUser, mChannel);
if(message == null)
break;
}
if(message == null)
message = new ChatMessage {
Target = mChannel,
TargetName = mChannel.TargetName,
DateTime = DateTimeOffset.UtcNow,
Sender = mUser,
Text = messageText,
};
Context.Events.Add(message);
mChannel.Send(new ChatMessageAddPacket(message));
break;
case SockChatClientPacket.FocusChannel:
if(sess.User == null || args.Length < 2)
break;
ChatChannel fChannel = Context.Channels.Get(args[1]);
if(fChannel == null || sess.User.CurrentChannel == fChannel)
break;
sess.User.FocusChannel(fChannel);
break;
case SockChatClientPacket.Typing:
if(!ENABLE_TYPING_EVENT || sess.User == null)
break;
ChatChannel tChannel = sess.User.CurrentChannel;
if(tChannel == null || !tChannel.CanType(sess.User))
break;
ChatChannelTyping tInfo = tChannel.RegisterTyping(sess.User);
if(tInfo == null)
return;
tChannel.Send(new TypingPacket(tChannel, tInfo));
break;
}
}
public IChatMessage HandleV1Command(string message, ChatUser user, ChatChannel channel) {
string[] parts = message.Substring(1).Split(' ');
string commandName = parts[0].Replace(@".", string.Empty).ToLowerInvariant();
for(int i = 1; i < parts.Length; i++)
parts[i] = parts[i].Replace(@"<", @"&lt;")
.Replace(@">", @"&gt;")
.Replace("\n", @" <br/> ");
IChatCommand command = null;
foreach(IChatCommand cmd in Commands)
if(cmd.IsMatch(commandName)) {
command = cmd;
break;
}
if(command != null)
return command.Dispatch(new ChatCommandContext(parts, user, channel));
switch(commandName) {
case @"nick": // sets a temporary nickname
bool setOthersNick = user.Can(ChatUserPermissions.SetOthersNickname);
if(!setOthersNick && !user.Can(ChatUserPermissions.SetOwnNickname)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
ChatUser targetUser = null;
int offset = 1;
if(setOthersNick && parts.Length > 1 && long.TryParse(parts[1], out long targetUserId) && targetUserId > 0) {
targetUser = Context.Users.Get(targetUserId);
offset = 2;
}
if(targetUser == null)
targetUser = user;
if(parts.Length < offset) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
string nickStr = string.Join('_', parts.Skip(offset))
.Replace(' ', '_')
.Replace("\n", string.Empty)
.Replace("\r", string.Empty)
.Replace("\f", string.Empty)
.Replace("\t", string.Empty)
.Trim();
if(nickStr == targetUser.Username)
nickStr = null;
else if(nickStr.Length > 15)
nickStr = nickStr.Substring(0, 15);
else if(string.IsNullOrEmpty(nickStr))
nickStr = null;
if(nickStr != null && Context.Users.Get(nickStr) != null) {
user.Send(new LegacyCommandResponse(LCR.NAME_IN_USE, true, nickStr));
break;
}
string previousName = targetUser == user ? (targetUser.Nickname ?? targetUser.Username) : null;
targetUser.Nickname = nickStr;
channel.Send(new UserUpdatePacket(targetUser, previousName));
break;
case @"whisper": // sends a pm to another user
case @"msg":
if(parts.Length < 3) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
ChatUser whisperUser = Context.Users.Get(parts[1]);
if(whisperUser == null) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts[1]));
break;
}
if(whisperUser == user)
break;
string whisperStr = string.Join(' ', parts.Skip(2));
whisperUser.Send(new ChatMessageAddPacket(new ChatMessage {
DateTime = DateTimeOffset.Now,
Target = whisperUser,
TargetName = whisperUser.TargetName,
Sender = user,
Text = whisperStr,
Flags = ChatMessageFlags.Private,
}));
user.Send(new ChatMessageAddPacket(new ChatMessage {
DateTime = DateTimeOffset.Now,
Target = whisperUser,
TargetName = whisperUser.TargetName,
Sender = user,
Text = $@"{whisperUser.DisplayName} {whisperStr}",
Flags = ChatMessageFlags.Private,
}));
break;
case @"action": // describe an action
case @"me":
if(parts.Length < 2)
break;
string actionMsg = string.Join(' ', parts.Skip(1));
return new ChatMessage {
Target = channel,
TargetName = channel.TargetName,
DateTime = DateTimeOffset.UtcNow,
Sender = user,
Text = actionMsg,
Flags = ChatMessageFlags.Action,
};
case @"who": // gets all online users/online users in a channel if arg
StringBuilder whoChanSB = new StringBuilder();
string whoChanStr = parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : string.Empty;
if(!string.IsNullOrEmpty(whoChanStr)) {
ChatChannel whoChan = Context.Channels.Get(whoChanStr);
if(whoChan == null) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, whoChanStr));
break;
}
if(whoChan.Rank > user.Rank || (whoChan.HasPassword && !user.Can(ChatUserPermissions.JoinAnyChannel))) {
user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_ERROR, true, whoChanStr));
break;
}
foreach(ChatUser whoUser in whoChan.GetUsers()) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(whoUser == user)
whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append(@">");
whoChanSB.Append(whoUser.DisplayName);
whoChanSB.Append(@"</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_CHANNEL, false, whoChanSB));
} else {
foreach(ChatUser whoUser in Context.Users.All()) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(whoUser == user)
whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append(@">");
whoChanSB.Append(whoUser.DisplayName);
whoChanSB.Append(@"</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_SERVER, false, whoChanSB));
}
break;
// double alias for delchan and delmsg
// if the argument is a number we're deleting a message
// if the argument is a string we're deleting a channel
case @"delete":
if(parts.Length < 2) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
if(parts[1].All(char.IsDigit))
goto case @"delmsg";
goto case @"delchan";
// anyone can use these
case @"join": // join a channel
if(parts.Length < 2)
break;
ChatChannel joinChan = Context.Channels.Get(parts[1]);
if(joinChan == null) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, parts[1]));
user.ForceChannel();
break;
}
Context.SwitchChannel(user, joinChan, string.Join(' ', parts.Skip(2)));
break;
case @"create": // create a new channel
if(user.Can(ChatUserPermissions.CreateChannel)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
bool createChanHasHierarchy;
if(parts.Length < 2 || (createChanHasHierarchy = parts[1].All(char.IsDigit) && parts.Length < 3)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
int createChanHierarchy = 0;
if(createChanHasHierarchy)
int.TryParse(parts[1], out createChanHierarchy);
if(createChanHierarchy > user.Rank) {
user.Send(new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY));
break;
}
string createChanName = string.Join('_', parts.Skip(createChanHasHierarchy ? 2 : 1));
ChatChannel createChan = new() {
Name = createChanName,
IsTemporary = !user.Can(ChatUserPermissions.SetChannelPermanent),
Rank = createChanHierarchy,
Owner = user,
};
try {
Context.Channels.Add(createChan);
} catch(ChannelExistException) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_ALREADY_EXISTS, true, createChan.Name));
break;
} catch(ChannelInvalidNameException) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NAME_INVALID));
break;
}
Context.SwitchChannel(user, createChan, createChan.Password);
user.Send(new LegacyCommandResponse(LCR.CHANNEL_CREATED, false, createChan.Name));
break;
case @"delchan": // delete a channel
if(parts.Length < 2 || string.IsNullOrWhiteSpace(parts[1])) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
string delChanName = string.Join('_', parts.Skip(1));
ChatChannel delChan = Context.Channels.Get(delChanName);
if(delChan == null) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, delChanName));
break;
}
if(!user.Can(ChatUserPermissions.DeleteChannel) && delChan.Owner != user) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_DELETE_FAILED, true, delChan.Name));
break;
}
Context.Channels.Remove(delChan);
user.Send(new LegacyCommandResponse(LCR.CHANNEL_DELETED, false, delChan.Name));
break;
case @"password": // set a password on the channel
case @"pwd":
if(!user.Can(ChatUserPermissions.SetChannelPassword) || channel.Owner != user) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
string chanPass = string.Join(' ', parts.Skip(1)).Trim();
if(string.IsNullOrWhiteSpace(chanPass))
chanPass = string.Empty;
Context.Channels.Update(channel, password: chanPass);
user.Send(new LegacyCommandResponse(LCR.CHANNEL_PASSWORD_CHANGED, false));
break;
case @"privilege": // sets a minimum hierarchy requirement on the channel
case @"rank":
case @"priv":
if(!user.Can(ChatUserPermissions.SetChannelHierarchy) || channel.Owner != user) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
if(parts.Length < 2 || !int.TryParse(parts[1], out int chanHierarchy) || chanHierarchy > user.Rank) {
user.Send(new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY));
break;
}
Context.Channels.Update(channel, hierarchy: chanHierarchy);
user.Send(new LegacyCommandResponse(LCR.CHANNEL_HIERARCHY_CHANGED, false));
break;
case @"say": // pretend to be the bot
if(!user.Can(ChatUserPermissions.Broadcast)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
Context.Send(new LegacyCommandResponse(LCR.BROADCAST, false, string.Join(' ', parts.Skip(1))));
break;
case @"delmsg": // deletes a message
bool deleteAnyMessage = user.Can(ChatUserPermissions.DeleteAnyMessage);
if(!deleteAnyMessage && !user.Can(ChatUserPermissions.DeleteOwnMessage)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
if(parts.Length < 2 || !parts[1].All(char.IsDigit) || !long.TryParse(parts[1], out long delSeqId)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
IChatEvent delMsg = Context.Events.Get(delSeqId);
if(delMsg == null || delMsg.Sender.Rank > user.Rank || (!deleteAnyMessage && delMsg.Sender.UserId != user.UserId)) {
user.Send(new LegacyCommandResponse(LCR.MESSAGE_DELETE_ERROR));
break;
}
Context.Events.Remove(delMsg);
break;
case @"kick": // kick a user from the server
case @"ban": // ban a user from the server, this differs from /kick in that it adds all remote address to the ip banlist
bool isBanning = commandName == @"ban";
if(!user.Can(isBanning ? ChatUserPermissions.BanUser : ChatUserPermissions.KickUser)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
ChatUser banUser;
if(parts.Length < 2 || (banUser = Context.Users.Get(parts[1])) == null) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1]));
break;
}
if(banUser == user || banUser.Rank >= user.Rank || Context.Bans.Check(banUser) > DateTimeOffset.Now) {
user.Send(new LegacyCommandResponse(LCR.KICK_NOT_ALLOWED, true, banUser.DisplayName));
break;
}
DateTimeOffset? banUntil = isBanning ? (DateTimeOffset?)DateTimeOffset.MaxValue : null;
if(parts.Length > 2) {
if(!double.TryParse(parts[2], out double silenceSeconds)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
banUntil = DateTimeOffset.UtcNow.AddSeconds(silenceSeconds);
}
Context.BanUser(banUser, banUntil, isBanning);
break;
case @"pardon":
case @"unban":
if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
if(parts.Length < 2) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, string.Empty));
break;
}
BannedUser unbanUser = Context.Bans.GetUser(parts[1]);
if(unbanUser == null || unbanUser.Expires <= DateTimeOffset.Now) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanUser?.Username ?? parts[1]));
break;
}
Context.Bans.Remove(unbanUser);
user.Send(new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanUser));
break;
case @"pardonip":
case @"unbanip":
if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
if(parts.Length < 2 || !IPAddress.TryParse(parts[1], out IPAddress unbanIP)) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, string.Empty));
break;
}
if(Context.Bans.Check(unbanIP) <= DateTimeOffset.Now) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanIP));
break;
}
Context.Bans.Remove(unbanIP);
user.Send(new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanIP));
break;
case @"bans": // gets a list of bans
case @"banned":
if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
user.Send(new BanListPacket(Context.Bans.All()));
break;
case @"silence": // silence a user
if(!user.Can(ChatUserPermissions.SilenceUser)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
ChatUser silUser;
if(parts.Length < 2 || (silUser = Context.Users.Get(parts[1])) == null) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1]));
break;
}
if(silUser == user) {
user.Send(new LegacyCommandResponse(LCR.SILENCE_SELF));
break;
}
if(silUser.Rank >= user.Rank) {
user.Send(new LegacyCommandResponse(LCR.SILENCE_HIERARCHY));
break;
}
if(silUser.IsSilenced) {
user.Send(new LegacyCommandResponse(LCR.SILENCE_ALREADY));
break;
}
DateTimeOffset silenceUntil = DateTimeOffset.MaxValue;
if(parts.Length > 2) {
if(!double.TryParse(parts[2], out double silenceSeconds)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
break;
}
silenceUntil = DateTimeOffset.UtcNow.AddSeconds(silenceSeconds);
}
silUser.SilencedUntil = silenceUntil;
silUser.Send(new LegacyCommandResponse(LCR.SILENCED, false));
user.Send(new LegacyCommandResponse(LCR.TARGET_SILENCED, false, silUser.DisplayName));
break;
case @"unsilence": // unsilence a user
if(!user.Can(ChatUserPermissions.SilenceUser)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $@"/{commandName}"));
break;
}
ChatUser unsilUser;
if(parts.Length < 2 || (unsilUser = Context.Users.Get(parts[1])) == null) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1]));
break;
}
if(unsilUser.Rank >= user.Rank) {
user.Send(new LegacyCommandResponse(LCR.UNSILENCE_HIERARCHY));
break;
}
if(!unsilUser.IsSilenced) {
user.Send(new LegacyCommandResponse(LCR.NOT_SILENCED));
break;
}
unsilUser.SilencedUntil = DateTimeOffset.MinValue;
unsilUser.Send(new LegacyCommandResponse(LCR.UNSILENCED, false));
user.Send(new LegacyCommandResponse(LCR.TARGET_UNSILENCED, false, unsilUser.DisplayName));
break;
case @"ip": // gets a user's ip (from all connections in this case)
case @"whois":
if(!user.Can(ChatUserPermissions.SeeIPAddress)) {
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, @"/ip"));
break;
}
ChatUser ipUser;
if(parts.Length < 2 || (ipUser = Context.Users.Get(parts[1])) == null) {
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? @"User" : parts[1]));
break;
}
foreach(IPAddress ip in ipUser.RemoteAddresses.Distinct().ToArray())
user.Send(new LegacyCommandResponse(LCR.IP_ADDRESS, false, ipUser.Username, ip));
break;
default:
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_FOUND, true, commandName));
break;
}
return null;
}
~SockChatServer()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Sessions?.Clear();
Server?.Dispose();
Context?.Dispose();
}
}
}

90
SharpChat/UserManager.cs Normal file
View File

@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class UserManager : IDisposable {
private readonly List<ChatUser> Users = new List<ChatUser>();
public readonly ChatContext Context;
public bool IsDisposed { get; private set; }
public UserManager(ChatContext context) {
Context = context;
}
public void Add(ChatUser user) {
if (user == null)
throw new ArgumentNullException(nameof(user));
lock(Users)
if(!Contains(user))
Users.Add(user);
}
public void Remove(ChatUser user) {
if (user == null)
return;
lock(Users)
Users.Remove(user);
}
public bool Contains(ChatUser user) {
if (user == null)
return false;
lock (Users)
return Users.Contains(user) || Users.Any(x => x.UserId == user.UserId || x.Username.ToLowerInvariant() == user.Username.ToLowerInvariant());
}
public ChatUser Get(long userId) {
lock(Users)
return Users.FirstOrDefault(x => x.UserId == userId);
}
public ChatUser Get(string username, bool includeNickName = true, bool includeDisplayName = true) {
if (string.IsNullOrWhiteSpace(username))
return null;
username = username.ToLowerInvariant();
lock(Users)
return Users.FirstOrDefault(x => x.Username.ToLowerInvariant() == username
|| (includeNickName && x.Nickname?.ToLowerInvariant() == username)
|| (includeDisplayName && x.DisplayName.ToLowerInvariant() == username));
}
public IEnumerable<ChatUser> OfHierarchy(int hierarchy) {
lock (Users)
return Users.Where(u => u.Rank >= hierarchy).ToList();
}
public IEnumerable<ChatUser> WithActiveConnections() {
lock (Users)
return Users.Where(u => u.HasSessions).ToList();
}
public IEnumerable<ChatUser> All() {
lock (Users)
return Users.ToList();
}
~UserManager()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
private void Dispose(bool disposing) {
if (IsDisposed)
return;
IsDisposed = true;
Users.Clear();
if (disposing)
GC.SuppressFinalize(this);
}
}
}