Split out the Flashii interaction code into a separate library.

This commit is contained in:
flash 2025-04-26 19:42:23 +00:00
parent 51f5c4c948
commit 2eba089a21
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
55 changed files with 769 additions and 552 deletions

View file

@ -1,4 +1,4 @@
root = true root = true
[*] [*]
end_of_line = lf end_of_line = lf
@ -11,3 +11,134 @@ indent_size = 4
# IDE1006: Naming Styles # IDE1006: Naming Styles
dotnet_diagnostic.IDE1006.severity = none dotnet_diagnostic.IDE1006.severity = none
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_prefer_system_threading_lock = true:suggestion
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_prefer_static_local_function = true:suggestion
csharp_style_prefer_readonly_struct = true:suggestion
csharp_prefer_static_anonymous_function = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_conditional_delegate_call = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_pattern_matching = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_style_readonly_field = true:suggestion
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
dotnet_code_quality_unused_parameters = all:suggestion
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_event = false:silent
dotnet_style_qualification_for_method = false:silent

View file

@ -0,0 +1,31 @@
using SharpChat.Auth;
using System.Text.Json.Serialization;
namespace SharpChat.Flashii {
public class FlashiiAuthResult : AuthResult {
public string UserId => UserIdRaw.ToString();
public string UserName => UserNameRaw ?? string.Empty;
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("user_id")]
public long UserIdRaw { get; init; }
[JsonPropertyName("username")]
public string? UserNameRaw { get; init; }
[JsonPropertyName("colour_raw")]
public int UserColourRaw { get; init; }
[JsonPropertyName("hierarchy")]
public int UserRank { get; init; }
[JsonPropertyName("perms")]
public UserPermissions UserPermissions { get; init; }
}
}

View file

@ -0,0 +1,13 @@
using SharpChat.Bans;
namespace SharpChat.Flashii {
public abstract class FlashiiBanInfo(
BanKind kind,
FlashiiRawBanInfo rawBanInfo
) : BanInfo {
public BanKind Kind { get; } = kind;
public bool IsPermanent { get; } = rawBanInfo.IsPermanent;
public DateTimeOffset ExpiresAt { get; } = rawBanInfo.ExpiresAt;
public abstract override string ToString();
}
}

View file

@ -0,0 +1,240 @@
using SharpChat.Auth;
using SharpChat.Bans;
using SharpChat.Configuration;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace SharpChat.Flashii {
public class FlashiiClient(HttpClient httpClient, Config config) : AuthClient, BansClient {
private const string DEFAULT_BASE_URL = "https://flashii.net/_sockchat";
private readonly CachedValue<string> BaseURL = config.ReadCached("url", DEFAULT_BASE_URL);
private const string DEFAULT_SECRET_KEY = "woomy";
private readonly CachedValue<string> SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
private string CreateStringSignature(string str) {
return CreateBufferSignature(Encoding.UTF8.GetBytes(str));
}
private string CreateBufferSignature(byte[] bytes) {
using HMACSHA256 algo = new(Encoding.UTF8.GetBytes(SecretKey!));
return string.Concat(algo.ComputeHash(bytes).Select(c => c.ToString("x2")));
}
private const string AUTH_VERIFY_URL = "{0}/verify";
private const string AUTH_VERIFY_SIG = "verify#{0}#{1}#{2}";
public async Task<AuthResult> AuthVerifyAsync(IPAddress remoteAddr, string scheme, string token) {
string remoteAddrStr = remoteAddr.ToString();
HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_VERIFY_URL, BaseURL)) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "method", scheme },
{ "token", token },
{ "ipaddr", remoteAddrStr },
}),
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(string.Format(AUTH_VERIFY_SIG, scheme, token, remoteAddrStr)) },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
FlashiiAuthResult? authResult = await JsonSerializer.DeserializeAsync<FlashiiAuthResult>(stream);
if(authResult?.Success != true)
throw new AuthFailedException(authResult?.Reason ?? "none");
return authResult;
}
private const string AUTH_BUMP_USERS_ONLINE_URL = "{0}/bump";
public async Task AuthBumpUsersOnlineAsync(IEnumerable<(IPAddress remoteAddr, string userId)> entries) {
if(!entries.Any())
return;
string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
StringBuilder sb = new();
sb.AppendFormat("bump#{0}", now);
Dictionary<string, string> formData = new() {
{ "t", now },
};
foreach(var (remoteAddr, userId) in entries) {
string remoteAddrStr = remoteAddr.ToString();
sb.AppendFormat("#{0}:{1}", userId, remoteAddrStr);
formData.Add(string.Format("u[{0}]", userId), remoteAddrStr);
}
HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_BUMP_USERS_ONLINE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) }
},
Content = new FormUrlEncodedContent(formData),
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
private const string BANS_CREATE_URL = "{0}/bans/create";
private const string BANS_CREATE_SIG = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}";
public async Task BanCreateAsync(
BanKind kind,
TimeSpan duration,
IPAddress remoteAddr,
string? userId = null,
string? reason = null,
IPAddress? issuerRemoteAddr = null,
string? issuerUserId = null
) {
if(duration <= TimeSpan.Zero || kind != BanKind.User)
return;
issuerUserId ??= string.Empty;
userId ??= string.Empty;
reason ??= string.Empty;
issuerRemoteAddr ??= IPAddress.IPv6None;
string isPerma = duration == TimeSpan.MaxValue ? "1" : "0";
string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString();
string remoteAddrStr = remoteAddr.ToString();
string issuerRemoteAddrStr = issuerRemoteAddr.ToString();
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string sig = string.Format(
BANS_CREATE_SIG,
now, userId, remoteAddrStr,
issuerUserId, issuerRemoteAddrStr,
durationStr, isPerma, reason
);
HttpRequestMessage request = new(HttpMethod.Post, string.Format(BANS_CREATE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "t", now },
{ "ui", userId },
{ "ua", remoteAddrStr },
{ "mi", issuerUserId },
{ "ma", issuerRemoteAddrStr },
{ "d", durationStr },
{ "p", isPerma },
{ "r", reason },
}),
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
private const string BANS_REVOKE_URL = "{0}/bans/revoke?t={1}&s={2}&x={3}";
private const string BANS_REVOKE_SIG = "revoke#{0}#{1}#{2}";
public async Task<bool> BanRevokeAsync(BanInfo info) {
string type;
string target;
if(info is UserBanInfo ubi) {
if(info.Kind != BanKind.User)
throw new ArgumentException("info argument is an instance of UserBanInfo but Kind was not set to BanKind.User", nameof(info));
type = "user";
target = ubi.UserId;
} else if(info is IPAddressBanInfo iabi) {
if(info.Kind != BanKind.IPAddress)
throw new ArgumentException("info argument is an instance of IPAddressBanInfo but Kind was not set to BanKind.IPAddress", nameof(info));
type = "addr";
target = iabi.Address.ToString();
} else throw new ArgumentException("info argument is set to unsupported implementation", nameof(info));
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_REVOKE_URL, BaseURL, Uri.EscapeDataString(type), Uri.EscapeDataString(target), Uri.EscapeDataString(now));
string sig = string.Format(BANS_REVOKE_SIG, now, type, target);
HttpRequestMessage request = new(HttpMethod.Delete, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
if(response.StatusCode == HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return response.StatusCode == HttpStatusCode.NoContent;
}
private const string BANS_CHECK_URL = "{0}/bans/check?u={1}&a={2}&x={3}&n={4}";
private const string BANS_CHECK_SIG = "check#{0}#{1}#{2}#{3}";
public async Task<BanInfo?> BanGetAsync(string? userIdOrName = null, IPAddress? remoteAddr = null) {
userIdOrName ??= "0";
remoteAddr ??= IPAddress.None;
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
bool usingUserName = string.IsNullOrEmpty(userIdOrName) || userIdOrName.Any(c => c is < '0' or > '9');
string remoteAddrStr = remoteAddr.ToString();
string usingUserNameStr = usingUserName ? "1" : "0";
string url = string.Format(BANS_CHECK_URL, BaseURL, Uri.EscapeDataString(userIdOrName), Uri.EscapeDataString(remoteAddrStr), Uri.EscapeDataString(now), Uri.EscapeDataString(usingUserNameStr));
string sig = string.Format(BANS_CHECK_SIG, now, userIdOrName, remoteAddrStr, usingUserNameStr);
HttpRequestMessage request = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
FlashiiRawBanInfo? rawBanInfo = await JsonSerializer.DeserializeAsync<FlashiiRawBanInfo>(stream);
if(rawBanInfo?.IsBanned != true || rawBanInfo.HasExpired)
return null;
return rawBanInfo.RemoteAddress is null or "::"
? new FlashiiUserBanInfo(rawBanInfo)
: new FlashiiIPAddressBanInfo(rawBanInfo);
}
private const string BANS_LIST_URL = "{0}/bans/list?x={1}";
private const string BANS_LIST_SIG = "list#{0}";
public async Task<BanInfo[]> BanGetListAsync() {
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_LIST_URL, BaseURL, Uri.EscapeDataString(now));
string sig = string.Format(BANS_LIST_SIG, now);
HttpRequestMessage request = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
using Stream stream = await response.Content.ReadAsStreamAsync();
FlashiiRawBanInfo[]? list = await JsonSerializer.DeserializeAsync<FlashiiRawBanInfo[]>(stream);
if(list is null || list.Length < 1)
return [];
return [.. list.Where(b => b?.IsBanned == true && !b.HasExpired).Select(b => {
return (BanInfo)(b.RemoteAddress is null or "::"
? new FlashiiUserBanInfo(b) : new FlashiiIPAddressBanInfo(b));
})];
}
}
}

View file

@ -0,0 +1,9 @@
using SharpChat.Bans;
using System.Net;
namespace SharpChat.Flashii {
public class FlashiiIPAddressBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.IPAddress, rawBanInfo), IPAddressBanInfo {
public IPAddress Address { get; } = IPAddress.TryParse(rawBanInfo.RemoteAddress, out IPAddress? addr) && addr is not null ? addr : IPAddress.IPv6None;
public override string ToString() => Address.ToString();
}
}

View file

@ -1,13 +1,20 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace SharpChat.Misuzu { namespace SharpChat.Flashii {
public class MisuzuBanInfo { public class FlashiiRawBanInfo {
[JsonPropertyName("is_ban")] [JsonPropertyName("is_ban")]
public bool IsBanned { get; set; } public bool IsBanned { get; set; }
[JsonPropertyName("user_id")] [JsonPropertyName("user_id")]
public string? UserId { get; set; } public string? UserId { get; set; }
[JsonPropertyName("user_name")]
public string? UserName { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
[JsonPropertyName("ip_addr")] [JsonPropertyName("ip_addr")]
public string? RemoteAddress { get; set; } public string? RemoteAddress { get; set; }
@ -17,15 +24,6 @@ namespace SharpChat.Misuzu {
[JsonPropertyName("expires")] [JsonPropertyName("expires")]
public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset ExpiresAt { get; set; }
// only populated in list request
[JsonPropertyName("user_name")]
public string? UserName { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt; public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
} }
} }

View file

@ -0,0 +1,10 @@
using SharpChat.Bans;
namespace SharpChat.Flashii {
public class FlashiiUserBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.User, rawBanInfo), UserBanInfo {
public string UserId { get; } = rawBanInfo.UserId ?? string.Empty;
public string UserName { get; } = rawBanInfo.UserName ?? $"({rawBanInfo.UserId ?? string.Empty})";
public ColourInheritable UserColour { get; } = ColourInheritable.FromMisuzu(rawBanInfo.UserColourRaw);
public override string ToString() => UserName;
}
}

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
</ItemGroup>
</Project>

View file

@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChatCommon", "SharpChatCommon\SharpChatCommon.csproj", "{C8B619A7-7815-426D-B459-20EE26F7460E}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChatCommon", "SharpChatCommon\SharpChatCommon.csproj", "{C8B619A7-7815-426D-B459-20EE26F7460E}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.Flashii", "SharpChat.Misuzu\SharpChat.Flashii.csproj", "{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -31,6 +33,10 @@ Global
{C8B619A7-7815-426D-B459-20EE26F7460E}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8B619A7-7815-426D-B459-20EE26F7460E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C8B619A7-7815-426D-B459-20EE26F7460E}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8B619A7-7815-426D-B459-20EE26F7460E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8B619A7-7815-426D-B459-20EE26F7460E}.Release|Any CPU.Build.0 = Release|Any CPU {C8B619A7-7815-426D-B459-20EE26F7460E}.Release|Any CPU.Build.0 = Release|Any CPU
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View file

@ -1,15 +1,16 @@
using SharpChat.Config; using SharpChat.Auth;
using SharpChat.Misuzu; using SharpChat.Bans;
using SharpChat.Configuration;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
namespace SharpChat.C2SPacketHandlers { namespace SharpChat.C2SPacketHandlers {
public class AuthC2SPacketHandler( public class AuthC2SPacketHandler(
MisuzuClient msz, AuthClient authClient,
BansClient bansClient,
Channel defaultChannel, Channel defaultChannel,
CachedValue<int> maxMsgLength, CachedValue<int> maxMsgLength,
CachedValue<int> maxConns CachedValue<int> maxConns
) : C2SPacketHandler { ) : C2SPacketHandler {
private readonly MisuzuClient Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
private readonly Channel DefaultChannel = defaultChannel ?? throw new ArgumentNullException(nameof(defaultChannel)); private readonly Channel DefaultChannel = defaultChannel ?? throw new ArgumentNullException(nameof(defaultChannel));
private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength)); private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
private readonly CachedValue<int> MaxConnections = maxConns ?? throw new ArgumentNullException(nameof(maxConns)); private readonly CachedValue<int> MaxConnections = maxConns ?? throw new ArgumentNullException(nameof(maxConns));
@ -22,14 +23,9 @@ namespace SharpChat.C2SPacketHandlers {
string[] args = ctx.SplitText(3); string[] args = ctx.SplitText(3);
string? authMethod = args.ElementAtOrDefault(1); string? authMethod = args.ElementAtOrDefault(1);
if(string.IsNullOrWhiteSpace(authMethod)) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Connection.Dispose();
return;
}
string? authToken = args.ElementAtOrDefault(2); string? authToken = args.ElementAtOrDefault(2);
if(string.IsNullOrWhiteSpace(authToken)) {
if(string.IsNullOrWhiteSpace(authMethod) || string.IsNullOrWhiteSpace(authToken)) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid)); ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Connection.Dispose(); ctx.Connection.Dispose();
return; return;
@ -42,93 +38,75 @@ namespace SharpChat.C2SPacketHandlers {
} }
Task.Run(async () => { Task.Run(async () => {
MisuzuAuthInfo? fai;
string ipAddr = ctx.Connection.RemoteAddress.ToString();
try { try {
fai = await Misuzu.AuthVerifyAsync(authMethod, authToken, ipAddr); AuthResult authResult = await authClient.AuthVerifyAsync(
} catch(Exception ex) { ctx.Connection.RemoteAddress,
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}"); authMethod,
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid)); authToken
ctx.Connection.Dispose(); );
#if DEBUG
throw;
#else
return;
#endif
}
if(fai?.Success != true) { BanInfo? banInfo = await bansClient.BanGetAsync(authResult.UserId, ctx.Connection.RemoteAddress);
Logger.Debug($"<{ctx.Connection.Id}> Auth fail: {fai?.Reason ?? "unknown"}"); if(banInfo is not null) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid)); Logger.Write($"<{ctx.Connection.Id}> User is banned.");
ctx.Connection.Dispose(); ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Banned, banInfo.IsPermanent ? DateTimeOffset.MaxValue : banInfo.ExpiresAt));
return;
}
MisuzuBanInfo? fbi;
try {
fbi = await Misuzu.CheckBanAsync(fai.UserId.ToString(), ipAddr);
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed auth ban check: {ex}");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Connection.Dispose();
#if DEBUG
throw;
#else
return;
#endif
}
if(fbi?.IsBanned == true && !fbi.HasExpired) {
Logger.Write($"<{ctx.Connection.Id}> User is banned.");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Banned, fbi.IsPermanent ? DateTimeOffset.MaxValue : fbi.ExpiresAt));
ctx.Connection.Dispose();
return;
}
await ctx.Chat.ContextAccess.WaitAsync();
try {
User? user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == fai.UserId);
if(user == null)
user = new User(
fai.UserId,
fai.UserName ?? $"({fai.UserId})",
fai.Colour,
fai.Rank,
fai.Permissions
);
else
ctx.Chat.UpdateUser(
user,
userName: fai.UserName ?? $"({fai.UserId})",
colour: fai.Colour,
rank: fai.Rank,
perms: fai.Permissions
);
// Enforce a maximum amount of connections per user
if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.MaxSessions));
ctx.Connection.Dispose(); ctx.Connection.Dispose();
return; return;
} }
ctx.Connection.BumpPing(); await ctx.Chat.ContextAccess.WaitAsync();
ctx.Connection.User = user; try {
ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, $"Welcome to Flashii Chat, {user.UserName}!")); User? user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == authResult.UserId);
if(File.Exists("welcome.txt")) { if(user == null)
IEnumerable<string> lines = File.ReadAllLines("welcome.txt").Where(x => !string.IsNullOrWhiteSpace(x)); user = new User(
string? line = lines.ElementAtOrDefault(RNG.Next(lines.Count())); authResult.UserId,
authResult.UserName ?? $"({authResult.UserId})",
authResult.UserColour,
authResult.UserRank,
authResult.UserPermissions
);
else
ctx.Chat.UpdateUser(
user,
userName: authResult.UserName ?? $"({authResult.UserId})",
colour: authResult.UserColour,
rank: authResult.UserRank,
perms: authResult.UserPermissions
);
if(!string.IsNullOrWhiteSpace(line)) // Enforce a maximum amount of connections per user
ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line)); if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.MaxSessions));
ctx.Connection.Dispose();
return;
}
ctx.Connection.BumpPing();
ctx.Connection.User = user;
ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, $"Welcome to Flashii Chat, {user.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))
ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line));
}
ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength);
} finally {
ctx.Chat.ContextAccess.Release();
} }
} catch(AuthFailedException ex) {
ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength); Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
} finally { ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Chat.ContextAccess.Release(); ctx.Connection.Dispose();
throw;
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Exception));
ctx.Connection.Dispose();
throw;
} }
}).Wait(); }).Wait();
} }

View file

@ -1,10 +1,9 @@
using SharpChat.Misuzu; using SharpChat.Auth;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
using System.Net;
namespace SharpChat.C2SPacketHandlers { namespace SharpChat.C2SPacketHandlers {
public class PingC2SPacketHandler(MisuzuClient msz) : C2SPacketHandler { public class PingC2SPacketHandler(AuthClient authClient) : C2SPacketHandler {
private readonly MisuzuClient Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1); private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
private DateTimeOffset LastBump = DateTimeOffset.MinValue; private DateTimeOffset LastBump = DateTimeOffset.MinValue;
@ -24,13 +23,13 @@ namespace SharpChat.C2SPacketHandlers {
ctx.Chat.ContextAccess.Wait(); ctx.Chat.ContextAccess.Wait();
try { try {
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) { if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
(string, string)[] bumpList = [.. ctx.Chat.Users (IPAddress, string)[] bumpList = [.. ctx.Chat.Users
.Where(u => u.Status == UserStatus.Online && ctx.Chat.Connections.Any(c => c.User == u)) .Where(u => u.Status == UserStatus.Online && ctx.Chat.Connections.Any(c => c.User == u))
.Select(u => (u.UserId.ToString(), ctx.Chat.GetRemoteAddresses(u).FirstOrDefault()?.ToString() ?? string.Empty))]; .Select(u => (ctx.Chat.GetRemoteAddresses(u).FirstOrDefault() ?? IPAddress.None, u.UserId))];
if(bumpList.Length > 0) if(bumpList.Length > 0)
Task.Run(async () => { Task.Run(async () => {
await Misuzu.BumpUsersOnlineAsync(bumpList); await authClient.AuthBumpUsersOnlineAsync(bumpList);
}).Wait(); }).Wait();
LastBump = DateTimeOffset.UtcNow; LastBump = DateTimeOffset.UtcNow;

View file

@ -1,4 +1,4 @@
using SharpChat.Config; using SharpChat.Configuration;
using SharpChat.Events; using SharpChat.Events;
using SharpChat.Snowflake; using SharpChat.Snowflake;
using System.Globalization; using System.Globalization;
@ -35,7 +35,7 @@ namespace SharpChat.C2SPacketHandlers {
return; return;
// Extra validation step, not necessary at all but enforces proper formatting in SCv1. // Extra validation step, not necessary at all but enforces proper formatting in SCv1.
if(!long.TryParse(args[1], out long mUserId) || user.UserId != mUserId) if(!long.TryParse(args[1], out long mUserId) || user.UserId != mUserId.ToString())
return; return;
ctx.Chat.ContextAccess.Wait(); ctx.Chat.ContextAccess.Wait();

View file

@ -1,16 +1,16 @@
namespace SharpChat { namespace SharpChat {
public class Channel( public class Channel(
string name, string name,
string password = "", string password = "",
bool isTemporary = false, bool isTemporary = false,
int rank = 0, int rank = 0,
long ownerId = 0 string ownerId = ""
) { ) {
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
public string Password { get; set; } = password ?? string.Empty; public string Password { get; set; } = password ?? string.Empty;
public bool IsTemporary { get; set; } = isTemporary; public bool IsTemporary { get; set; } = isTemporary;
public int Rank { get; set; } = rank; public int Rank { get; set; } = rank;
public long OwnerId { get; set; } = ownerId; public string OwnerId { get; set; } = ownerId;
public bool HasPassword public bool HasPassword
=> !string.IsNullOrWhiteSpace(Password); => !string.IsNullOrWhiteSpace(Password);
@ -20,7 +20,7 @@
} }
public bool IsOwner(User user) { public bool IsOwner(User user) {
return OwnerId > 0 return string.IsNullOrEmpty(OwnerId)
&& user != null && user != null
&& OwnerId == user.UserId; && OwnerId == user.UserId;
} }

View file

@ -1,10 +1,8 @@
using SharpChat.Misuzu; using SharpChat.Bans;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands {
public class BanListClientCommand(MisuzuClient msz) : ClientCommand { public class BanListClientCommand(BansClient bansClient) : ClientCommand {
private readonly MisuzuClient Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
public bool IsMatch(ClientCommandContext ctx) { public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("bans") return ctx.NameEquals("bans")
|| ctx.NameEquals("banned"); || ctx.NameEquals("banned");
@ -19,18 +17,15 @@ namespace SharpChat.ClientCommands {
} }
Task.Run(async () => { Task.Run(async () => {
MisuzuBanInfo[]? mbis = await Misuzu.GetBanListAsync(); try {
if(mbis is null) BanInfo[] banInfos = await bansClient.BanGetListAsync();
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
else
ctx.Chat.SendTo(ctx.User, new BanListS2CPacket( ctx.Chat.SendTo(ctx.User, new BanListS2CPacket(
msgId, msgId,
mbis.Where(mbi => mbi.IsBanned && !mbi.HasExpired) banInfos.Select(bi => new BanListS2CPacket.Entry(bi.Kind, bi.ToString()))
.Select(mbi => new BanListS2CPacket.Entry(
BanListS2CPacket.Type.UserName, // Misuzu currently only does username bans so we can just do this
mbi.UserName ?? $"({mbi.UserId})"
))
)); ));
} catch(Exception) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
}
}).Wait(); }).Wait();
} }
} }

View file

@ -1,10 +1,9 @@
using SharpChat.Misuzu; using SharpChat.Bans;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
using System.Net;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands {
public class KickBanClientCommand(MisuzuClient msz) : ClientCommand { public class KickBanClientCommand(BansClient bansClient) : ClientCommand {
private readonly MisuzuClient Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
public bool IsMatch(ClientCommandContext ctx) { public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("kick") return ctx.NameEquals("kick")
|| ctx.NameEquals("ban"); || ctx.NameEquals("ban");
@ -53,25 +52,20 @@ namespace SharpChat.ClientCommands {
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex)); string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
Task.Run(async () => { Task.Run(async () => {
string userId = banUser.UserId.ToString(); BanInfo? banInfo = await bansClient.BanGetAsync(banUser.UserId);
string userIp = ctx.Chat.GetRemoteAddresses(banUser).FirstOrDefault()?.ToString() ?? string.Empty; if(banInfo is not null) {
// obviously it makes no sense to only check for one ip address but that's current misuzu limitations
MisuzuBanInfo? fbi = await Misuzu.CheckBanAsync(userId, userIp);
if(fbi is null) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
return;
}
if(fbi.IsBanned && !fbi.HasExpired) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
return; return;
} }
await Misuzu.CreateBanAsync( await bansClient.BanCreateAsync(
userId, userIp, BanKind.User,
ctx.User.UserId.ToString(), ctx.Connection.RemoteAddress.ToString(), duration,
duration, banReason ctx.Chat.GetRemoteAddresses(banUser).FirstOrDefault() ?? IPAddress.None,
banUser.UserId,
banReason,
ctx.Connection.RemoteAddress,
ctx.User.UserId
); );
ctx.Chat.BanUser(banUser, duration); ctx.Chat.BanUser(banUser, duration);

View file

@ -1,4 +1,4 @@
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
@ -24,7 +24,7 @@ namespace SharpChat.ClientCommands {
int offset = 0; int offset = 0;
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) { if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == targetUserId); targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == targetUserId.ToString());
++offset; ++offset;
} }

View file

@ -1,11 +1,9 @@
using SharpChat.Misuzu; using SharpChat.Bans;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands {
public class PardonAddressClientCommand(MisuzuClient msz) : ClientCommand { public class PardonAddressClientCommand(BansClient bansClient) : ClientCommand {
private readonly MisuzuClient Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
public bool IsMatch(ClientCommandContext ctx) { public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pardonip") return ctx.NameEquals("pardonip")
|| ctx.NameEquals("unbanip"); || ctx.NameEquals("unbanip");
@ -28,19 +26,13 @@ namespace SharpChat.ClientCommands {
unbanAddrTarget = unbanAddr.ToString(); unbanAddrTarget = unbanAddr.ToString();
Task.Run(async () => { Task.Run(async () => {
MisuzuBanInfo? banInfo = await Misuzu.CheckBanAsync(ipAddr: unbanAddrTarget); BanInfo? banInfo = await bansClient.BanGetAsync(remoteAddr: unbanAddr);
if(banInfo is null) { if(banInfo?.Kind != BanKind.IPAddress) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
return;
}
if(!banInfo.IsBanned || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
return; return;
} }
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.RemoteAddress); if(await bansClient.BanRevokeAsync(banInfo))
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanAddrTarget)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanAddrTarget));
else else
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));

View file

@ -1,10 +1,9 @@
using SharpChat.Misuzu; using SharpChat.Bans;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
using System.Text.Json;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands {
public class PardonUserClientCommand(MisuzuClient msz) : ClientCommand { public class PardonUserClientCommand(BansClient bansClient) : ClientCommand {
private readonly MisuzuClient Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
public bool IsMatch(ClientCommandContext ctx) { public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pardon") return ctx.NameEquals("pardon")
|| ctx.NameEquals("unban"); || ctx.NameEquals("unban");
@ -18,39 +17,32 @@ namespace SharpChat.ClientCommands {
return; return;
} }
bool unbanUserTargetIsName = true;
string? unbanUserTarget = ctx.Args.FirstOrDefault(); string? unbanUserTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanUserTarget)) { if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return; return;
} }
string unbanUserDisplay = unbanUserTarget;
User? unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget)); User? unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget));
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId)) { if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId))
unbanUserTargetIsName = false; unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == unbanUserId.ToString());
unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == unbanUserId); if(unbanUser != null) {
unbanUserTarget = unbanUser.UserId;
unbanUserDisplay = unbanUser.UserName;
} }
if(unbanUser != null)
unbanUserTarget = unbanUser.UserId.ToString();
Task.Run(async () => { Task.Run(async () => {
MisuzuBanInfo? banInfo = await Misuzu.CheckBanAsync(unbanUserTarget, userIdIsName: unbanUserTargetIsName); BanInfo? banInfo = await bansClient.BanGetAsync(unbanUserTarget);
if(banInfo is null) { if(banInfo?.Kind != BanKind.User) {
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
return; return;
} }
if(!banInfo.IsBanned || banInfo.HasExpired) { if(await bansClient.BanRevokeAsync(banInfo))
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserTarget)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanUserDisplay));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.UserId);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanUserTarget));
else else
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserTarget)); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
}).Wait(); }).Wait();
} }
} }

View file

@ -1,4 +1,4 @@
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands {
public class ShutdownRestartClientCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) : ClientCommand { public class ShutdownRestartClientCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) : ClientCommand {
@ -11,7 +11,7 @@ namespace SharpChat.ClientCommands {
} }
public void Dispatch(ClientCommandContext ctx) { public void Dispatch(ClientCommandContext ctx) {
if(ctx.User.UserId != 1) { if(!ctx.User.UserId.Equals("1")) {
long msgId = ctx.Chat.RandomSnowflake.Next(); long msgId = ctx.Chat.RandomSnowflake.Next();
ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return; return;

View file

@ -1,4 +1,4 @@
using SharpChat.Events; using SharpChat.Events;
using SharpChat.EventStorage; using SharpChat.EventStorage;
using SharpChat.S2CPackets; using SharpChat.S2CPackets;
using SharpChat.Snowflake; using SharpChat.Snowflake;
@ -6,7 +6,7 @@ using System.Net;
namespace SharpChat { namespace SharpChat {
public class Context { public class Context {
public record ChannelUserAssoc(long UserId, string ChannelName); public record ChannelUserAssoc(string UserId, string ChannelName);
public readonly SemaphoreSlim ContextAccess = new(1, 1); public readonly SemaphoreSlim ContextAccess = new(1, 1);
@ -18,8 +18,8 @@ namespace SharpChat {
public HashSet<User> Users { get; } = []; public HashSet<User> Users { get; } = [];
public EventStorage.EventStorage Events { get; } public EventStorage.EventStorage Events { get; }
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = []; public HashSet<ChannelUserAssoc> ChannelUsers { get; } = [];
public Dictionary<long, RateLimiter> UserRateLimiters { get; } = []; public Dictionary<string, RateLimiter> UserRateLimiters { get; } = [];
public Dictionary<long, Channel> UserLastChannel { get; } = []; public Dictionary<string, Channel> UserLastChannel { get; } = [];
public Context(EventStorage.EventStorage evtStore) { public Context(EventStorage.EventStorage evtStore) {
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore)); Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
@ -38,7 +38,7 @@ namespace SharpChat {
if(!mce.ChannelName.StartsWith('@')) if(!mce.ChannelName.StartsWith('@'))
return; return;
IEnumerable<long> uids = mce.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1); IEnumerable<string> uids = mce.ChannelName[1..].Split('-', 3).Select(u => (long.TryParse(u, out long up) ? up : -1).ToString());
if(uids.Count() != 2) if(uids.Count() != 2)
return; return;
@ -120,12 +120,12 @@ namespace SharpChat {
return [.. Channels.Where(c => names.Any(n => c.NameEquals(n)))]; return [.. Channels.Where(c => names.Any(n => c.NameEquals(n)))];
} }
public long[] GetChannelUserIds(Channel channel) { public string[] GetChannelUserIds(Channel channel) {
return [.. ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId)]; return [.. ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId)];
} }
public User[] GetChannelUsers(Channel channel) { public User[] GetChannelUsers(Channel channel) {
long[] ids = GetChannelUserIds(channel); string[] ids = GetChannelUserIds(channel);
return [.. Users.Where(u => ids.Contains(u.UserId))]; return [.. Users.Where(u => ids.Contains(u.UserId))];
} }

View file

@ -1,10 +1,10 @@
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage {
public interface EventStorage { public interface EventStorage {
void AddEvent( void AddEvent(
long id, long id,
string type, string type,
string channelName, string channelName,
long senderId, string senderId,
string senderName, string senderName,
ColourInheritable senderColour, ColourInheritable senderColour,
int senderRank, int senderRank,

View file

@ -1,4 +1,4 @@
using MySqlConnector; using MySqlConnector;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
@ -10,7 +10,7 @@ namespace SharpChat.EventStorage {
long id, long id,
string type, string type,
string channelName, string channelName,
long senderId, string senderId,
string senderName, string senderName,
ColourInheritable senderColour, ColourInheritable senderColour,
int senderRank, int senderRank,
@ -19,8 +19,6 @@ namespace SharpChat.EventStorage {
object? data = null, object? data = null,
StoredEventFlags flags = StoredEventFlags.None StoredEventFlags flags = StoredEventFlags.None
) { ) {
ArgumentNullException.ThrowIfNull(type);
RunCommand( RunCommand(
"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`" "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`)" + ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
@ -31,7 +29,7 @@ namespace SharpChat.EventStorage {
new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName), new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new MySqlParameter("flags", (byte)flags), new MySqlParameter("flags", (byte)flags),
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)), new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
new MySqlParameter("sender", senderId < 1 ? null : senderId), new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
new MySqlParameter("sender_name", senderName), new MySqlParameter("sender_name", senderName),
new MySqlParameter("sender_colour", senderColour.ToMisuzu()), new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
new MySqlParameter("sender_rank", senderRank), new MySqlParameter("sender_rank", senderRank),
@ -72,7 +70,7 @@ namespace SharpChat.EventStorage {
reader.GetInt64("event_id"), reader.GetInt64("event_id"),
Encoding.ASCII.GetString((byte[])reader["event_type"]), Encoding.ASCII.GetString((byte[])reader["event_type"]),
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new User( reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new User(
reader.GetInt64("event_sender"), reader.GetInt64("event_sender").ToString(),
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"), reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")), ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")),
reader.GetInt32("event_sender_rank"), reader.GetInt32("event_sender_rank"),

View file

@ -1,9 +1,9 @@
using MySqlConnector; using MySqlConnector;
using SharpChat.Config; using SharpChat.Configuration;
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage { public partial class MariaDBEventStorage {
public static string BuildConnString(Config.Config config) { public static string BuildConnString(Configuration.Config config) {
return BuildConnString( return BuildConnString(
config.ReadValue("host", "localhost"), config.ReadValue("host", "localhost"),
config.ReadValue("user", string.Empty), config.ReadValue("user", string.Empty),

View file

@ -1,4 +1,4 @@
using System.Text.Json; using System.Text.Json;
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage {
public class VirtualEventStorage : EventStorage { public class VirtualEventStorage : EventStorage {
@ -8,7 +8,7 @@ namespace SharpChat.EventStorage {
long id, long id,
string type, string type,
string channelName, string channelName,
long senderId, string senderId,
string senderName, string senderName,
ColourInheritable senderColour, ColourInheritable senderColour,
int senderRank, int senderRank,
@ -17,18 +17,28 @@ namespace SharpChat.EventStorage {
object? data = null, object? data = null,
StoredEventFlags flags = StoredEventFlags.None StoredEventFlags flags = StoredEventFlags.None
) { ) {
ArgumentNullException.ThrowIfNull(type); Events.Add(
id,
// VES is meant as an emergency fallback but this is something else new(
JsonDocument hack = JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data)); id,
Events.Add(id, new(id, type, senderId < 1 ? null : new User( type,
senderId, long.TryParse(senderId, out long senderId64) && senderId64 > 0
senderName, ? new User(
senderColour, senderId,
senderRank, senderName,
senderPerms, senderColour,
senderNick senderRank,
), DateTimeOffset.Now, null, channelName, hack, flags)); senderPerms,
senderNick
)
: null,
DateTimeOffset.Now,
null,
channelName,
JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data)),
flags
)
);
} }
public StoredEventInfo? GetEvent(long seqId) { public StoredEventInfo? GetEvent(long seqId) {

View file

@ -1,8 +1,8 @@
namespace SharpChat.Events { namespace SharpChat.Events {
public class MessageCreateEvent( public class MessageCreateEvent(
long msgId, long msgId,
string channelName, string channelName,
long senderId, string senderId,
string senderName, string senderName,
ColourInheritable senderColour, ColourInheritable senderColour,
int senderRank, int senderRank,
@ -16,7 +16,7 @@
) : ChatEvent { ) : ChatEvent {
public long MessageId { get; } = msgId; public long MessageId { get; } = msgId;
public string ChannelName { get; } = channelName; public string ChannelName { get; } = channelName;
public long SenderId { get; } = senderId; public string SenderId { get; } = senderId;
public string SenderName { get; } = senderName; public string SenderName { get; } = senderName;
public ColourInheritable SenderColour { get; } = senderColour; public ColourInheritable SenderColour { get; } = senderColour;
public int SenderRank { get; } = senderRank; public int SenderRank { get; } = senderRank;

View file

@ -1,28 +0,0 @@
using System.Text.Json.Serialization;
namespace SharpChat.Misuzu {
public class MisuzuAuthInfo {
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = "none";
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("username")]
public string? UserName { get; set; }
[JsonPropertyName("colour_raw")]
public int ColourRaw { get; set; }
public ColourInheritable Colour => ColourInheritable.FromMisuzu(ColourRaw);
[JsonPropertyName("hierarchy")]
public int Rank { get; set; }
[JsonPropertyName("perms")]
public UserPermissions Permissions { get; set; }
}
}

View file

@ -1,241 +0,0 @@
using SharpChat.Config;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace SharpChat.Misuzu {
public class MisuzuClient {
private const string DEFAULT_BASE_URL = "https://flashii.net/_sockchat";
private const string DEFAULT_SECRET_KEY = "woomy";
private const string BUMP_ONLINE_URL = "{0}/bump";
private const string AUTH_VERIFY_URL = "{0}/verify";
private const string BANS_CHECK_URL = "{0}/bans/check?u={1}&a={2}&x={3}&n={4}";
private const string BANS_CREATE_URL = "{0}/bans/create";
private const string BANS_REVOKE_URL = "{0}/bans/revoke?t={1}&s={2}&x={3}";
private const string BANS_LIST_URL = "{0}/bans/list?x={1}";
private const string VERIFY_SIG = "verify#{0}#{1}#{2}";
private const string BANS_CHECK_SIG = "check#{0}#{1}#{2}#{3}";
private const string BANS_REVOKE_SIG = "revoke#{0}#{1}#{2}";
private const string BANS_CREATE_SIG = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}";
private const string BANS_LIST_SIG = "list#{0}";
private readonly HttpClient HttpClient;
private CachedValue<string> BaseURL { get; }
private CachedValue<string> SecretKey { get; }
public MisuzuClient(HttpClient httpClient, Config.Config config) {
ArgumentNullException.ThrowIfNull(config);
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
BaseURL = config.ReadCached("url", DEFAULT_BASE_URL);
SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
}
public string CreateStringSignature(string str) {
return CreateBufferSignature(Encoding.UTF8.GetBytes(str));
}
public string CreateBufferSignature(byte[] bytes) {
using HMACSHA256 algo = new(Encoding.UTF8.GetBytes(SecretKey!));
return string.Concat(algo.ComputeHash(bytes).Select(c => c.ToString("x2")));
}
public async Task<MisuzuAuthInfo?> AuthVerifyAsync(string method, string token, string ipAddr) {
method ??= string.Empty;
token ??= string.Empty;
ipAddr ??= string.Empty;
string sig = string.Format(VERIFY_SIG, method, token, ipAddr);
HttpRequestMessage req = new(HttpMethod.Post, string.Format(AUTH_VERIFY_URL, BaseURL)) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "method", method },
{ "token", token },
{ "ipaddr", ipAddr },
}),
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
return JsonSerializer.Deserialize<MisuzuAuthInfo>(
await res.Content.ReadAsByteArrayAsync()
);
}
public async Task BumpUsersOnlineAsync(IEnumerable<(string userId, string ipAddr)> list) {
ArgumentNullException.ThrowIfNull(list);
if(!list.Any())
return;
string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
StringBuilder sb = new();
sb.AppendFormat("bump#{0}", now);
Dictionary<string, string> formData = new() {
{ "t", now }
};
foreach(var (userId, ipAddr) in list) {
sb.AppendFormat("#{0}:{1}", userId, ipAddr);
formData.Add(string.Format("u[{0}]", userId), ipAddr);
}
HttpRequestMessage req = new(HttpMethod.Post, string.Format(BUMP_ONLINE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) }
},
Content = new FormUrlEncodedContent(formData),
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
try {
res.EnsureSuccessStatusCode();
} catch(HttpRequestException) {
Logger.Debug(await res.Content.ReadAsStringAsync());
#if DEBUG
throw;
#endif
}
}
public async Task<MisuzuBanInfo?> CheckBanAsync(string? userId = null, string? ipAddr = null, bool userIdIsName = false) {
userId ??= string.Empty;
ipAddr ??= string.Empty;
string userIdIsNameStr = userIdIsName ? "1" : "0";
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_CHECK_URL, BaseURL, Uri.EscapeDataString(userId), Uri.EscapeDataString(ipAddr), Uri.EscapeDataString(now), Uri.EscapeDataString(userIdIsNameStr));
string sig = string.Format(BANS_CHECK_SIG, now, userId, ipAddr, userIdIsNameStr);
HttpRequestMessage req = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
return JsonSerializer.Deserialize<MisuzuBanInfo>(
await res.Content.ReadAsByteArrayAsync()
);
}
public async Task<MisuzuBanInfo[]?> GetBanListAsync() {
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_LIST_URL, BaseURL, Uri.EscapeDataString(now));
string sig = string.Format(BANS_LIST_SIG, now);
HttpRequestMessage req = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
return JsonSerializer.Deserialize<MisuzuBanInfo[]>(
await res.Content.ReadAsByteArrayAsync()
);
}
public enum BanRevokeKind {
UserId,
RemoteAddress,
}
public async Task<bool> RevokeBanAsync(MisuzuBanInfo banInfo, BanRevokeKind kind) {
ArgumentNullException.ThrowIfNull(banInfo);
string type = kind switch {
BanRevokeKind.UserId => "user",
BanRevokeKind.RemoteAddress => "addr",
_ => throw new ArgumentException("Invalid kind specified.", nameof(kind)),
};
string target = kind switch {
BanRevokeKind.UserId => banInfo.UserId,
BanRevokeKind.RemoteAddress => banInfo.RemoteAddress,
_ => null,
} ?? string.Empty;
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_REVOKE_URL, BaseURL, Uri.EscapeDataString(type), Uri.EscapeDataString(target), Uri.EscapeDataString(now));
string sig = string.Format(BANS_REVOKE_SIG, now, type, target);
HttpRequestMessage req = new(HttpMethod.Delete, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
if(res.StatusCode == HttpStatusCode.NotFound)
return false;
res.EnsureSuccessStatusCode();
return res.StatusCode == HttpStatusCode.NoContent;
}
public async Task CreateBanAsync(
string targetId,
string targetAddr,
string modId,
string modAddr,
TimeSpan duration,
string reason
) {
if(string.IsNullOrWhiteSpace(targetAddr))
throw new ArgumentNullException(nameof(targetAddr));
if(string.IsNullOrWhiteSpace(modAddr))
throw new ArgumentNullException(nameof(modAddr));
if(duration <= TimeSpan.Zero)
return;
modId ??= string.Empty;
targetId ??= string.Empty;
reason ??= string.Empty;
string isPerma = duration == TimeSpan.MaxValue ? "1" : "0";
string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString();
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string sig = string.Format(
BANS_CREATE_SIG,
now, targetId, targetAddr,
modId, modAddr,
durationStr, isPerma, reason
);
HttpRequestMessage req = new(HttpMethod.Post, string.Format(BANS_CREATE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "t", now },
{ "ui", targetId },
{ "ua", targetAddr },
{ "mi", modId },
{ "ma", modAddr },
{ "d", durationStr },
{ "p", isPerma },
{ "r", reason },
}),
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
res.EnsureSuccessStatusCode();
}
}
}

View file

@ -1,6 +1,6 @@
using SharpChat.Config; using SharpChat.Configuration;
using SharpChat.EventStorage; using SharpChat.EventStorage;
using SharpChat.Misuzu; using SharpChat.Flashii;
using System.Text; using System.Text;
namespace SharpChat { namespace SharpChat {
@ -52,7 +52,7 @@ namespace SharpChat {
if(hasCancelled) return; if(hasCancelled) return;
MisuzuClient msz = new(httpClient, config.ScopeTo("msz")); FlashiiClient flashii = new(httpClient, config.ScopeTo("msz"));
if(hasCancelled) return; if(hasCancelled) return;
@ -67,7 +67,7 @@ namespace SharpChat {
if(hasCancelled) return; if(hasCancelled) return;
using SockChatServer scs = new(httpClient, msz, evtStore, config.ScopeTo("chat")); using SockChatServer scs = new(httpClient, flashii, flashii, evtStore, config.ScopeTo("chat"));
scs.Listen(mre); scs.Listen(mre);
mre.WaitOne(); mre.WaitOne();

View file

@ -1,4 +1,4 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class AuthFailS2CPacket( public class AuthFailS2CPacket(
@ -9,6 +9,7 @@ namespace SharpChat.S2CPackets {
AuthInvalid, AuthInvalid,
MaxSessions, MaxSessions,
Banned, Banned,
Exception,
} }
public string Pack() { public string Pack() {
@ -21,6 +22,9 @@ namespace SharpChat.S2CPackets {
default: default:
sb.Append("authfail"); sb.Append("authfail");
break; break;
case Reason.Exception:
sb.Append("userfail");
break;
case Reason.MaxSessions: case Reason.MaxSessions:
sb.Append("sockfail"); sb.Append("sockfail");
break; break;

View file

@ -1,8 +1,8 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class AuthSuccessS2CPacket( public class AuthSuccessS2CPacket(
long userId, string userId,
string userName, string userName,
ColourInheritable userColour, ColourInheritable userColour,
int userRank, int userRank,

View file

@ -1,16 +1,12 @@
using System.Text; using SharpChat.Bans;
using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class BanListS2CPacket( public class BanListS2CPacket(
long msgId, long msgId,
IEnumerable<BanListS2CPacket.Entry> entries IEnumerable<BanListS2CPacket.Entry> entries
) : S2CPacket { ) : S2CPacket {
public enum Type { public record Entry(BanKind type, string value);
UserName,
IPAddress,
}
public record Entry(Type type, string value);
public string Pack() { public string Pack() {
StringBuilder sb = new(); StringBuilder sb = new();
@ -20,7 +16,7 @@ namespace SharpChat.S2CPackets {
sb.Append("\t-1\t0\fbanlist\f"); sb.Append("\t-1\t0\fbanlist\f");
sb.Append(string.Join(", ", entries.Select(entry => string.Format( sb.Append(string.Join(", ", entries.Select(entry => string.Format(
@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('{0} '+ this.innerHTML);"">{1}</a>", @"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('{0} '+ this.innerHTML);"">{1}</a>",
entry.type == Type.IPAddress ? "/unbanip" : "/unban", entry.type == BanKind.IPAddress ? "/unbanip" : "/unban",
entry.value entry.value
)))); ))));
sb.Append('\t'); sb.Append('\t');

View file

@ -1,10 +1,10 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class ChatMessageAddS2CPacket( public class ChatMessageAddS2CPacket(
long msgId, long msgId,
DateTimeOffset created, DateTimeOffset created,
long userId, string userId,
string text, string text,
bool isAction, bool isAction,
bool isPrivate bool isPrivate

View file

@ -1,8 +1,8 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class ContextUsersS2CPacket(IEnumerable<ContextUsersS2CPacket.Entry> entries) : S2CPacket { public class ContextUsersS2CPacket(IEnumerable<ContextUsersS2CPacket.Entry> entries) : S2CPacket {
public record Entry(long id, string name, ColourInheritable colour, int rank, UserPermissions perms, bool visible); public record Entry(string id, string name, ColourInheritable colour, int rank, UserPermissions perms, bool visible);
public string Pack() { public string Pack() {
StringBuilder sb = new(); StringBuilder sb = new();

View file

@ -1,9 +1,9 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class UserChannelJoinS2CPacket( public class UserChannelJoinS2CPacket(
long msgId, long msgId,
long userId, string userId,
string userName, string userName,
ColourInheritable userColour, ColourInheritable userColour,
int userRank, int userRank,

View file

@ -1,7 +1,7 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class UserChannelLeaveS2CPacket(long msgId, long userId) : S2CPacket { public class UserChannelLeaveS2CPacket(long msgId, string userId) : S2CPacket {
public string Pack() { public string Pack() {
StringBuilder sb = new(); StringBuilder sb = new();

View file

@ -1,10 +1,10 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class UserConnectS2CPacket( public class UserConnectS2CPacket(
long msgId, long msgId,
DateTimeOffset joined, DateTimeOffset joined,
long userId, string userId,
string userName, string userName,
ColourInheritable userColour, ColourInheritable userColour,
int userRank, int userRank,

View file

@ -1,10 +1,10 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class UserDisconnectS2CPacket( public class UserDisconnectS2CPacket(
long msgId, long msgId,
DateTimeOffset disconnected, DateTimeOffset disconnected,
long userId, string userId,
string userName, string userName,
UserDisconnectS2CPacket.Reason reason UserDisconnectS2CPacket.Reason reason
) : S2CPacket { ) : S2CPacket {

View file

@ -1,8 +1,8 @@
using System.Text; using System.Text;
namespace SharpChat.S2CPackets { namespace SharpChat.S2CPackets {
public class UserUpdateS2CPacket( public class UserUpdateS2CPacket(
long userId, string userId,
string userName, string userName,
ColourInheritable userColour, ColourInheritable userColour,
int userRank, int userRank,

View file

@ -33,6 +33,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SharpChat.Misuzu\SharpChat.Flashii.csproj" />
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" /> <ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -1,10 +1,11 @@
using Fleck; using Fleck;
using SharpChat.ClientCommands; using SharpChat.Auth;
using SharpChat.Config; using SharpChat.Bans;
using SharpChat.EventStorage;
using SharpChat.Misuzu;
using SharpChat.S2CPackets;
using SharpChat.C2SPacketHandlers; using SharpChat.C2SPacketHandlers;
using SharpChat.ClientCommands;
using SharpChat.Configuration;
using SharpChat.S2CPackets;
using System.Net;
namespace SharpChat { namespace SharpChat {
public class SockChatServer : IDisposable { public class SockChatServer : IDisposable {
@ -18,7 +19,7 @@ namespace SharpChat {
public Context Context { get; } public Context Context { get; }
private readonly HttpClient HttpClient; private readonly HttpClient HttpClient;
private readonly MisuzuClient Misuzu; private readonly BansClient BansClient;
private readonly CachedValue<int> MaxMessageLength; private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections; private readonly CachedValue<int> MaxConnections;
@ -35,11 +36,17 @@ namespace SharpChat {
private Channel DefaultChannel { get; set; } private Channel DefaultChannel { get; set; }
public SockChatServer(HttpClient httpClient, MisuzuClient msz, EventStorage.EventStorage evtStore, Config.Config config) { public SockChatServer(
HttpClient httpClient,
AuthClient authClient,
BansClient bansClient,
EventStorage.EventStorage evtStore,
Config config
) {
Logger.Write("Initialising Sock Chat server..."); Logger.Write("Initialising Sock Chat server...");
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); HttpClient = httpClient;
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz)); BansClient = bansClient;
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX); MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS); MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
@ -51,7 +58,7 @@ namespace SharpChat {
string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS); string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS);
if(channelNames is not null) if(channelNames is not null)
foreach(string channelName in channelNames) { foreach(string channelName in channelNames) {
Config.Config channelCfg = config.ScopeTo($"channels:{channelName}"); Config channelCfg = config.ScopeTo($"channels:{channelName}");
string name = channelCfg.SafeReadValue("name", string.Empty)!; string name = channelCfg.SafeReadValue("name", string.Empty)!;
if(string.IsNullOrWhiteSpace(name)) if(string.IsNullOrWhiteSpace(name))
@ -70,10 +77,10 @@ namespace SharpChat {
if(DefaultChannel is null) if(DefaultChannel is null)
throw new Exception("The default channel could not be determined."); throw new Exception("The default channel could not be determined.");
GuestHandlers.Add(new AuthC2SPacketHandler(Misuzu, DefaultChannel, MaxMessageLength, MaxConnections)); GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, DefaultChannel, MaxMessageLength, MaxConnections));
AuthedHandlers.AddRange([ AuthedHandlers.AddRange([
new PingC2SPacketHandler(Misuzu), new PingC2SPacketHandler(authClient),
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength), SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
]); ]);
@ -90,10 +97,10 @@ namespace SharpChat {
new RankChannelClientCommand(), new RankChannelClientCommand(),
new BroadcastClientCommand(), new BroadcastClientCommand(),
new DeleteMessageClientCommand(), new DeleteMessageClientCommand(),
new KickBanClientCommand(msz), new KickBanClientCommand(bansClient),
new PardonUserClientCommand(msz), new PardonUserClientCommand(bansClient),
new PardonAddressClientCommand(msz), new PardonAddressClientCommand(bansClient),
new BanListClientCommand(msz), new BanListClientCommand(bansClient),
new RemoteAddressClientCommand(), new RemoteAddressClientCommand(),
]); ]);
@ -184,11 +191,13 @@ namespace SharpChat {
Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood); Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood);
if(banDuration > TimeSpan.Zero) if(banDuration > TimeSpan.Zero)
Misuzu.CreateBanAsync( BansClient.BanCreateAsync(
conn.User.UserId.ToString(), conn.RemoteAddress.ToString(), BanKind.User,
string.Empty, "::1",
banDuration, banDuration,
"Kicked from chat for flood protection." conn.RemoteAddress,
conn.User.UserId,
"Kicked from chat for flood protection.",
IPAddress.IPv6Loopback
).Wait(); ).Wait();
return; return;

View file

@ -1,10 +1,10 @@
using SharpChat.ClientCommands; using SharpChat.ClientCommands;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace SharpChat { namespace SharpChat {
public class User( public class User(
long userId, string userId,
string userName, string userName,
ColourInheritable colour, ColourInheritable colour,
int rank, int rank,
@ -17,7 +17,7 @@ namespace SharpChat {
public const int DEFAULT_MINIMUM_DELAY = 10000; public const int DEFAULT_MINIMUM_DELAY = 10000;
public const int DEFAULT_RISKY_OFFSET = 5; public const int DEFAULT_RISKY_OFFSET = 5;
public long UserId { get; } = userId; public string UserId { get; } = userId;
public string UserName { get; set; } = userName ?? throw new ArgumentNullException(nameof(userName)); public string UserName { get; set; } = userName ?? throw new ArgumentNullException(nameof(userName));
public ColourInheritable Colour { get; set; } = colour; public ColourInheritable Colour { get; set; } = colour;
public int Rank { get; set; } = rank; public int Rank { get; set; } = rank;
@ -65,9 +65,9 @@ namespace SharpChat {
} }
public static string GetDMChannelName(User user1, User user2) { public static string GetDMChannelName(User user1, User user2) {
return user1.UserId < user2.UserId return string.Compare(user1.UserId, user2.UserId, StringComparison.InvariantCultureIgnoreCase) > 0
? $"@{user1.UserId}-{user2.UserId}" ? $"@{user2.UserId}-{user1.UserId}"
: $"@{user2.UserId}-{user1.UserId}"; : $"@{user1.UserId}-{user2.UserId}";
} }
} }
} }

View file

@ -0,0 +1,8 @@
using System.Net;
namespace SharpChat.Auth {
public interface AuthClient {
Task<AuthResult> AuthVerifyAsync(IPAddress remoteAddr, string scheme, string token);
Task AuthBumpUsersOnlineAsync(IEnumerable<(IPAddress remoteAddr, string userId)> entries);
}
}

View file

@ -0,0 +1,3 @@
namespace SharpChat.Auth {
public class AuthFailedException(string message) : Exception(message) {}
}

View file

@ -0,0 +1,9 @@
namespace SharpChat.Auth {
public interface AuthResult {
string UserId { get; }
string UserName { get; }
ColourInheritable UserColour { get; }
int UserRank { get; }
UserPermissions UserPermissions { get; }
}
}

View file

@ -0,0 +1,9 @@
namespace SharpChat.Bans {
public interface BanInfo {
BanKind Kind { get; }
bool IsPermanent { get; }
DateTimeOffset ExpiresAt { get; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
string ToString();
}
}

View file

@ -0,0 +1,6 @@
namespace SharpChat.Bans {
public enum BanKind {
User,
IPAddress,
}
}

View file

@ -0,0 +1,18 @@
using System.Net;
namespace SharpChat.Bans {
public interface BansClient {
Task BanCreateAsync(
BanKind kind,
TimeSpan duration,
IPAddress remoteAddr,
string? userId = null,
string? reason = null,
IPAddress? issuerRemoteAddr = null,
string? issuerUserId = null
);
Task<bool> BanRevokeAsync(BanInfo info);
Task<BanInfo?> BanGetAsync(string? userIdOrName = null, IPAddress? remoteAddr = null);
Task<BanInfo[]> BanGetListAsync();
}
}

View file

@ -0,0 +1,7 @@
using System.Net;
namespace SharpChat.Bans {
public interface IPAddressBanInfo : BanInfo {
IPAddress Address { get; }
}
}

View file

@ -0,0 +1,7 @@
namespace SharpChat.Bans {
public interface UserBanInfo : BanInfo {
string UserId { get; }
string UserName { get; }
ColourInheritable UserColour { get; }
}
}

View file

@ -1,4 +1,4 @@
namespace SharpChat.Config { namespace SharpChat.Configuration {
public class CachedValue<T>(Config config, string name, TimeSpan lifetime, T? fallback) { public class CachedValue<T>(Config config, string name, TimeSpan lifetime, T? fallback) {
private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config)); private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
private string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); private string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));

View file

@ -1,4 +1,4 @@
namespace SharpChat.Config { namespace SharpChat.Configuration {
public interface Config : IDisposable { public interface Config : IDisposable {
/// <summary> /// <summary>
/// Creates a proxy object that forces all names to start with the given prefix. /// Creates a proxy object that forces all names to start with the given prefix.

View file

@ -1,4 +1,4 @@
namespace SharpChat.Config { namespace SharpChat.Configuration {
public abstract class ConfigException : Exception { public abstract class ConfigException : Exception {
public ConfigException(string message) : base(message) { } public ConfigException(string message) : base(message) { }
public ConfigException(string message, Exception ex) : base(message, ex) { } public ConfigException(string message, Exception ex) : base(message, ex) { }

View file

@ -1,4 +1,4 @@
namespace SharpChat.Config { namespace SharpChat.Configuration {
public class ScopedConfig(Config config, string prefix) : Config { public class ScopedConfig(Config config, string prefix) : Config {
private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config)); private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
private string Prefix { get; } = prefix ?? throw new ArgumentNullException(nameof(prefix)); private string Prefix { get; } = prefix ?? throw new ArgumentNullException(nameof(prefix));

View file

@ -1,6 +1,6 @@
using System.Text; using System.Text;
namespace SharpChat.Config { namespace SharpChat.Configuration {
public class StreamConfig : Config { public class StreamConfig : Config {
private Stream Stream { get; } private Stream Stream { get; }
private StreamReader StreamReader { get; } private StreamReader StreamReader { get; }