Imported stable branch.
This commit is contained in:
commit
4ceffeb48d
|
@ -0,0 +1 @@
|
|||
* text=auto
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.") { }
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -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#.
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(@"<{0}>_", StatusMessage.Substring(0, Math.Min(StatusMessage.Length, 5)).ToUpperInvariant());
|
||||
|
||||
if(string.IsNullOrWhiteSpace(Nickname))
|
||||
sb.Append(Username);
|
||||
else {
|
||||
sb.Append('~');
|
||||
sb.Append(Nickname);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Can(ChatUserPermissions perm, bool strict = false) {
|
||||
ChatUserPermissions perms = Permissions & perm;
|
||||
return strict ? perms == perm : perms > 0;
|
||||
}
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.Append(UserId);
|
||||
sb.Append('\t');
|
||||
sb.Append(DisplayName);
|
||||
sb.Append('\t');
|
||||
sb.Append(Colour);
|
||||
sb.Append('\t');
|
||||
sb.Append(Rank);
|
||||
sb.Append(' ');
|
||||
sb.Append(Can(ChatUserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(@" 0 ");
|
||||
sb.Append(Can(ChatUserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(Can(ChatUserPermissions.CreateChannel | ChatUserPermissions.SetChannelPermanent, true) ? 2 : (
|
||||
Can(ChatUserPermissions.CreateChannel) ? 1 : 0
|
||||
));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChatUser : BasicUser, IPacketTarget {
|
||||
public DateTimeOffset SilencedUntil { get; set; }
|
||||
|
||||
private readonly List<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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
namespace SharpChat {
|
||||
public enum ChatUserStatus {
|
||||
Online,
|
||||
Away,
|
||||
Offline,
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
using SharpChat.Events;
|
||||
|
||||
namespace SharpChat {
|
||||
public interface IChatCommand {
|
||||
bool IsMatch(string name);
|
||||
IChatMessage Dispatch(IChatCommandContext context);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
namespace SharpChat {
|
||||
public interface IPacketTarget {
|
||||
string TargetName { get; }
|
||||
void Send(IServerPacket packet);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(@"<", @"<")
|
||||
.Replace(@">", @">")
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(@"<", @"<")
|
||||
.Replace(@">", @">")
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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(@"<", @"<")
|
||||
.Replace(@">", @">")
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue