Switched to file namespace declarations.

This commit is contained in:
flash 2025-04-26 23:15:54 +00:00
parent 6593929827
commit 34e4e9b1a9
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
93 changed files with 3470 additions and 3471 deletions
.editorconfig
SharpChat.Flashii
SharpChat.SockChat
SharpChat
SharpChatCommon

View file

@ -15,7 +15,7 @@ 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_namespace_declarations = file_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

View file

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

View file

@ -1,13 +1,13 @@
using SharpChat.Bans;
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();
}
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

@ -6,235 +6,235 @@ 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);
namespace SharpChat.Flashii;
private const string DEFAULT_SECRET_KEY = "woomy";
private readonly CachedValue<string> SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
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 string CreateStringSignature(string str) {
return CreateBufferSignature(Encoding.UTF8.GetBytes(str));
}
private const string DEFAULT_SECRET_KEY = "woomy";
private readonly CachedValue<string> SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
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 string CreateStringSignature(string str) {
return CreateBufferSignature(Encoding.UTF8.GetBytes(str));
}
private const string AUTH_VERIFY_URL = "{0}/verify";
private const string AUTH_VERIFY_SIG = "verify#{0}#{1}#{2}";
private 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<AuthResult> AuthVerifyAsync(IPAddress remoteAddr, string scheme, string token) {
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();
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;
sb.AppendFormat("#{0}:{1}", userId, remoteAddrStr);
formData.Add(string.Format("u[{0}]", userId), remoteAddrStr);
}
private const string AUTH_BUMP_USERS_ONLINE_URL = "{0}/bump";
HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_BUMP_USERS_ONLINE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) }
},
Content = new FormUrlEncodedContent(formData),
};
public async Task AuthBumpUsersOnlineAsync(IEnumerable<(IPAddress remoteAddr, string userId)> entries) {
if(!entries.Any())
return;
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
StringBuilder sb = new();
sb.AppendFormat("bump#{0}", now);
private const string BANS_CREATE_URL = "{0}/bans/create";
private const string BANS_CREATE_SIG = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}";
Dictionary<string, string> formData = new() {
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 },
}),
};
foreach(var (remoteAddr, userId) in entries) {
string remoteAddrStr = remoteAddr.ToString();
sb.AppendFormat("#{0}:{1}", userId, remoteAddrStr);
formData.Add(string.Format("u[{0}]", userId), remoteAddrStr);
}
using HttpResponseMessage response = await httpClient.SendAsync(request);
HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_BUMP_USERS_ONLINE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) }
},
Content = new FormUrlEncodedContent(formData),
};
response.EnsureSuccessStatusCode();
}
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}";
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<bool> BanRevokeAsync(BanInfo info) {
string type;
string target;
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;
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));
issuerUserId ??= string.Empty;
userId ??= string.Empty;
reason ??= string.Empty;
issuerRemoteAddr ??= IPAddress.IPv6None;
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));
string isPerma = duration == TimeSpan.MaxValue ? "1" : "0";
string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString();
string remoteAddrStr = remoteAddr.ToString();
string issuerRemoteAddrStr = issuerRemoteAddr.ToString();
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 sig = string.Format(
BANS_CREATE_SIG,
now, userId, remoteAddrStr,
issuerUserId, issuerRemoteAddrStr,
durationStr, isPerma, reason
);
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.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 },
}),
};
HttpRequestMessage request = new(HttpMethod.Delete, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
using HttpResponseMessage response = await httpClient.SendAsync(request);
if(response.StatusCode == HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
}
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}";
return response.StatusCode == HttpStatusCode.NoContent;
}
public async Task<bool> BanRevokeAsync(BanInfo info) {
string type;
string target;
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}";
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));
public async Task<BanInfo?> BanGetAsync(string? userIdOrName = null, IPAddress? remoteAddr = null) {
userIdOrName ??= "0";
remoteAddr ??= IPAddress.None;
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));
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);
type = "addr";
target = iabi.Address.ToString();
} else throw new ArgumentException("info argument is set to unsupported implementation", nameof(info));
HttpRequestMessage request = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
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);
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
HttpRequestMessage request = new(HttpMethod.Delete, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using Stream stream = await response.Content.ReadAsStreamAsync();
FlashiiRawBanInfo? rawBanInfo = await JsonSerializer.DeserializeAsync<FlashiiRawBanInfo>(stream);
if(rawBanInfo?.IsBanned != true || rawBanInfo.HasExpired)
return null;
using HttpResponseMessage response = await httpClient.SendAsync(request);
if(response.StatusCode == HttpStatusCode.NotFound)
return false;
return rawBanInfo.RemoteAddress is null or "::"
? new FlashiiUserBanInfo(rawBanInfo)
: new FlashiiIPAddressBanInfo(rawBanInfo);
}
response.EnsureSuccessStatusCode();
private const string BANS_LIST_URL = "{0}/bans/list?x={1}";
private const string BANS_LIST_SIG = "list#{0}";
return response.StatusCode == HttpStatusCode.NoContent;
}
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);
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}";
HttpRequestMessage request = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
public async Task<BanInfo?> BanGetAsync(string? userIdOrName = null, IPAddress? remoteAddr = null) {
userIdOrName ??= "0";
remoteAddr ??= IPAddress.None;
using HttpResponseMessage response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
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);
using Stream stream = await response.Content.ReadAsStreamAsync();
FlashiiRawBanInfo[]? list = await JsonSerializer.DeserializeAsync<FlashiiRawBanInfo[]>(stream);
if(list is null || list.Length < 1)
return [];
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));
})];
}
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

@ -1,9 +1,9 @@
using SharpChat.Bans;
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();
}
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,29 +1,29 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace SharpChat.Flashii {
public class FlashiiRawBanInfo {
[JsonPropertyName("is_ban")]
public bool IsBanned { get; set; }
namespace SharpChat.Flashii;
[JsonPropertyName("user_id")]
public string? UserId { get; set; }
public class FlashiiRawBanInfo {
[JsonPropertyName("is_ban")]
public bool IsBanned { get; set; }
[JsonPropertyName("user_name")]
public string? UserName { get; set; }
[JsonPropertyName("user_id")]
public string? UserId { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
[JsonPropertyName("user_name")]
public string? UserName { get; set; }
[JsonPropertyName("ip_addr")]
public string? RemoteAddress { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
[JsonPropertyName("is_perma")]
public bool IsPermanent { get; set; }
[JsonPropertyName("ip_addr")]
public string? RemoteAddress { get; set; }
[JsonPropertyName("expires")]
public DateTimeOffset ExpiresAt { get; set; }
[JsonPropertyName("is_perma")]
public bool IsPermanent { get; set; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
}
[JsonPropertyName("expires")]
public DateTimeOffset ExpiresAt { get; set; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
}

View file

@ -1,10 +1,10 @@
using SharpChat.Bans;
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;
}
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

@ -1,5 +1,5 @@
namespace SharpChat.SockChat {
public interface S2CPacket {
string Pack();
}
namespace SharpChat.SockChat;
public interface S2CPacket {
string Pack();
}

View file

@ -1,43 +1,43 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class AuthFailS2CPacket(
AuthFailS2CPacket.Reason reason,
DateTimeOffset? expiresAt = null
) : S2CPacket {
public enum Reason {
AuthInvalid,
MaxSessions,
Banned,
Exception,
namespace SharpChat.SockChat.S2CPackets;
public class AuthFailS2CPacket(
AuthFailS2CPacket.Reason reason,
DateTimeOffset? expiresAt = null
) : S2CPacket {
public enum Reason {
AuthInvalid,
MaxSessions,
Banned,
Exception,
}
public string Pack() {
StringBuilder sb = new();
sb.Append("1\tn\t");
switch(reason) {
case Reason.AuthInvalid:
default:
sb.Append("authfail");
break;
case Reason.Exception:
sb.Append("userfail");
break;
case Reason.MaxSessions:
sb.Append("sockfail");
break;
case Reason.Banned:
sb.Append("joinfail\t");
if(expiresAt is null || expiresAt == DateTimeOffset.MaxValue)
sb.Append("-1");
else
sb.Append(expiresAt.Value.ToUnixTimeSeconds());
break;
}
public string Pack() {
StringBuilder sb = new();
sb.Append("1\tn\t");
switch(reason) {
case Reason.AuthInvalid:
default:
sb.Append("authfail");
break;
case Reason.Exception:
sb.Append("userfail");
break;
case Reason.MaxSessions:
sb.Append("sockfail");
break;
case Reason.Banned:
sb.Append("joinfail\t");
if(expiresAt is null || expiresAt == DateTimeOffset.MaxValue)
sb.Append("-1");
else
sb.Append(expiresAt.Value.ToUnixTimeSeconds());
break;
}
return sb.ToString();
}
return sb.ToString();
}
}

View file

@ -1,40 +1,40 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class AuthSuccessS2CPacket(
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms,
string channelName,
int maxMsgLength
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("1\ty\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(channelName);
sb.Append('\t');
sb.Append(maxMsgLength);
public class AuthSuccessS2CPacket(
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms,
string channelName,
int maxMsgLength
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("1\ty\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(channelName);
sb.Append('\t');
sb.Append(maxMsgLength);
return sb.ToString();
}
}

View file

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

View file

@ -1,22 +1,22 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ChannelCreateS2CPacket(
string name,
bool hasPassword,
bool isTemporary
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("4\t0\t");
sb.Append(name);
sb.Append('\t');
sb.Append(hasPassword ? '1' : '0');
sb.Append('\t');
sb.Append(isTemporary ? '1' : '0');
public class ChannelCreateS2CPacket(
string name,
bool hasPassword,
bool isTemporary
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("4\t0\t");
sb.Append(name);
sb.Append('\t');
sb.Append(hasPassword ? '1' : '0');
sb.Append('\t');
sb.Append(isTemporary ? '1' : '0');
return sb.ToString();
}
}

View file

@ -1,16 +1,16 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ChannelDeleteS2CPacket(
string channelName
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("4\t2\t");
sb.Append(channelName);
public class ChannelDeleteS2CPacket(
string channelName
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("4\t2\t");
sb.Append(channelName);
return sb.ToString();
}
}

View file

@ -1,25 +1,25 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ChannelUpdateS2CPacket(
string previousName,
string newName,
bool hasPassword,
bool isTemporary
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("4\t1\t");
sb.Append(previousName);
sb.Append('\t');
sb.Append(newName);
sb.Append('\t');
sb.Append(hasPassword ? '1' : '0');
sb.Append('\t');
sb.Append(isTemporary ? '1' : '0');
public class ChannelUpdateS2CPacket(
string previousName,
string newName,
bool hasPassword,
bool isTemporary
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("4\t1\t");
sb.Append(previousName);
sb.Append('\t');
sb.Append(newName);
sb.Append('\t');
sb.Append(hasPassword ? '1' : '0');
sb.Append('\t');
sb.Append(isTemporary ? '1' : '0');
return sb.ToString();
}
}

View file

@ -1,48 +1,48 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ChatMessageAddS2CPacket(
long msgId,
DateTimeOffset created,
string userId,
string text,
bool isAction,
bool isPrivate
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("2\t");
public class ChatMessageAddS2CPacket(
long msgId,
DateTimeOffset created,
string userId,
string text,
bool isAction,
bool isPrivate
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append(created.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append("2\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(created.ToUnixTimeSeconds());
sb.Append('\t');
if(isAction)
sb.Append("<i>");
sb.Append(userId);
sb.Append('\t');
sb.Append(
text.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")
);
if(isAction)
sb.Append("<i>");
if(isAction)
sb.Append("</i>");
sb.Append(
text.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")
);
sb.Append('\t');
sb.Append(msgId);
sb.AppendFormat(
"\t1{0}0{1}{2}",
isAction ? '1' : '0',
isAction ? '0' : '1',
isPrivate ? '1' : '0'
);
if(isAction)
sb.Append("</i>");
return sb.ToString();
}
sb.Append('\t');
sb.Append(msgId);
sb.AppendFormat(
"\t1{0}0{1}{2}",
isAction ? '1' : '0',
isAction ? '0' : '1',
isPrivate ? '1' : '0'
);
return sb.ToString();
}
}

View file

@ -1,14 +1,14 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ChatMessageDeleteS2CPacket(long eventId) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("6\t");
sb.Append(eventId);
public class ChatMessageDeleteS2CPacket(long eventId) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("6\t");
sb.Append(eventId);
return sb.ToString();
}
}

View file

@ -1,85 +1,85 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class CommandResponseS2CPacket(
long msgId,
string stringId,
bool isError = true,
params object[] args
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
if(stringId == LCR.WELCOME) {
sb.Append("7\t1\t");
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\tChatBot\tinherit\t\t");
} else {
sb.Append("2\t");
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t");
public class CommandResponseS2CPacket(
long msgId,
string stringId,
bool isError = true,
params object[] args
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
if(stringId == LCR.WELCOME) {
sb.Append("7\t1\t");
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\tChatBot\tinherit\t\t");
} else {
sb.Append("2\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(args.Length > 0)
foreach(object arg in args) {
sb.Append('\f');
sb.Append(arg);
}
sb.Append(isError ? '1' : '0');
sb.Append('\f');
sb.Append(stringId == LCR.WELCOME ? LCR.BROADCAST : stringId);
sb.Append('\t');
if(args.Length > 0)
foreach(object arg in args) {
sb.Append('\f');
sb.Append(arg);
}
if(stringId == LCR.WELCOME) {
sb.Append(stringId);
sb.Append("\t0");
} else
sb.Append(msgId);
sb.Append('\t');
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'
);*/
if(stringId == LCR.WELCOME) {
sb.Append(stringId);
sb.Append("\t0");
} else
sb.Append(msgId);
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'
);*/
return sb.ToString();
}
}
// Abbreviated class name because otherwise shit gets wide
public static class LCR {
public const string GENERIC_ERROR = "generr";
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 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";
public const string FLOOD_WARN = "flwarn";
public const string NICKNAME_CHANGE = "nick";
return sb.ToString();
}
}
// Abbreviated class name because otherwise shit gets wide
public static class LCR {
public const string GENERIC_ERROR = "generr";
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 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";
public const string FLOOD_WARN = "flwarn";
public const string NICKNAME_CHANGE = "nick";
}

View file

@ -1,25 +1,25 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ContextChannelsS2CPacket(IEnumerable<ContextChannelsS2CPacket.Entry> entries) : S2CPacket {
public record Entry(string name, bool hasPassword, bool isTemporary);
namespace SharpChat.SockChat.S2CPackets;
public string Pack() {
StringBuilder sb = new();
public class ContextChannelsS2CPacket(IEnumerable<ContextChannelsS2CPacket.Entry> entries) : S2CPacket {
public record Entry(string name, bool hasPassword, bool isTemporary);
sb.Append("7\t2\t");
sb.Append(entries.Count());
public string Pack() {
StringBuilder sb = new();
foreach(Entry entry in entries) {
sb.Append('\t');
sb.Append(entry.name);
sb.Append('\t');
sb.Append(entry.hasPassword ? '1' : '0');
sb.Append('\t');
sb.Append(entry.isTemporary ? '1' : '0');
}
sb.Append("7\t2\t");
sb.Append(entries.Count());
return sb.ToString();
foreach(Entry entry in entries) {
sb.Append('\t');
sb.Append(entry.name);
sb.Append('\t');
sb.Append(entry.hasPassword ? '1' : '0');
sb.Append('\t');
sb.Append(entry.isTemporary ? '1' : '0');
}
return sb.ToString();
}
}

View file

@ -1,22 +1,22 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ContextClearS2CPacket(ContextClearS2CPacket.Mode mode) : S2CPacket {
public enum Mode {
Messages = 0,
Users = 1,
Channels = 2,
MessagesUsers = 3,
MessagesUsersChannels = 4,
}
namespace SharpChat.SockChat.S2CPackets;
public string Pack() {
StringBuilder sb = new();
public class ContextClearS2CPacket(ContextClearS2CPacket.Mode mode) : S2CPacket {
public enum Mode {
Messages = 0,
Users = 1,
Channels = 2,
MessagesUsers = 3,
MessagesUsersChannels = 4,
}
sb.Append("8\t");
sb.Append((int)mode);
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("8\t");
sb.Append((int)mode);
return sb.ToString();
}
}

View file

@ -1,37 +1,37 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ContextUsersS2CPacket(IEnumerable<ContextUsersS2CPacket.Entry> entries) : S2CPacket {
public record Entry(string id, string name, ColourInheritable colour, int rank, UserPermissions perms, bool visible);
namespace SharpChat.SockChat.S2CPackets;
public string Pack() {
StringBuilder sb = new();
public class ContextUsersS2CPacket(IEnumerable<ContextUsersS2CPacket.Entry> entries) : S2CPacket {
public record Entry(string id, string name, ColourInheritable colour, int rank, UserPermissions perms, bool visible);
sb.Append("7\t0\t");
sb.Append(entries.Count());
public string Pack() {
StringBuilder sb = new();
foreach(Entry entry in entries) {
sb.Append('\t');
sb.Append(entry.id);
sb.Append('\t');
sb.Append(entry.name);
sb.Append('\t');
sb.Append(entry.colour);
sb.Append('\t');
sb.Append(entry.rank);
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.CreateChannel) ? (entry.perms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(entry.visible ? '1' : '0');
}
sb.Append("7\t0\t");
sb.Append(entries.Count());
return sb.ToString();
foreach(Entry entry in entries) {
sb.Append('\t');
sb.Append(entry.id);
sb.Append('\t');
sb.Append(entry.name);
sb.Append('\t');
sb.Append(entry.colour);
sb.Append('\t');
sb.Append(entry.rank);
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(entry.perms.HasFlag(UserPermissions.CreateChannel) ? (entry.perms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(entry.visible ? '1' : '0');
}
return sb.ToString();
}
}

View file

@ -1,22 +1,22 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ForceDisconnectS2CPacket(DateTimeOffset? expires = null) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("9\t");
public class ForceDisconnectS2CPacket(DateTimeOffset? expires = null) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
if(expires.HasValue && expires.Value > DateTimeOffset.UtcNow) {
sb.Append("1\t");
if(expires.Value < DateTimeOffset.MaxValue)
sb.Append(expires.Value.ToUnixTimeSeconds());
else
sb.Append("-1");
} else
sb.Append('0');
return sb.ToString();
}
sb.Append("9\t");
if(expires.HasValue && expires.Value > DateTimeOffset.UtcNow) {
sb.Append("1\t");
if(expires.Value < DateTimeOffset.MaxValue)
sb.Append(expires.Value.ToUnixTimeSeconds());
else
sb.Append("-1");
} else
sb.Append('0');
return sb.ToString();
}
}

View file

@ -1,7 +1,7 @@
namespace SharpChat.SockChat.S2CPackets {
public class PongS2CPacket : S2CPacket {
public string Pack() {
return "0\tpong";
}
namespace SharpChat.SockChat.S2CPackets;
public class PongS2CPacket : S2CPacket {
public string Pack() {
return "0\tpong";
}
}

View file

@ -1,14 +1,14 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class UserChannelForceJoinS2CPacket(string channelName) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("5\t2\t");
sb.Append(channelName);
public class UserChannelForceJoinS2CPacket(string channelName) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("5\t2\t");
sb.Append(channelName);
return sb.ToString();
}
}

View file

@ -1,37 +1,37 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class UserChannelJoinS2CPacket(
long msgId,
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("5\t0\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(msgId);
public class UserChannelJoinS2CPacket(
long msgId,
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("5\t0\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(msgId);
return sb.ToString();
}
}

View file

@ -1,16 +1,16 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class UserChannelLeaveS2CPacket(long msgId, string userId) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("5\t1\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(msgId);
public class UserChannelLeaveS2CPacket(long msgId, string userId) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("5\t1\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(msgId);
return sb.ToString();
}
}

View file

@ -1,40 +1,40 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class UserConnectS2CPacket(
long msgId,
DateTimeOffset joined,
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("1\t");
sb.Append(joined.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(msgId);
public class UserConnectS2CPacket(
long msgId,
DateTimeOffset joined,
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("1\t");
sb.Append(joined.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
sb.Append(msgId);
return sb.ToString();
}
}

View file

@ -1,51 +1,51 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class UserDisconnectS2CPacket(
long msgId,
DateTimeOffset disconnected,
string userId,
string userName,
UserDisconnectS2CPacket.Reason reason
) : S2CPacket {
public enum Reason {
Leave,
TimeOut,
Kicked,
Flood,
namespace SharpChat.SockChat.S2CPackets;
public class UserDisconnectS2CPacket(
long msgId,
DateTimeOffset disconnected,
string userId,
string userName,
UserDisconnectS2CPacket.Reason reason
) : S2CPacket {
public enum Reason {
Leave,
TimeOut,
Kicked,
Flood,
}
public string Pack() {
StringBuilder sb = new();
sb.Append("3\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
switch(reason) {
case Reason.Leave:
default:
sb.Append("leave");
break;
case Reason.TimeOut:
sb.Append("timeout");
break;
case Reason.Kicked:
sb.Append("kick");
break;
case Reason.Flood:
sb.Append("flood");
break;
}
public string Pack() {
StringBuilder sb = new();
sb.Append('\t');
sb.Append(disconnected.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(msgId);
sb.Append("3\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
switch(reason) {
case Reason.Leave:
default:
sb.Append("leave");
break;
case Reason.TimeOut:
sb.Append("timeout");
break;
case Reason.Kicked:
sb.Append("kick");
break;
case Reason.Flood:
sb.Append("flood");
break;
}
sb.Append('\t');
sb.Append(disconnected.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(msgId);
return sb.ToString();
}
return sb.ToString();
}
}

View file

@ -1,34 +1,34 @@
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class UserUpdateS2CPacket(
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
namespace SharpChat.SockChat.S2CPackets;
sb.Append("10\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
public class UserUpdateS2CPacket(
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
return sb.ToString();
}
sb.Append("10\t");
sb.Append(userId);
sb.Append('\t');
sb.Append(userName);
sb.Append('\t');
sb.Append(userColour);
sb.Append('\t');
sb.Append(userRank);
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
return sb.ToString();
}
}

View file

@ -1,6 +1,6 @@
namespace SharpChat {
public interface C2SPacketHandler {
bool IsMatch(C2SPacketHandlerContext ctx);
Task Handle(C2SPacketHandlerContext ctx);
}
namespace SharpChat;
public interface C2SPacketHandler {
bool IsMatch(C2SPacketHandlerContext ctx);
Task Handle(C2SPacketHandlerContext ctx);
}

View file

@ -1,19 +1,19 @@
namespace SharpChat {
public class C2SPacketHandlerContext(
string text,
Context chat,
Connection connection
) {
public string Text { get; } = text ?? throw new ArgumentNullException(nameof(text));
public Context Chat { get; } = chat ?? throw new ArgumentNullException(nameof(chat));
public Connection Connection { get; } = connection ?? throw new ArgumentNullException(nameof(connection));
namespace SharpChat;
public bool CheckPacketId(string packetId) {
return Text == packetId || Text.StartsWith(packetId + '\t');
}
public class C2SPacketHandlerContext(
string text,
Context chat,
Connection connection
) {
public string Text { get; } = text ?? throw new ArgumentNullException(nameof(text));
public Context Chat { get; } = chat ?? throw new ArgumentNullException(nameof(chat));
public Connection Connection { get; } = connection ?? throw new ArgumentNullException(nameof(connection));
public string[] SplitText(int expect) {
return Text.Split('\t', expect + 1);
}
public bool CheckPacketId(string packetId) {
return Text == packetId || Text.StartsWith(packetId + '\t');
}
public string[] SplitText(int expect) {
return Text.Split('\t', expect + 1);
}
}

View file

@ -3,110 +3,110 @@ using SharpChat.Bans;
using SharpChat.Configuration;
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.C2SPacketHandlers {
public class AuthC2SPacketHandler(
AuthClient authClient,
BansClient bansClient,
Channel defaultChannel,
CachedValue<int> maxMsgLength,
CachedValue<int> maxConns
) : C2SPacketHandler {
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> MaxConnections = maxConns ?? throw new ArgumentNullException(nameof(maxConns));
namespace SharpChat.C2SPacketHandlers;
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("1");
public class AuthC2SPacketHandler(
AuthClient authClient,
BansClient bansClient,
Channel defaultChannel,
CachedValue<int> maxMsgLength,
CachedValue<int> maxConns
) : C2SPacketHandler {
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> MaxConnections = maxConns ?? throw new ArgumentNullException(nameof(maxConns));
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("1");
}
public async Task Handle(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
string? authMethod = args.ElementAtOrDefault(1);
string? authToken = args.ElementAtOrDefault(2);
if(string.IsNullOrWhiteSpace(authMethod) || string.IsNullOrWhiteSpace(authToken)) {
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Connection.Dispose();
return;
}
public async Task Handle(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) {
string[] tokenParts = authToken.Split(':', 2);
authMethod = tokenParts[0];
authToken = tokenParts[1];
}
string? authMethod = args.ElementAtOrDefault(1);
string? authToken = args.ElementAtOrDefault(2);
try {
AuthResult authResult = await authClient.AuthVerifyAsync(
ctx.Connection.RemoteAddress,
authMethod,
authToken
);
if(string.IsNullOrWhiteSpace(authMethod) || string.IsNullOrWhiteSpace(authToken)) {
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
BanInfo? banInfo = await bansClient.BanGetAsync(authResult.UserId, ctx.Connection.RemoteAddress);
if(banInfo is not null) {
Logger.Write($"<{ctx.Connection.Id}> User is banned.");
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Banned, banInfo.IsPermanent ? DateTimeOffset.MaxValue : banInfo.ExpiresAt));
ctx.Connection.Dispose();
return;
}
if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) {
string[] tokenParts = authToken.Split(':', 2);
authMethod = tokenParts[0];
authToken = tokenParts[1];
}
await ctx.Chat.ContextAccess.WaitAsync();
try {
AuthResult authResult = await authClient.AuthVerifyAsync(
ctx.Connection.RemoteAddress,
authMethod,
authToken
);
User? user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == authResult.UserId);
BanInfo? banInfo = await bansClient.BanGetAsync(authResult.UserId, ctx.Connection.RemoteAddress);
if(banInfo is not null) {
Logger.Write($"<{ctx.Connection.Id}> User is banned.");
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Banned, banInfo.IsPermanent ? DateTimeOffset.MaxValue : banInfo.ExpiresAt));
if(user == null)
user = new User(
authResult.UserId,
authResult.UserName ?? $"({authResult.UserId})",
authResult.UserColour,
authResult.UserRank,
authResult.UserPermissions
);
else
await ctx.Chat.UpdateUser(
user,
userName: authResult.UserName ?? $"({authResult.UserId})",
colour: authResult.UserColour,
rank: authResult.UserRank,
perms: authResult.UserPermissions
);
// Enforce a maximum amount of connections per user
if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) {
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.MaxSessions));
ctx.Connection.Dispose();
return;
}
await ctx.Chat.ContextAccess.WaitAsync();
try {
User? user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == authResult.UserId);
ctx.Connection.BumpPing();
ctx.Connection.User = user;
await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, $"Welcome to Flashii Chat, {user.UserName}!"));
if(user == null)
user = new User(
authResult.UserId,
authResult.UserName ?? $"({authResult.UserId})",
authResult.UserColour,
authResult.UserRank,
authResult.UserPermissions
);
else
await ctx.Chat.UpdateUser(
user,
userName: authResult.UserName ?? $"({authResult.UserId})",
colour: authResult.UserColour,
rank: authResult.UserRank,
perms: authResult.UserPermissions
);
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()));
// Enforce a maximum amount of connections per user
if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) {
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.MaxSessions));
ctx.Connection.Dispose();
return;
}
ctx.Connection.BumpPing();
ctx.Connection.User = user;
await 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))
await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line));
}
await ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength);
} finally {
ctx.Chat.ContextAccess.Release();
if(!string.IsNullOrWhiteSpace(line))
await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line));
}
} catch(AuthFailedException ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Connection.Dispose();
throw;
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Exception));
ctx.Connection.Dispose();
throw;
await ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength);
} finally {
ctx.Chat.ContextAccess.Release();
}
} catch(AuthFailedException ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
ctx.Connection.Dispose();
throw;
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Exception));
ctx.Connection.Dispose();
throw;
}
}
}

View file

@ -2,39 +2,39 @@ using SharpChat.Auth;
using SharpChat.SockChat.S2CPackets;
using System.Net;
namespace SharpChat.C2SPacketHandlers {
public class PingC2SPacketHandler(AuthClient authClient) : C2SPacketHandler {
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
namespace SharpChat.C2SPacketHandlers;
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("0");
}
public class PingC2SPacketHandler(AuthClient authClient) : C2SPacketHandler {
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
public async Task Handle(C2SPacketHandlerContext ctx) {
string[] parts = ctx.SplitText(2);
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("0");
}
if(!int.TryParse(parts.FirstOrDefault(), out int pTime))
return;
public async Task Handle(C2SPacketHandlerContext ctx) {
string[] parts = ctx.SplitText(2);
ctx.Connection.BumpPing();
await ctx.Connection.Send(new PongS2CPacket());
if(!int.TryParse(parts.FirstOrDefault(), out int pTime))
return;
ctx.Chat.ContextAccess.Wait();
try {
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
(IPAddress, string)[] bumpList = [.. ctx.Chat.Users
.Where(u => u.Status == UserStatus.Online && ctx.Chat.Connections.Any(c => c.User == u))
.Select(u => (ctx.Chat.GetRemoteAddresses(u).FirstOrDefault() ?? IPAddress.None, u.UserId))];
ctx.Connection.BumpPing();
await ctx.Connection.Send(new PongS2CPacket());
if(bumpList.Length > 0)
await authClient.AuthBumpUsersOnlineAsync(bumpList);
ctx.Chat.ContextAccess.Wait();
try {
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
(IPAddress, string)[] bumpList = [.. ctx.Chat.Users
.Where(u => u.Status == UserStatus.Online && ctx.Chat.Connections.Any(c => c.User == u))
.Select(u => (ctx.Chat.GetRemoteAddresses(u).FirstOrDefault() ?? IPAddress.None, u.UserId))];
LastBump = DateTimeOffset.UtcNow;
}
} finally {
ctx.Chat.ContextAccess.Release();
if(bumpList.Length > 0)
await authClient.AuthBumpUsersOnlineAsync(bumpList);
LastBump = DateTimeOffset.UtcNow;
}
} finally {
ctx.Chat.ContextAccess.Release();
}
}
}

View file

@ -4,86 +4,86 @@ using SharpChat.Snowflake;
using System.Globalization;
using System.Text;
namespace SharpChat.C2SPacketHandlers {
public class SendMessageC2SPacketHandler(
RandomSnowflake randomSnowflake,
CachedValue<int> maxMsgLength
) : C2SPacketHandler {
private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
namespace SharpChat.C2SPacketHandlers;
private List<ClientCommand> Commands { get; } = [];
public class SendMessageC2SPacketHandler(
RandomSnowflake randomSnowflake,
CachedValue<int> maxMsgLength
) : C2SPacketHandler {
private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
public void AddCommand(ClientCommand command) {
Commands.Add(command ?? throw new ArgumentNullException(nameof(command)));
}
private List<ClientCommand> Commands { get; } = [];
public void AddCommands(IEnumerable<ClientCommand> commands) {
Commands.AddRange(commands ?? throw new ArgumentNullException(nameof(commands)));
}
public void AddCommand(ClientCommand command) {
Commands.Add(command ?? throw new ArgumentNullException(nameof(command)));
}
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("2");
}
public void AddCommands(IEnumerable<ClientCommand> commands) {
Commands.AddRange(commands ?? throw new ArgumentNullException(nameof(commands)));
}
public async Task Handle(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("2");
}
User? user = ctx.Connection.User;
string? messageText = args.ElementAtOrDefault(2);
public async Task Handle(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
if(user == null || !user.Can(UserPermissions.SendMessage) || string.IsNullOrWhiteSpace(messageText))
User? user = ctx.Connection.User;
string? messageText = args.ElementAtOrDefault(2);
if(user == null || !user.Can(UserPermissions.SendMessage) || string.IsNullOrWhiteSpace(messageText))
return;
// Extra validation step, not necessary at all but enforces proper formatting in SCv1.
if(!long.TryParse(args[1], out long mUserId) || user.UserId != mUserId.ToString())
return;
ctx.Chat.ContextAccess.Wait();
try {
if(!ctx.Chat.UserLastChannel.TryGetValue(user.UserId, out Channel? channel)
&& (channel is null || !ctx.Chat.IsInChannel(user, channel)))
return;
// Extra validation step, not necessary at all but enforces proper formatting in SCv1.
if(!long.TryParse(args[1], out long mUserId) || user.UserId != mUserId.ToString())
return;
if(user.Status != UserStatus.Online)
await ctx.Chat.UpdateUser(user, status: UserStatus.Online);
ctx.Chat.ContextAccess.Wait();
try {
if(!ctx.Chat.UserLastChannel.TryGetValue(user.UserId, out Channel? channel)
&& (channel is null || !ctx.Chat.IsInChannel(user, channel)))
return;
int maxMsgLength = MaxMessageLength;
StringInfo messageTextInfo = new(messageText);
if(Encoding.UTF8.GetByteCount(messageText) > (maxMsgLength * 10)
|| messageTextInfo.LengthInTextElements > maxMsgLength)
messageText = messageTextInfo.SubstringByTextElements(0, Math.Min(messageTextInfo.LengthInTextElements, maxMsgLength));
if(user.Status != UserStatus.Online)
await ctx.Chat.UpdateUser(user, status: UserStatus.Online);
int maxMsgLength = MaxMessageLength;
StringInfo messageTextInfo = new(messageText);
if(Encoding.UTF8.GetByteCount(messageText) > (maxMsgLength * 10)
|| messageTextInfo.LengthInTextElements > maxMsgLength)
messageText = messageTextInfo.SubstringByTextElements(0, Math.Min(messageTextInfo.LengthInTextElements, maxMsgLength));
messageText = messageText.Trim();
messageText = messageText.Trim();
#if DEBUG
Logger.Write($"<{ctx.Connection.Id} {user.UserName}> {messageText}");
Logger.Write($"<{ctx.Connection.Id} {user.UserName}> {messageText}");
#endif
if(messageText.StartsWith('/')) {
ClientCommandContext context = new(messageText, ctx.Chat, user, ctx.Connection, channel);
foreach(ClientCommand cmd in Commands)
if(cmd.IsMatch(context)) {
await cmd.Dispatch(context);
return;
}
}
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
randomSnowflake.Next(),
channel.Name,
user.UserId,
user.UserName,
user.Colour,
user.Rank,
user.NickName,
user.Permissions,
DateTimeOffset.Now,
messageText,
false, false, false
));
} finally {
ctx.Chat.ContextAccess.Release();
if(messageText.StartsWith('/')) {
ClientCommandContext context = new(messageText, ctx.Chat, user, ctx.Connection, channel);
foreach(ClientCommand cmd in Commands)
if(cmd.IsMatch(context)) {
await cmd.Dispatch(context);
return;
}
}
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
randomSnowflake.Next(),
channel.Name,
user.UserId,
user.UserName,
user.Colour,
user.Rank,
user.NickName,
user.Permissions,
DateTimeOffset.Now,
messageText,
false, false, false
));
} finally {
ctx.Chat.ContextAccess.Release();
}
}
}

View file

@ -1,40 +1,40 @@
namespace SharpChat {
public class Channel(
string name,
string password = "",
bool isTemporary = false,
int rank = 0,
string ownerId = ""
) {
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
public string Password { get; set; } = password ?? string.Empty;
public bool IsTemporary { get; set; } = isTemporary;
public int Rank { get; set; } = rank;
public string OwnerId { get; set; } = ownerId;
namespace SharpChat;
public bool HasPassword
=> !string.IsNullOrWhiteSpace(Password);
public class Channel(
string name,
string password = "",
bool isTemporary = false,
int rank = 0,
string ownerId = ""
) {
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
public string Password { get; set; } = password ?? string.Empty;
public bool IsTemporary { get; set; } = isTemporary;
public int Rank { get; set; } = rank;
public string OwnerId { get; set; } = ownerId;
public bool NameEquals(string name) {
return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase);
}
public bool HasPassword
=> !string.IsNullOrWhiteSpace(Password);
public bool IsOwner(User user) {
return string.IsNullOrEmpty(OwnerId)
&& user != null
&& OwnerId == user.UserId;
}
public bool NameEquals(string name) {
return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase);
}
public override int GetHashCode() {
return Name.GetHashCode();
}
public bool IsOwner(User user) {
return string.IsNullOrEmpty(OwnerId)
&& user != null
&& OwnerId == user.UserId;
}
public static bool CheckName(string name) {
return !string.IsNullOrWhiteSpace(name) && name.All(CheckNameChar);
}
public override int GetHashCode() {
return Name.GetHashCode();
}
public static bool CheckNameChar(char c) {
return char.IsLetter(c) || char.IsNumber(c) || c == '-' || c == '_';
}
public static bool CheckName(string name) {
return !string.IsNullOrWhiteSpace(name) && name.All(CheckNameChar);
}
public static bool CheckNameChar(char c) {
return char.IsLetter(c) || char.IsNumber(c) || c == '-' || c == '_';
}
}

View file

@ -1,6 +1,6 @@
namespace SharpChat {
public interface ClientCommand {
bool IsMatch(ClientCommandContext ctx);
Task Dispatch(ClientCommandContext ctx);
}
namespace SharpChat;
public interface ClientCommand {
bool IsMatch(ClientCommandContext ctx);
Task Dispatch(ClientCommandContext ctx);
}

View file

@ -1,49 +1,49 @@
namespace SharpChat {
public class ClientCommandContext {
public string Name { get; }
public string[] Args { get; }
public Context Chat { get; }
public User User { get; }
public Connection Connection { get; }
public Channel Channel { get; }
namespace SharpChat;
public ClientCommandContext(
string text,
Context chat,
User user,
Connection connection,
Channel channel
) {
ArgumentNullException.ThrowIfNull(text);
public class ClientCommandContext {
public string Name { get; }
public string[] Args { get; }
public Context Chat { get; }
public User User { get; }
public Connection Connection { get; }
public Channel Channel { get; }
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
User = user ?? throw new ArgumentNullException(nameof(user));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
public ClientCommandContext(
string text,
Context chat,
User user,
Connection connection,
Channel channel
) {
ArgumentNullException.ThrowIfNull(text);
string[] parts = text[1..].Split(' ');
Name = parts.First().Replace(".", string.Empty);
Args = [.. parts.Skip(1)];
}
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
User = user ?? throw new ArgumentNullException(nameof(user));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
public ClientCommandContext(
string name,
string[] args,
Context chat,
User user,
Connection connection,
Channel channel
) {
Name = name ?? throw new ArgumentNullException(nameof(name));
Args = args ?? throw new ArgumentNullException(nameof(args));
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
User = user ?? throw new ArgumentNullException(nameof(user));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
string[] parts = text[1..].Split(' ');
Name = parts.First().Replace(".", string.Empty);
Args = [.. parts.Skip(1)];
}
public bool NameEquals(string name) {
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase);
}
public ClientCommandContext(
string name,
string[] args,
Context chat,
User user,
Connection connection,
Channel channel
) {
Name = name ?? throw new ArgumentNullException(nameof(name));
Args = args ?? throw new ArgumentNullException(nameof(args));
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
User = user ?? throw new ArgumentNullException(nameof(user));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
public bool NameEquals(string name) {
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase);
}
}

View file

@ -1,34 +1,34 @@
using System.Globalization;
using System.Text;
namespace SharpChat.ClientCommands {
public class AFKClientCommand : ClientCommand {
private const string DEFAULT = "AFK";
public const int MAX_GRAPHEMES = 5;
public const int MAX_BYTES = MAX_GRAPHEMES * 10;
namespace SharpChat.ClientCommands;
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("afk");
public class AFKClientCommand : ClientCommand {
private const string DEFAULT = "AFK";
public const int MAX_GRAPHEMES = 5;
public const int MAX_BYTES = MAX_GRAPHEMES * 10;
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("afk");
}
public async Task Dispatch(ClientCommandContext ctx) {
string? statusText = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
statusText = statusText.Trim();
StringInfo sti = new(statusText);
if(Encoding.UTF8.GetByteCount(statusText) > MAX_BYTES
|| sti.LengthInTextElements > MAX_GRAPHEMES)
statusText = sti.SubstringByTextElements(0, Math.Min(sti.LengthInTextElements, MAX_GRAPHEMES)).Trim();
}
public async Task Dispatch(ClientCommandContext ctx) {
string? statusText = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
statusText = statusText.Trim();
StringInfo sti = new(statusText);
if(Encoding.UTF8.GetByteCount(statusText) > MAX_BYTES
|| sti.LengthInTextElements > MAX_GRAPHEMES)
statusText = sti.SubstringByTextElements(0, Math.Min(sti.LengthInTextElements, MAX_GRAPHEMES)).Trim();
}
await ctx.Chat.UpdateUser(
ctx.User,
status: UserStatus.Away,
statusText: statusText
);
}
await ctx.Chat.UpdateUser(
ctx.User,
status: UserStatus.Away,
statusText: statusText
);
}
}

View file

@ -1,33 +1,33 @@
using SharpChat.Events;
namespace SharpChat.ClientCommands {
public class ActionClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("action")
|| ctx.NameEquals("me");
}
namespace SharpChat.ClientCommands;
public async Task Dispatch(ClientCommandContext ctx) {
if(ctx.Args.Length < 1)
return;
public class ActionClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("action")
|| ctx.NameEquals("me");
}
string actionStr = string.Join(' ', ctx.Args);
if(string.IsNullOrWhiteSpace(actionStr))
return;
public async Task Dispatch(ClientCommandContext ctx) {
if(ctx.Args.Length < 1)
return;
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
ctx.Chat.RandomSnowflake.Next(),
ctx.Channel.Name,
ctx.User.UserId,
ctx.User.UserName,
ctx.User.Colour,
ctx.User.Rank,
ctx.User.NickName,
ctx.User.Permissions,
DateTimeOffset.Now,
actionStr,
false, true, false
));
}
string actionStr = string.Join(' ', ctx.Args);
if(string.IsNullOrWhiteSpace(actionStr))
return;
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
ctx.Chat.RandomSnowflake.Next(),
ctx.Channel.Name,
ctx.User.UserId,
ctx.User.UserName,
ctx.User.Colour,
ctx.User.Rank,
ctx.User.NickName,
ctx.User.Permissions,
DateTimeOffset.Now,
actionStr,
false, true, false
));
}
}

View file

@ -1,30 +1,30 @@
using SharpChat.Bans;
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class BanListClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("bans")
|| ctx.NameEquals("banned");
namespace SharpChat.ClientCommands;
public class BanListClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("bans")
|| ctx.NameEquals("banned");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
try {
BanInfo[] banInfos = await bansClient.BanGetListAsync();
await ctx.Chat.SendTo(ctx.User, new BanListS2CPacket(
msgId,
banInfos.Select(bi => new BanListS2CPacket.Entry(bi.Kind, bi.ToString()))
));
} catch(Exception) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
}
try {
BanInfo[] banInfos = await bansClient.BanGetListAsync();
await ctx.Chat.SendTo(ctx.User, new BanListS2CPacket(
msgId,
banInfos.Select(bi => new BanListS2CPacket.Entry(bi.Kind, bi.ToString()))
));
} catch(Exception) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
}
}
}

View file

@ -1,34 +1,34 @@
using SharpChat.Events;
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class BroadcastClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("say")
|| ctx.NameEquals("broadcast");
namespace SharpChat.ClientCommands;
public class BroadcastClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("say")
|| ctx.NameEquals("broadcast");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.Broadcast)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.Broadcast)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
msgId,
string.Empty,
ctx.User.UserId,
ctx.User.UserName,
ctx.User.Colour,
ctx.User.Rank,
ctx.User.NickName,
ctx.User.Permissions,
DateTimeOffset.Now,
string.Join(' ', ctx.Args),
false, false, true
));
}
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
msgId,
string.Empty,
ctx.User.UserId,
ctx.User.UserName,
ctx.User.Colour,
ctx.User.Rank,
ctx.User.NickName,
ctx.User.Permissions,
DateTimeOffset.Now,
string.Join(' ', ctx.Args),
false, false, true
));
}
}

View file

@ -1,62 +1,62 @@
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class CreateChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("create");
namespace SharpChat.ClientCommands;
public class CreateChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("create");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.CreateChannel)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string firstArg = ctx.Args.First();
if(!ctx.User.Can(UserPermissions.CreateChannel)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string firstArg = ctx.Args.First();
bool createChanHasHierarchy;
if(ctx.Args.Length < 1 || (createChanHasHierarchy = firstArg.All(char.IsDigit) && ctx.Args.Length < 2)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
int createChanHierarchy = 0;
if(createChanHasHierarchy)
if(!int.TryParse(firstArg, out createChanHierarchy))
createChanHierarchy = 0;
if(createChanHierarchy > ctx.User.Rank) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
return;
}
string createChanName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0));
if(!Channel.CheckName(createChanName)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NAME_INVALID));
return;
}
if(ctx.Chat.Channels.Any(c => c.NameEquals(createChanName))) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_ALREADY_EXISTS, true, createChanName));
return;
}
Channel createChan = new(
createChanName,
isTemporary: !ctx.User.Can(UserPermissions.SetChannelPermanent),
rank: createChanHierarchy,
ownerId: ctx.User.UserId
);
ctx.Chat.Channels.Add(createChan);
foreach(User ccu in ctx.Chat.Users.Where(u => u.Rank >= ctx.Channel.Rank))
await ctx.Chat.SendTo(ccu, new ChannelCreateS2CPacket(createChan.Name, createChan.HasPassword, createChan.IsTemporary));
await ctx.Chat.SwitchChannel(ctx.User, createChan, createChan.Password);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_CREATED, false, createChan.Name));
bool createChanHasHierarchy;
if(ctx.Args.Length < 1 || (createChanHasHierarchy = firstArg.All(char.IsDigit) && ctx.Args.Length < 2)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
int createChanHierarchy = 0;
if(createChanHasHierarchy)
if(!int.TryParse(firstArg, out createChanHierarchy))
createChanHierarchy = 0;
if(createChanHierarchy > ctx.User.Rank) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
return;
}
string createChanName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0));
if(!Channel.CheckName(createChanName)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NAME_INVALID));
return;
}
if(ctx.Chat.Channels.Any(c => c.NameEquals(createChanName))) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_ALREADY_EXISTS, true, createChanName));
return;
}
Channel createChan = new(
createChanName,
isTemporary: !ctx.User.Can(UserPermissions.SetChannelPermanent),
rank: createChanHierarchy,
ownerId: ctx.User.UserId
);
ctx.Chat.Channels.Add(createChan);
foreach(User ccu in ctx.Chat.Users.Where(u => u.Rank >= ctx.Channel.Rank))
await ctx.Chat.SendTo(ccu, new ChannelCreateS2CPacket(createChan.Name, createChan.HasPassword, createChan.IsTemporary));
await ctx.Chat.SwitchChannel(ctx.User, createChan, createChan.Password);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_CREATED, false, createChan.Name));
}
}

View file

@ -1,37 +1,37 @@
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class DeleteChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("delchan") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
);
namespace SharpChat.ClientCommands;
public class DeleteChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("delchan") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
);
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(ctx.Args.Length < 1 || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string delChanName = string.Join('_', ctx.Args);
Channel? delChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(delChanName));
if(ctx.Args.Length < 1 || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string delChanName = string.Join('_', ctx.Args);
Channel? delChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(delChanName));
if(delChan == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, delChanName));
return;
}
if(!ctx.User.Can(UserPermissions.DeleteChannel) && delChan.IsOwner(ctx.User)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_DELETE_FAILED, true, delChan.Name));
return;
}
await ctx.Chat.RemoveChannel(delChan);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_DELETED, false, delChan.Name));
if(delChan == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, delChanName));
return;
}
if(!ctx.User.Can(UserPermissions.DeleteChannel) && delChan.IsOwner(ctx.User)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_DELETE_FAILED, true, delChan.Name));
return;
}
await ctx.Chat.RemoveChannel(delChan);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_DELETED, false, delChan.Name));
}
}

View file

@ -1,41 +1,40 @@
using SharpChat.EventStorage;
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands
{
public class DeleteMessageClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("delmsg") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
);
namespace SharpChat.ClientCommands;
public class DeleteMessageClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("delmsg") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
);
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
bool deleteAnyMessage = ctx.User.Can(UserPermissions.DeleteAnyMessage);
if(!deleteAnyMessage && !ctx.User.Can(UserPermissions.DeleteOwnMessage)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
bool deleteAnyMessage = ctx.User.Can(UserPermissions.DeleteAnyMessage);
string? firstArg = ctx.Args.FirstOrDefault();
if(!deleteAnyMessage && !ctx.User.Can(UserPermissions.DeleteOwnMessage)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string? firstArg = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(firstArg) || !firstArg.All(char.IsDigit) || !long.TryParse(firstArg, out long delSeqId)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
StoredEventInfo? delMsg = ctx.Chat.Events.GetEvent(delSeqId);
if(delMsg?.Sender is null || delMsg.Sender.Rank > ctx.User.Rank || (!deleteAnyMessage && delMsg.Sender.UserId != ctx.User.UserId)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.MESSAGE_DELETE_ERROR));
return;
}
ctx.Chat.Events.RemoveEvent(delMsg);
await ctx.Chat.Send(new ChatMessageDeleteS2CPacket(delMsg.Id));
if(string.IsNullOrWhiteSpace(firstArg) || !firstArg.All(char.IsDigit) || !long.TryParse(firstArg, out long delSeqId)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
StoredEventInfo? delMsg = ctx.Chat.Events.GetEvent(delSeqId);
if(delMsg?.Sender is null || delMsg.Sender.Rank > ctx.User.Rank || (!deleteAnyMessage && delMsg.Sender.UserId != ctx.User.UserId)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.MESSAGE_DELETE_ERROR));
return;
}
ctx.Chat.Events.RemoveEvent(delMsg);
await ctx.Chat.Send(new ChatMessageDeleteS2CPacket(delMsg.Id));
}
}

View file

@ -1,23 +1,23 @@
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class JoinChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("join");
namespace SharpChat.ClientCommands;
public class JoinChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("join");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string joinChanStr = ctx.Args.FirstOrDefault() ?? "Channel";
Channel? joinChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(joinChanStr));
if(joinChan is null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, joinChanStr));
await ctx.Chat.ForceChannel(ctx.User);
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string joinChanStr = ctx.Args.FirstOrDefault() ?? "Channel";
Channel? joinChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(joinChanStr));
if(joinChan is null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, joinChanStr));
await ctx.Chat.ForceChannel(ctx.User);
return;
}
await ctx.Chat.SwitchChannel(ctx.User, joinChan, string.Join(' ', ctx.Args.Skip(1)));
}
await ctx.Chat.SwitchChannel(ctx.User, joinChan, string.Join(' ', ctx.Args.Skip(1)));
}
}

View file

@ -2,72 +2,72 @@ using SharpChat.Bans;
using SharpChat.SockChat.S2CPackets;
using System.Net;
namespace SharpChat.ClientCommands {
public class KickBanClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("kick")
|| ctx.NameEquals("ban");
namespace SharpChat.ClientCommands;
public class KickBanClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("kick")
|| ctx.NameEquals("ban");
}
public async Task Dispatch(ClientCommandContext ctx) {
bool isBanning = ctx.NameEquals("ban");
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(isBanning ? UserPermissions.BanUser : UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
bool isBanning = ctx.NameEquals("ban");
long msgId = ctx.Chat.RandomSnowflake.Next();
string? banUserTarget = ctx.Args.ElementAtOrDefault(0);
string? banDurationStr = ctx.Args.ElementAtOrDefault(1);
int banReasonIndex = 1;
User? banUser = null;
if(!ctx.User.Can(isBanning ? UserPermissions.BanUser : UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
if(banUserTarget == null || (banUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(banUserTarget))) == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, banUserTarget ?? "User"));
return;
}
if(banUser.Rank >= ctx.User.Rank && banUser != ctx.User) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
return;
}
TimeSpan duration = isBanning ? TimeSpan.MaxValue : TimeSpan.Zero;
if(!string.IsNullOrWhiteSpace(banDurationStr) && double.TryParse(banDurationStr, out double durationSeconds)) {
if(durationSeconds < 0) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string? banUserTarget = ctx.Args.ElementAtOrDefault(0);
string? banDurationStr = ctx.Args.ElementAtOrDefault(1);
int banReasonIndex = 1;
User? banUser = null;
if(banUserTarget == null || (banUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(banUserTarget))) == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, banUserTarget ?? "User"));
return;
}
if(banUser.Rank >= ctx.User.Rank && banUser != ctx.User) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
return;
}
TimeSpan duration = isBanning ? TimeSpan.MaxValue : TimeSpan.Zero;
if(!string.IsNullOrWhiteSpace(banDurationStr) && double.TryParse(banDurationStr, out double durationSeconds)) {
if(durationSeconds < 0) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
duration = TimeSpan.FromSeconds(durationSeconds);
++banReasonIndex;
}
if(duration <= TimeSpan.Zero) {
await ctx.Chat.BanUser(banUser, duration);
return;
}
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
BanInfo? banInfo = await bansClient.BanGetAsync(banUser.UserId);
if(banInfo is not null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
return;
}
await bansClient.BanCreateAsync(
BanKind.User,
duration,
ctx.Chat.GetRemoteAddresses(banUser).FirstOrDefault() ?? IPAddress.None,
banUser.UserId,
banReason,
ctx.Connection.RemoteAddress,
ctx.User.UserId
);
duration = TimeSpan.FromSeconds(durationSeconds);
++banReasonIndex;
}
if(duration <= TimeSpan.Zero) {
await ctx.Chat.BanUser(banUser, duration);
return;
}
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
BanInfo? banInfo = await bansClient.BanGetAsync(banUser.UserId);
if(banInfo is not null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
return;
}
await bansClient.BanCreateAsync(
BanKind.User,
duration,
ctx.Chat.GetRemoteAddresses(banUser).FirstOrDefault() ?? IPAddress.None,
banUser.UserId,
banReason,
ctx.Connection.RemoteAddress,
ctx.User.UserId
);
await ctx.Chat.BanUser(banUser, duration);
}
}

View file

@ -2,62 +2,62 @@ using SharpChat.SockChat.S2CPackets;
using System.Globalization;
using System.Text;
namespace SharpChat.ClientCommands {
public class NickClientCommand : ClientCommand {
private const int MAX_GRAPHEMES = 16;
private const int MAX_BYTES = MAX_GRAPHEMES * 10;
namespace SharpChat.ClientCommands;
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("nick");
public class NickClientCommand : ClientCommand {
private const int MAX_GRAPHEMES = 16;
private const int MAX_BYTES = MAX_GRAPHEMES * 10;
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("nick");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
bool setOthersNick = ctx.User.Can(UserPermissions.SetOthersNickname);
if(!setOthersNick && !ctx.User.Can(UserPermissions.SetOwnNickname)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
bool setOthersNick = ctx.User.Can(UserPermissions.SetOthersNickname);
User? targetUser = null;
int offset = 0;
if(!setOthersNick && !ctx.User.Can(UserPermissions.SetOwnNickname)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
User? targetUser = null;
int offset = 0;
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == targetUserId.ToString());
++offset;
}
targetUser ??= ctx.User;
if(ctx.Args.Length < offset) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string nickStr = string.Join('_', ctx.Args.Skip(offset))
.Replace("\n", string.Empty).Replace("\r", string.Empty)
.Replace("\f", string.Empty).Replace("\t", string.Empty)
.Replace(' ', '_').Trim();
if(nickStr == targetUser.UserName)
nickStr = string.Empty;
else if(string.IsNullOrEmpty(nickStr))
nickStr = string.Empty;
else {
StringInfo nsi = new(nickStr);
if(Encoding.UTF8.GetByteCount(nickStr) > MAX_BYTES
|| nsi.LengthInTextElements > MAX_GRAPHEMES)
nickStr = nsi.SubstringByTextElements(0, Math.Min(nsi.LengthInTextElements, MAX_GRAPHEMES)).Trim();
}
if(!string.IsNullOrWhiteSpace(nickStr) && ctx.Chat.Users.Any(u => u.NameEquals(nickStr))) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.NAME_IN_USE, true, nickStr));
return;
}
string? previousName = targetUser.UserId == ctx.User.UserId ? (targetUser.NickName ?? targetUser.UserName) : null;
await ctx.Chat.UpdateUser(targetUser, nickName: nickStr, silent: previousName == null);
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == targetUserId.ToString());
++offset;
}
targetUser ??= ctx.User;
if(ctx.Args.Length < offset) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string nickStr = string.Join('_', ctx.Args.Skip(offset))
.Replace("\n", string.Empty).Replace("\r", string.Empty)
.Replace("\f", string.Empty).Replace("\t", string.Empty)
.Replace(' ', '_').Trim();
if(nickStr == targetUser.UserName)
nickStr = string.Empty;
else if(string.IsNullOrEmpty(nickStr))
nickStr = string.Empty;
else {
StringInfo nsi = new(nickStr);
if(Encoding.UTF8.GetByteCount(nickStr) > MAX_BYTES
|| nsi.LengthInTextElements > MAX_GRAPHEMES)
nickStr = nsi.SubstringByTextElements(0, Math.Min(nsi.LengthInTextElements, MAX_GRAPHEMES)).Trim();
}
if(!string.IsNullOrWhiteSpace(nickStr) && ctx.Chat.Users.Any(u => u.NameEquals(nickStr))) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.NAME_IN_USE, true, nickStr));
return;
}
string? previousName = targetUser.UserId == ctx.User.UserId ? (targetUser.NickName ?? targetUser.UserName) : null;
await ctx.Chat.UpdateUser(targetUser, nickName: nickStr, silent: previousName == null);
}
}

View file

@ -2,39 +2,39 @@ using SharpChat.Bans;
using SharpChat.SockChat.S2CPackets;
using System.Net;
namespace SharpChat.ClientCommands {
public class PardonAddressClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pardonip")
|| ctx.NameEquals("unbanip");
namespace SharpChat.ClientCommands;
public class PardonAddressClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pardonip")
|| ctx.NameEquals("unbanip");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string? unbanAddrTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress? unbanAddr)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
unbanAddrTarget = unbanAddr.ToString();
BanInfo? banInfo = await bansClient.BanGetAsync(remoteAddr: unbanAddr);
if(banInfo?.Kind != BanKind.IPAddress) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
return;
}
if(await bansClient.BanRevokeAsync(banInfo))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanAddrTarget));
else
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
string? unbanAddrTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress? unbanAddr)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
unbanAddrTarget = unbanAddr.ToString();
BanInfo? banInfo = await bansClient.BanGetAsync(remoteAddr: unbanAddr);
if(banInfo?.Kind != BanKind.IPAddress) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
return;
}
if(await bansClient.BanRevokeAsync(banInfo))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanAddrTarget));
else
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
}
}

View file

@ -1,46 +1,46 @@
using SharpChat.Bans;
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class PardonUserClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pardon")
|| ctx.NameEquals("unban");
namespace SharpChat.ClientCommands;
public class PardonUserClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pardon")
|| ctx.NameEquals("unban");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string? unbanUserTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string unbanUserDisplay = unbanUserTarget;
User? unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget));
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId))
unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == unbanUserId.ToString());
if(unbanUser != null) {
unbanUserTarget = unbanUser.UserId;
unbanUserDisplay = unbanUser.UserName;
}
BanInfo? banInfo = await bansClient.BanGetAsync(unbanUserTarget);
if(banInfo?.Kind != BanKind.User) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
return;
}
if(await bansClient.BanRevokeAsync(banInfo))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanUserDisplay));
else
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
string? unbanUserTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string unbanUserDisplay = unbanUserTarget;
User? unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget));
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId))
unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == unbanUserId.ToString());
if(unbanUser != null) {
unbanUserTarget = unbanUser.UserId;
unbanUserDisplay = unbanUser.UserName;
}
BanInfo? banInfo = await bansClient.BanGetAsync(unbanUserTarget);
if(banInfo?.Kind != BanKind.User) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
return;
}
if(await bansClient.BanRevokeAsync(banInfo))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanUserDisplay));
else
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
}
}

View file

@ -1,27 +1,27 @@
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class PasswordChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pwd")
|| ctx.NameEquals("password");
namespace SharpChat.ClientCommands;
public class PasswordChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("pwd")
|| ctx.NameEquals("password");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.SetChannelPassword) || ctx.Channel.IsOwner(ctx.User)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string chanPass = string.Join(' ', ctx.Args).Trim();
if(!ctx.User.Can(UserPermissions.SetChannelPassword) || ctx.Channel.IsOwner(ctx.User)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
if(string.IsNullOrWhiteSpace(chanPass))
chanPass = string.Empty;
string chanPass = string.Join(' ', ctx.Args).Trim();
if(string.IsNullOrWhiteSpace(chanPass))
chanPass = string.Empty;
await ctx.Chat.UpdateChannel(ctx.Channel, password: chanPass);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_PASSWORD_CHANGED, false));
}
await ctx.Chat.UpdateChannel(ctx.Channel, password: chanPass);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_PASSWORD_CHANGED, false));
}
}

View file

@ -1,28 +1,28 @@
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class RankChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("rank")
|| ctx.NameEquals("privilege")
|| ctx.NameEquals("priv");
namespace SharpChat.ClientCommands;
public class RankChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("rank")
|| ctx.NameEquals("privilege")
|| ctx.NameEquals("priv");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.SetChannelHierarchy) || ctx.Channel.IsOwner(ctx.User)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.SetChannelHierarchy) || ctx.Channel.IsOwner(ctx.User)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
if(ctx.Args.Length < 1 || !int.TryParse(ctx.Args.First(), out int chanHierarchy) || chanHierarchy > ctx.User.Rank) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
return;
}
await ctx.Chat.UpdateChannel(ctx.Channel, hierarchy: chanHierarchy);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_HIERARCHY_CHANGED, false));
if(ctx.Args.Length < 1 || !int.TryParse(ctx.Args.First(), out int chanHierarchy) || chanHierarchy > ctx.User.Rank) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
return;
}
await ctx.Chat.UpdateChannel(ctx.Channel, hierarchy: chanHierarchy);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_HIERARCHY_CHANGED, false));
}
}

View file

@ -1,31 +1,31 @@
using SharpChat.SockChat.S2CPackets;
using System.Net;
namespace SharpChat.ClientCommands {
public class RemoteAddressClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("ip")
|| ctx.NameEquals("whois");
namespace SharpChat.ClientCommands;
public class RemoteAddressClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("ip")
|| ctx.NameEquals("whois");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.SeeIPAddress)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, "/ip"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string? ipUserStr = ctx.Args.FirstOrDefault();
User? ipUser = null;
if(!ctx.User.Can(UserPermissions.SeeIPAddress)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, "/ip"));
return;
}
string? ipUserStr = ctx.Args.FirstOrDefault();
User? ipUser = null;
if(string.IsNullOrWhiteSpace(ipUserStr) || (ipUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(ipUserStr))) == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, ipUserStr ?? "User"));
return;
}
foreach(IPAddress ip in ctx.Chat.GetRemoteAddresses(ipUser))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.IP_ADDRESS, false, ipUser.UserName, ip));
if(string.IsNullOrWhiteSpace(ipUserStr) || (ipUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(ipUserStr))) == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, ipUserStr ?? "User"));
return;
}
foreach(IPAddress ip in ctx.Chat.GetRemoteAddresses(ipUser))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.IP_ADDRESS, false, ipUser.UserName, ip));
}
}

View file

@ -1,31 +1,31 @@
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class ShutdownRestartClientCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) : ClientCommand {
private readonly ManualResetEvent WaitHandle = waitHandle ?? throw new ArgumentNullException(nameof(waitHandle));
private readonly Func<bool> ShutdownCheck = shutdownCheck ?? throw new ArgumentNullException(nameof(shutdownCheck));
namespace SharpChat.ClientCommands;
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("shutdown")
|| ctx.NameEquals("restart");
public class ShutdownRestartClientCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) : ClientCommand {
private readonly ManualResetEvent WaitHandle = waitHandle ?? throw new ArgumentNullException(nameof(waitHandle));
private readonly Func<bool> ShutdownCheck = shutdownCheck ?? throw new ArgumentNullException(nameof(shutdownCheck));
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("shutdown")
|| ctx.NameEquals("restart");
}
public async Task Dispatch(ClientCommandContext ctx) {
if(!ctx.User.UserId.Equals("1")) {
long msgId = ctx.Chat.RandomSnowflake.Next();
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
if(!ctx.User.UserId.Equals("1")) {
long msgId = ctx.Chat.RandomSnowflake.Next();
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
if(!ShutdownCheck())
return;
if(!ShutdownCheck())
return;
if(ctx.NameEquals("restart"))
foreach(Connection conn in ctx.Chat.Connections)
conn.PrepareForRestart();
if(ctx.NameEquals("restart"))
foreach(Connection conn in ctx.Chat.Connections)
conn.PrepareForRestart();
await ctx.Chat.Update();
WaitHandle?.Set();
}
await ctx.Chat.Update();
WaitHandle?.Set();
}
}

View file

@ -1,45 +1,45 @@
using SharpChat.Events;
using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands {
public class WhisperClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("whisper")
|| ctx.NameEquals("msg");
namespace SharpChat.ClientCommands;
public class WhisperClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("whisper")
|| ctx.NameEquals("msg");
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
if(ctx.Args.Length < 2) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
User? whisperUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(whisperUserStr));
if(ctx.Args.Length < 2) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return;
}
string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
User? whisperUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(whisperUserStr));
if(whisperUser == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, whisperUserStr));
return;
}
if(whisperUser == ctx.User)
return;
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
msgId,
User.GetDMChannelName(ctx.User, whisperUser),
ctx.User.UserId,
ctx.User.UserName,
ctx.User.Colour,
ctx.User.Rank,
ctx.User.NickName,
ctx.User.Permissions,
DateTimeOffset.Now,
string.Join(' ', ctx.Args.Skip(1)),
true, false, false
));
if(whisperUser == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, whisperUserStr));
return;
}
if(whisperUser == ctx.User)
return;
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
msgId,
User.GetDMChannelName(ctx.User, whisperUser),
ctx.User.UserId,
ctx.User.UserName,
ctx.User.Colour,
ctx.User.Rank,
ctx.User.NickName,
ctx.User.Permissions,
DateTimeOffset.Now,
string.Join(' ', ctx.Args.Skip(1)),
true, false, false
));
}
}

View file

@ -1,62 +1,62 @@
using SharpChat.SockChat.S2CPackets;
using System.Text;
namespace SharpChat.ClientCommands {
public class WhoClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("who");
}
namespace SharpChat.ClientCommands;
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
StringBuilder whoChanSB = new();
string? whoChanStr = ctx.Args.FirstOrDefault();
public class WhoClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("who");
}
if(string.IsNullOrEmpty(whoChanStr)) {
foreach(User whoUser in ctx.Chat.Users) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
public async Task Dispatch(ClientCommandContext ctx) {
long msgId = ctx.Chat.RandomSnowflake.Next();
StringBuilder whoChanSB = new();
string? whoChanStr = ctx.Args.FirstOrDefault();
if(whoUser == ctx.User)
whoChanSB.Append(@" style=""font-weight: bold;""");
if(string.IsNullOrEmpty(whoChanStr)) {
foreach(User whoUser in ctx.Chat.Users) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
whoChanSB.Append('>');
whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
}
if(whoUser == ctx.User)
whoChanSB.Append(@" style=""font-weight: bold;""");
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_SERVER, false, whoChanSB));
} else {
Channel? whoChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(whoChanStr));
if(whoChan is null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, whoChanStr));
return;
}
if(whoChan.Rank > ctx.User.Rank || (whoChan.HasPassword && !ctx.User.Can(UserPermissions.JoinAnyChannel))) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_ERROR, true, whoChanStr));
return;
}
foreach(User whoUser in ctx.Chat.GetChannelUsers(whoChan)) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(whoUser == ctx.User)
whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append('>');
whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_CHANNEL, false, whoChan.Name, whoChanSB));
whoChanSB.Append('>');
whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_SERVER, false, whoChanSB));
} else {
Channel? whoChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(whoChanStr));
if(whoChan is null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, whoChanStr));
return;
}
if(whoChan.Rank > ctx.User.Rank || (whoChan.HasPassword && !ctx.User.Can(UserPermissions.JoinAnyChannel))) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_ERROR, true, whoChanStr));
return;
}
foreach(User whoUser in ctx.Chat.GetChannelUsers(whoChan)) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(whoUser == ctx.User)
whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append('>');
whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_CHANNEL, false, whoChan.Name, whoChanSB));
}
}
}

View file

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

View file

@ -5,409 +5,409 @@ using SharpChat.SockChat;
using SharpChat.SockChat.S2CPackets;
using System.Net;
namespace SharpChat {
public class Context {
public record ChannelUserAssoc(string UserId, string ChannelName);
namespace SharpChat;
public readonly SemaphoreSlim ContextAccess = new(1, 1);
public class Context {
public record ChannelUserAssoc(string UserId, string ChannelName);
public SnowflakeGenerator SnowflakeGenerator { get; } = new();
public RandomSnowflake RandomSnowflake { get; }
public readonly SemaphoreSlim ContextAccess = new(1, 1);
public HashSet<Channel> Channels { get; } = [];
public HashSet<Connection> Connections { get; } = [];
public HashSet<User> Users { get; } = [];
public EventStorage.EventStorage Events { get; }
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = [];
public Dictionary<string, RateLimiter> UserRateLimiters { get; } = [];
public Dictionary<string, Channel> UserLastChannel { get; } = [];
public SnowflakeGenerator SnowflakeGenerator { get; } = new();
public RandomSnowflake RandomSnowflake { get; }
public Context(EventStorage.EventStorage evtStore) {
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
RandomSnowflake = new(SnowflakeGenerator);
}
public HashSet<Channel> Channels { get; } = [];
public HashSet<Connection> Connections { get; } = [];
public HashSet<User> Users { get; } = [];
public EventStorage.EventStorage Events { get; }
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = [];
public Dictionary<string, RateLimiter> UserRateLimiters { get; } = [];
public Dictionary<string, Channel> UserLastChannel { get; } = [];
public async Task DispatchEvent(ChatEvent eventInfo) {
if(eventInfo is MessageCreateEvent mce) {
if(mce.IsBroadcast) {
await Send(new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.BROADCAST, false, mce.MessageText));
} else if(mce.IsPrivate) {
// The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed
// e.g. nook sees @Arysil and Arysil sees @nook
public Context(EventStorage.EventStorage evtStore) {
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
RandomSnowflake = new(SnowflakeGenerator);
}
// this entire routine is garbage, channels should probably in the db
if(!mce.ChannelName.StartsWith('@'))
return;
public async Task DispatchEvent(ChatEvent eventInfo) {
if(eventInfo is MessageCreateEvent mce) {
if(mce.IsBroadcast) {
await Send(new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.BROADCAST, false, mce.MessageText));
} else if(mce.IsPrivate) {
// The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed
// e.g. nook sees @Arysil and Arysil sees @nook
IEnumerable<string> uids = mce.ChannelName[1..].Split('-', 3).Select(u => (long.TryParse(u, out long up) ? up : -1).ToString());
if(uids.Count() != 2)
return;
// this entire routine is garbage, channels should probably in the db
if(!mce.ChannelName.StartsWith('@'))
return;
IEnumerable<User> users = Users.Where(u => uids.Any(uid => uid == u.UserId));
User? target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
if(target == null)
return;
IEnumerable<string> uids = mce.ChannelName[1..].Split('-', 3).Select(u => (long.TryParse(u, out long up) ? up : -1).ToString());
if(uids.Count() != 2)
return;
foreach(User user in users)
await SendTo(user, new ChatMessageAddS2CPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText,
mce.IsAction,
true
));
} else {
Channel? channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName));
if(channel is not null)
await SendTo(channel, new ChatMessageAddS2CPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.MessageText,
mce.IsAction,
false
));
}
IEnumerable<User> users = Users.Where(u => uids.Any(uid => uid == u.UserId));
User? target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
if(target == null)
return;
Events.AddEvent(
mce.MessageId, "msg:add",
mce.ChannelName,
mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms,
new { text = mce.MessageText },
(mce.IsBroadcast ? StoredEventFlags.Broadcast : 0)
| (mce.IsAction ? StoredEventFlags.Action : 0)
| (mce.IsPrivate ? StoredEventFlags.Private : 0)
);
return;
}
}
public async Task Update() {
foreach(Connection conn in Connections)
if(!conn.IsDisposed && conn.HasTimedOut) {
conn.Dispose();
Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}.");
}
Connections.RemoveWhere(conn => conn.IsDisposed);
foreach(User user in Users)
if(!Connections.Any(conn => conn.User == user)) {
Logger.Write($"Timing out {user} (no more connections).");
await HandleDisconnect(user, UserDisconnectS2CPacket.Reason.TimeOut);
}
}
public async Task SafeUpdate() {
ContextAccess.Wait();
try {
await Update();
} finally {
ContextAccess.Release();
}
}
public bool IsInChannel(User user, Channel channel) {
return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
}
public string[] GetUserChannelNames(User user) {
return [.. ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName)];
}
public Channel[] GetUserChannels(User user) {
string[] names = GetUserChannelNames(user);
return [.. Channels.Where(c => names.Any(n => c.NameEquals(n)))];
}
public string[] GetChannelUserIds(Channel channel) {
return [.. ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId)];
}
public User[] GetChannelUsers(Channel channel) {
string[] ids = GetChannelUserIds(channel);
return [.. Users.Where(u => ids.Contains(u.UserId))];
}
public async Task UpdateUser(
User user,
string? userName = null,
string? nickName = null,
ColourInheritable? colour = null,
UserStatus? status = null,
string? statusText = null,
int? rank = null,
UserPermissions? perms = null,
bool silent = false
) {
ArgumentNullException.ThrowIfNull(user);
bool hasChanged = false;
string previousName = string.Empty;
if(userName != null && !user.UserName.Equals(userName)) {
user.UserName = userName;
hasChanged = true;
}
if(nickName != null && !user.NickName.Equals(nickName)) {
if(!silent)
previousName = user.LegacyName;
user.NickName = nickName;
hasChanged = true;
}
if(colour.HasValue && user.Colour != colour.Value) {
user.Colour = colour.Value;
hasChanged = true;
}
if(status.HasValue && user.Status != status.Value) {
user.Status = status.Value;
hasChanged = true;
}
if(statusText != null && !user.StatusText.Equals(statusText)) {
user.StatusText = statusText;
hasChanged = true;
}
if(rank != null && user.Rank != rank) {
user.Rank = (int)rank;
hasChanged = true;
}
if(perms.HasValue && user.Permissions != perms) {
user.Permissions = perms.Value;
hasChanged = true;
}
if(hasChanged) {
if(!string.IsNullOrWhiteSpace(previousName))
await SendToUserChannels(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.NICKNAME_CHANGE, false, previousName, user.LegacyNameWithStatus));
await SendToUserChannels(user, new UserUpdateS2CPacket(user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
}
}
public async Task BanUser(User user, TimeSpan duration, UserDisconnectS2CPacket.Reason reason = UserDisconnectS2CPacket.Reason.Kicked) {
if(duration > TimeSpan.Zero) {
DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration;
await SendTo(user, new ForceDisconnectS2CPacket(expires));
} else
await SendTo(user, new ForceDisconnectS2CPacket());
foreach(Connection conn in Connections)
if(conn.User == user)
conn.Dispose();
Connections.RemoveWhere(conn => conn.IsDisposed);
await HandleDisconnect(user, reason);
}
public async Task HandleJoin(User user, Channel chan, Connection conn, int maxMsgLength) {
if(!IsInChannel(user, chan)) {
long msgId = RandomSnowflake.Next();
await SendTo(chan, new UserConnectS2CPacket(msgId, DateTimeOffset.Now, user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
Events.AddEvent(msgId, "user:connect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
}
await conn.Send(new AuthSuccessS2CPacket(
user.UserId,
user.LegacyNameWithStatus,
user.Colour,
user.Rank,
user.Permissions,
chan.Name,
maxMsgLength
));
await conn.Send(new ContextUsersS2CPacket(
GetChannelUsers(chan).Except([user]).OrderByDescending(u => u.Rank)
.Select(u => new ContextUsersS2CPacket.Entry(
u.UserId,
u.LegacyNameWithStatus,
u.Colour,
u.Rank,
u.Permissions,
foreach(User user in users)
await SendTo(user, new ChatMessageAddS2CPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText,
mce.IsAction,
true
))
));
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
await conn.Send(new ContextMessageS2CPacket(msg));
await conn.Send(new ContextChannelsS2CPacket(
Channels.Where(c => c.Rank <= user.Rank)
.Select(c => new ContextChannelsS2CPacket.Entry(c.Name, c.HasPassword, c.IsTemporary))
));
Users.Add(user);
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
}
public async Task HandleDisconnect(User user, UserDisconnectS2CPacket.Reason reason = UserDisconnectS2CPacket.Reason.Leave) {
await UpdateUser(user, status: UserStatus.Offline);
Users.Remove(user);
UserLastChannel.Remove(user.UserId);
Channel[] channels = GetUserChannels(user);
foreach(Channel chan in channels) {
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, chan.Name));
long msgId = RandomSnowflake.Next();
await SendTo(chan, new UserDisconnectS2CPacket(msgId, DateTimeOffset.Now, user.UserId, user.LegacyNameWithStatus, reason));
Events.AddEvent(msgId, "user:disconnect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, new { reason = (int)reason }, StoredEventFlags.Log);
if(chan.IsTemporary && chan.IsOwner(user))
await RemoveChannel(chan);
));
} else {
Channel? channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName));
if(channel is not null)
await SendTo(channel, new ChatMessageAddS2CPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.MessageText,
mce.IsAction,
false
));
}
Events.AddEvent(
mce.MessageId, "msg:add",
mce.ChannelName,
mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms,
new { text = mce.MessageText },
(mce.IsBroadcast ? StoredEventFlags.Broadcast : 0)
| (mce.IsAction ? StoredEventFlags.Action : 0)
| (mce.IsPrivate ? StoredEventFlags.Private : 0)
);
return;
}
}
public async Task Update() {
foreach(Connection conn in Connections)
if(!conn.IsDisposed && conn.HasTimedOut) {
conn.Dispose();
Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}.");
}
Connections.RemoveWhere(conn => conn.IsDisposed);
foreach(User user in Users)
if(!Connections.Any(conn => conn.User == user)) {
Logger.Write($"Timing out {user} (no more connections).");
await HandleDisconnect(user, UserDisconnectS2CPacket.Reason.TimeOut);
}
}
public async Task SafeUpdate() {
ContextAccess.Wait();
try {
await Update();
} finally {
ContextAccess.Release();
}
}
public bool IsInChannel(User user, Channel channel) {
return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
}
public string[] GetUserChannelNames(User user) {
return [.. ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName)];
}
public Channel[] GetUserChannels(User user) {
string[] names = GetUserChannelNames(user);
return [.. Channels.Where(c => names.Any(n => c.NameEquals(n)))];
}
public string[] GetChannelUserIds(Channel channel) {
return [.. ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId)];
}
public User[] GetChannelUsers(Channel channel) {
string[] ids = GetChannelUserIds(channel);
return [.. Users.Where(u => ids.Contains(u.UserId))];
}
public async Task UpdateUser(
User user,
string? userName = null,
string? nickName = null,
ColourInheritable? colour = null,
UserStatus? status = null,
string? statusText = null,
int? rank = null,
UserPermissions? perms = null,
bool silent = false
) {
ArgumentNullException.ThrowIfNull(user);
bool hasChanged = false;
string previousName = string.Empty;
if(userName != null && !user.UserName.Equals(userName)) {
user.UserName = userName;
hasChanged = true;
}
public async Task SwitchChannel(User user, Channel chan, string password) {
if(UserLastChannel.TryGetValue(user.UserId, out Channel? ulc) && chan == ulc) {
if(nickName != null && !user.NickName.Equals(nickName)) {
if(!silent)
previousName = user.LegacyName;
user.NickName = nickName;
hasChanged = true;
}
if(colour.HasValue && user.Colour != colour.Value) {
user.Colour = colour.Value;
hasChanged = true;
}
if(status.HasValue && user.Status != status.Value) {
user.Status = status.Value;
hasChanged = true;
}
if(statusText != null && !user.StatusText.Equals(statusText)) {
user.StatusText = statusText;
hasChanged = true;
}
if(rank != null && user.Rank != rank) {
user.Rank = (int)rank;
hasChanged = true;
}
if(perms.HasValue && user.Permissions != perms) {
user.Permissions = perms.Value;
hasChanged = true;
}
if(hasChanged) {
if(!string.IsNullOrWhiteSpace(previousName))
await SendToUserChannels(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.NICKNAME_CHANGE, false, previousName, user.LegacyNameWithStatus));
await SendToUserChannels(user, new UserUpdateS2CPacket(user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
}
}
public async Task BanUser(User user, TimeSpan duration, UserDisconnectS2CPacket.Reason reason = UserDisconnectS2CPacket.Reason.Kicked) {
if(duration > TimeSpan.Zero) {
DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration;
await SendTo(user, new ForceDisconnectS2CPacket(expires));
} else
await SendTo(user, new ForceDisconnectS2CPacket());
foreach(Connection conn in Connections)
if(conn.User == user)
conn.Dispose();
Connections.RemoveWhere(conn => conn.IsDisposed);
await HandleDisconnect(user, reason);
}
public async Task HandleJoin(User user, Channel chan, Connection conn, int maxMsgLength) {
if(!IsInChannel(user, chan)) {
long msgId = RandomSnowflake.Next();
await SendTo(chan, new UserConnectS2CPacket(msgId, DateTimeOffset.Now, user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
Events.AddEvent(msgId, "user:connect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
}
await conn.Send(new AuthSuccessS2CPacket(
user.UserId,
user.LegacyNameWithStatus,
user.Colour,
user.Rank,
user.Permissions,
chan.Name,
maxMsgLength
));
await conn.Send(new ContextUsersS2CPacket(
GetChannelUsers(chan).Except([user]).OrderByDescending(u => u.Rank)
.Select(u => new ContextUsersS2CPacket.Entry(
u.UserId,
u.LegacyNameWithStatus,
u.Colour,
u.Rank,
u.Permissions,
true
))
));
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
await conn.Send(new ContextMessageS2CPacket(msg));
await conn.Send(new ContextChannelsS2CPacket(
Channels.Where(c => c.Rank <= user.Rank)
.Select(c => new ContextChannelsS2CPacket.Entry(c.Name, c.HasPassword, c.IsTemporary))
));
Users.Add(user);
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
}
public async Task HandleDisconnect(User user, UserDisconnectS2CPacket.Reason reason = UserDisconnectS2CPacket.Reason.Leave) {
await UpdateUser(user, status: UserStatus.Offline);
Users.Remove(user);
UserLastChannel.Remove(user.UserId);
Channel[] channels = GetUserChannels(user);
foreach(Channel chan in channels) {
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, chan.Name));
long msgId = RandomSnowflake.Next();
await SendTo(chan, new UserDisconnectS2CPacket(msgId, DateTimeOffset.Now, user.UserId, user.LegacyNameWithStatus, reason));
Events.AddEvent(msgId, "user:disconnect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, new { reason = (int)reason }, StoredEventFlags.Log);
if(chan.IsTemporary && chan.IsOwner(user))
await RemoveChannel(chan);
}
}
public async Task SwitchChannel(User user, Channel chan, string password) {
if(UserLastChannel.TryGetValue(user.UserId, out Channel? ulc) && chan == ulc) {
await ForceChannel(user);
return;
}
if(!user.Can(UserPermissions.JoinAnyChannel) && chan.IsOwner(user)) {
if(chan.Rank > user.Rank) {
await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
await ForceChannel(user);
return;
}
if(!user.Can(UserPermissions.JoinAnyChannel) && chan.IsOwner(user)) {
if(chan.Rank > user.Rank) {
await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
await ForceChannel(user);
return;
}
if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) {
await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
await ForceChannel(user);
return;
}
if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) {
await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
await ForceChannel(user);
return;
}
await ForceChannelSwitch(user, chan);
}
public async Task ForceChannelSwitch(User user, Channel chan) {
if(!Channels.Contains(chan))
return;
await ForceChannelSwitch(user, chan);
}
Channel oldChan = UserLastChannel[user.UserId];
public async Task ForceChannelSwitch(User user, Channel chan) {
if(!Channels.Contains(chan))
return;
long leaveId = RandomSnowflake.Next();
await SendTo(oldChan, new UserChannelLeaveS2CPacket(leaveId, user.UserId));
Events.AddEvent(leaveId, "chan:leave", oldChan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
Channel oldChan = UserLastChannel[user.UserId];
long joinId = RandomSnowflake.Next();
await SendTo(chan, new UserChannelJoinS2CPacket(joinId, user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
Events.AddEvent(joinId, "chan:join", chan.Name, user.UserId, user.LegacyName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
long leaveId = RandomSnowflake.Next();
await SendTo(oldChan, new UserChannelLeaveS2CPacket(leaveId, user.UserId));
Events.AddEvent(leaveId, "chan:leave", oldChan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
await SendTo(user, new ContextClearS2CPacket(ContextClearS2CPacket.Mode.MessagesUsers));
await SendTo(user, new ContextUsersS2CPacket(
GetChannelUsers(chan).Except([user]).OrderByDescending(u => u.Rank)
.Select(u => new ContextUsersS2CPacket.Entry(
u.UserId,
u.LegacyNameWithStatus,
u.Colour,
u.Rank,
u.Permissions,
true
))
));
long joinId = RandomSnowflake.Next();
await SendTo(chan, new UserChannelJoinS2CPacket(joinId, user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
Events.AddEvent(joinId, "chan:join", chan.Name, user.UserId, user.LegacyName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
await SendTo(user, new ContextMessageS2CPacket(msg));
await SendTo(user, new ContextClearS2CPacket(ContextClearS2CPacket.Mode.MessagesUsers));
await SendTo(user, new ContextUsersS2CPacket(
GetChannelUsers(chan).Except([user]).OrderByDescending(u => u.Rank)
.Select(u => new ContextUsersS2CPacket.Entry(
u.UserId,
u.LegacyNameWithStatus,
u.Colour,
u.Rank,
u.Permissions,
true
))
));
await ForceChannel(user, chan);
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
await SendTo(user, new ContextMessageS2CPacket(msg));
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
await ForceChannel(user, chan);
if(oldChan.IsTemporary && oldChan.IsOwner(user))
await RemoveChannel(oldChan);
}
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
public async Task Send(S2CPacket packet) {
foreach(Connection conn in Connections)
if(conn.IsAlive && conn.User is not null)
await conn.Send(packet);
}
if(oldChan.IsTemporary && oldChan.IsOwner(user))
await RemoveChannel(oldChan);
}
public async Task SendTo(User user, S2CPacket packet) {
foreach(Connection conn in Connections)
if(conn.IsAlive && conn.User == user)
await conn.Send(packet);
}
public async Task SendTo(Channel channel, S2CPacket packet) {
// might be faster to grab the users first and then cascade into that SendTo
IEnumerable<Connection> conns = Connections.Where(c => c.IsAlive && c.User is not null && IsInChannel(c.User, channel));
foreach(Connection conn in conns)
public async Task Send(S2CPacket packet) {
foreach(Connection conn in Connections)
if(conn.IsAlive && conn.User is not null)
await conn.Send(packet);
}
}
public async Task SendToUserChannels(User user, S2CPacket packet) {
IEnumerable<Channel> chans = Channels.Where(c => IsInChannel(user, c));
IEnumerable<Connection> conns = Connections.Where(conn => conn.IsAlive && conn.User is not null && ChannelUsers.Any(cu => cu.UserId == conn.User.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName))));
foreach(Connection conn in conns)
public async Task SendTo(User user, S2CPacket packet) {
foreach(Connection conn in Connections)
if(conn.IsAlive && conn.User == user)
await conn.Send(packet);
}
}
public IPAddress[] GetRemoteAddresses(User user) {
return [.. Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct()];
}
public async Task SendTo(Channel channel, S2CPacket packet) {
// might be faster to grab the users first and then cascade into that SendTo
IEnumerable<Connection> conns = Connections.Where(c => c.IsAlive && c.User is not null && IsInChannel(c.User, channel));
foreach(Connection conn in conns)
await conn.Send(packet);
}
public async Task ForceChannel(User user, Channel? chan = null) {
ArgumentNullException.ThrowIfNull(user);
public async Task SendToUserChannels(User user, S2CPacket packet) {
IEnumerable<Channel> chans = Channels.Where(c => IsInChannel(user, c));
IEnumerable<Connection> conns = Connections.Where(conn => conn.IsAlive && conn.User is not null && ChannelUsers.Any(cu => cu.UserId == conn.User.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName))));
foreach(Connection conn in conns)
await conn.Send(packet);
}
if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan))
throw new ArgumentException("no channel???");
public IPAddress[] GetRemoteAddresses(User user) {
return [.. Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct()];
}
await SendTo(user, new UserChannelForceJoinS2CPacket(chan.Name));
}
public async Task ForceChannel(User user, Channel? chan = null) {
ArgumentNullException.ThrowIfNull(user);
public async Task UpdateChannel(Channel channel, bool? temporary = null, int? hierarchy = null, string? password = null) {
ArgumentNullException.ThrowIfNull(channel);
if(!Channels.Contains(channel))
throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel));
if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan))
throw new ArgumentException("no channel???");
if(temporary.HasValue)
channel.IsTemporary = temporary.Value;
await SendTo(user, new UserChannelForceJoinS2CPacket(chan.Name));
}
if(hierarchy.HasValue)
channel.Rank = hierarchy.Value;
public async Task UpdateChannel(Channel channel, bool? temporary = null, int? hierarchy = null, string? password = null) {
ArgumentNullException.ThrowIfNull(channel);
if(!Channels.Contains(channel))
throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel));
if(password != null)
channel.Password = password;
if(temporary.HasValue)
channel.IsTemporary = temporary.Value;
// TODO: 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(User user in Users.Where(u => u.Rank >= channel.Rank))
await SendTo(user, new ChannelUpdateS2CPacket(channel.Name, channel.Name, channel.HasPassword, channel.IsTemporary));
}
if(hierarchy.HasValue)
channel.Rank = hierarchy.Value;
public async Task RemoveChannel(Channel channel) {
if(channel == null || Channels.Count < 1)
return;
if(password != null)
channel.Password = password;
Channel? defaultChannel = Channels.FirstOrDefault();
if(defaultChannel is null)
return;
// TODO: 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(User user in Users.Where(u => u.Rank >= channel.Rank))
await SendTo(user, new ChannelUpdateS2CPacket(channel.Name, channel.Name, channel.HasPassword, channel.IsTemporary));
}
// Remove channel from the listing
Channels.Remove(channel);
public async Task RemoveChannel(Channel channel) {
if(channel == null || Channels.Count < 1)
return;
// 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(User user in GetChannelUsers(channel))
await SwitchChannel(user, defaultChannel, string.Empty);
Channel? defaultChannel = Channels.FirstOrDefault();
if(defaultChannel is null)
return;
// Broadcast deletion of channel
foreach(User user in Users.Where(u => u.Rank >= channel.Rank))
await SendTo(user, new ChannelDeleteS2CPacket(channel.Name));
}
// 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(User user in GetChannelUsers(channel))
await SwitchChannel(user, defaultChannel, string.Empty);
// Broadcast deletion of channel
foreach(User user in Users.Where(u => u.Rank >= channel.Rank))
await SendTo(user, new ChannelDeleteS2CPacket(channel.Name));
}
}

View file

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

View file

@ -2,130 +2,130 @@ using MySqlConnector;
using System.Text;
using System.Text.Json;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage(string connString) : EventStorage {
private string ConnectionString { get; } = connString ?? throw new ArgumentNullException(nameof(connString));
namespace SharpChat.EventStorage;
public void AddEvent(
long id,
string type,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNick,
UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
RunCommand(
"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
+ " VALUES (@id, NOW(), @type, @target, @flags, @data"
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter("id", id),
new MySqlParameter("type", type),
new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new MySqlParameter("flags", (byte)flags),
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
new MySqlParameter("sender_name", senderName),
new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
new MySqlParameter("sender_rank", senderRank),
new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
new MySqlParameter("sender_perms", senderPerms)
public partial class MariaDBEventStorage(string connString) : EventStorage {
private string ConnectionString { get; } = connString ?? throw new ArgumentNullException(nameof(connString));
public void AddEvent(
long id,
string type,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNick,
UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
RunCommand(
"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
+ " VALUES (@id, NOW(), @type, @target, @flags, @data"
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter("id", id),
new MySqlParameter("type", type),
new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new MySqlParameter("flags", (byte)flags),
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
new MySqlParameter("sender_name", senderName),
new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
new MySqlParameter("sender_rank", senderRank),
new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
new MySqlParameter("sender_perms", senderPerms)
);
}
public StoredEventInfo? GetEvent(long seqId) {
try {
using MySqlDataReader? reader = RunQuery(
"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ ", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_id` = @id",
new MySqlParameter("id", seqId)
);
}
public StoredEventInfo? GetEvent(long seqId) {
try {
using MySqlDataReader? reader = RunQuery(
"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ ", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_id` = @id",
new MySqlParameter("id", seqId)
);
if(reader is null)
return null;
if(reader is null)
return null;
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
return evt;
}
} catch(MySqlException ex) {
Logger.Write(ex);
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
return evt;
}
return null;
} catch(MySqlException ex) {
Logger.Write(ex);
}
private static StoredEventInfo ReadEvent(MySqlDataReader reader) {
return new StoredEventInfo(
reader.GetInt64("event_id"),
Encoding.ASCII.GetString((byte[])reader["event_type"]),
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new User(
reader.GetInt64("event_sender").ToString(),
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")),
reader.GetInt32("event_sender_rank"),
(UserPermissions)reader.GetInt32("event_sender_perms"),
reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick")
),
DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")),
reader.IsDBNull(reader.GetOrdinal("event_target")) ? null : Encoding.ASCII.GetString((byte[])reader["event_target"]),
JsonDocument.Parse(Encoding.ASCII.GetString((byte[])reader["event_data"])),
(StoredEventFlags)reader.GetByte("event_flags")
return null;
}
private static StoredEventInfo ReadEvent(MySqlDataReader reader) {
return new StoredEventInfo(
reader.GetInt64("event_id"),
Encoding.ASCII.GetString((byte[])reader["event_type"]),
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new User(
reader.GetInt64("event_sender").ToString(),
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")),
reader.GetInt32("event_sender_rank"),
(UserPermissions)reader.GetInt32("event_sender_perms"),
reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick")
),
DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")),
reader.IsDBNull(reader.GetOrdinal("event_target")) ? null : Encoding.ASCII.GetString((byte[])reader["event_target"]),
JsonDocument.Parse(Encoding.ASCII.GetString((byte[])reader["event_data"])),
(StoredEventFlags)reader.GetByte("event_flags")
);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
List<StoredEventInfo> events = [];
try {
using MySqlDataReader? reader = RunQuery(
"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ ", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_deleted` IS NULL AND (`event_target` = @target OR `event_target` IS NULL)"
+ " AND `event_id` > @offset"
+ " ORDER BY `event_id` DESC"
+ " LIMIT @amount",
new MySqlParameter("target", channelName),
new MySqlParameter("amount", amount),
new MySqlParameter("offset", offset)
);
}
if(reader is null)
return events;
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
List<StoredEventInfo> events = [];
try {
using MySqlDataReader? reader = RunQuery(
"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ ", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_deleted` IS NULL AND (`event_target` = @target OR `event_target` IS NULL)"
+ " AND `event_id` > @offset"
+ " ORDER BY `event_id` DESC"
+ " LIMIT @amount",
new MySqlParameter("target", channelName),
new MySqlParameter("amount", amount),
new MySqlParameter("offset", offset)
);
if(reader is null)
return events;
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
events.Add(evt);
}
} catch(MySqlException ex) {
Logger.Write(ex);
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
events.Add(evt);
}
events.Reverse();
return events;
} catch(MySqlException ex) {
Logger.Write(ex);
}
public void RemoveEvent(StoredEventInfo evt) {
ArgumentNullException.ThrowIfNull(evt);
RunCommand(
"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
new MySqlParameter("id", evt.Id)
);
}
events.Reverse();
return events;
}
public void RemoveEvent(StoredEventInfo evt) {
ArgumentNullException.ThrowIfNull(evt);
RunCommand(
"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
new MySqlParameter("id", evt.Id)
);
}
}

View file

@ -1,87 +1,87 @@
using MySqlConnector;
using SharpChat.Configuration;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage {
public static string BuildConnString(Configuration.Config config) {
return BuildConnString(
config.ReadValue("host", "localhost"),
config.ReadValue("user", string.Empty),
config.ReadValue("pass", string.Empty),
config.ReadValue("db", "sharpchat")
);
namespace SharpChat.EventStorage;
public partial class MariaDBEventStorage {
public static string BuildConnString(Configuration.Config config) {
return BuildConnString(
config.ReadValue("host", "localhost"),
config.ReadValue("user", string.Empty),
config.ReadValue("pass", string.Empty),
config.ReadValue("db", "sharpchat")
);
}
public static string BuildConnString(string? host, string? username, string? password, string? database) {
return new MySqlConnectionStringBuilder {
Server = host,
UserID = username,
Password = password,
Database = database,
OldGuids = false,
TreatTinyAsBoolean = false,
CharacterSet = "utf8mb4",
SslMode = MySqlSslMode.None,
ForceSynchronous = true,
ConnectionTimeout = 5,
DefaultCommandTimeout = 900, // fuck it, 15 minutes
}.ToString();
}
private MySqlConnection GetConnection() {
MySqlConnection conn = new(ConnectionString);
conn.Open();
return conn;
}
private int RunCommand(string command, params MySqlParameter[] parameters) {
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteNonQuery();
} catch(MySqlException ex) {
Logger.Write(ex);
}
public static string BuildConnString(string? host, string? username, string? password, string? database) {
return new MySqlConnectionStringBuilder {
Server = host,
UserID = username,
Password = password,
Database = database,
OldGuids = false,
TreatTinyAsBoolean = false,
CharacterSet = "utf8mb4",
SslMode = MySqlSslMode.None,
ForceSynchronous = true,
ConnectionTimeout = 5,
DefaultCommandTimeout = 900, // fuck it, 15 minutes
}.ToString();
return 0;
}
private MySqlDataReader? RunQuery(string command, params MySqlParameter[] parameters) {
try {
MySqlConnection conn = GetConnection();
MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);
} catch(MySqlException ex) {
Logger.Write(ex);
}
private MySqlConnection GetConnection() {
MySqlConnection conn = new(ConnectionString);
conn.Open();
return conn;
return null;
}
private T RunQueryValue<T>(string command, params MySqlParameter[] parameters)
where T : struct {
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
cmd.Prepare();
object? raw = cmd.ExecuteScalar();
if(raw is T value)
return value;
} catch(MySqlException ex) {
Logger.Write(ex);
}
private int RunCommand(string command, params MySqlParameter[] parameters) {
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteNonQuery();
} catch(MySqlException ex) {
Logger.Write(ex);
}
return 0;
}
private MySqlDataReader? RunQuery(string command, params MySqlParameter[] parameters) {
try {
MySqlConnection conn = GetConnection();
MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private T RunQueryValue<T>(string command, params MySqlParameter[] parameters)
where T : struct {
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
cmd.Prepare();
object? raw = cmd.ExecuteScalar();
if(raw is T value)
return value;
} catch(MySqlException ex) {
Logger.Write(ex);
}
return default;
}
return default;
}
}

View file

@ -1,84 +1,84 @@
using MySqlConnector;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage {
private void DoMigration(string name, Action action) {
bool done = RunQueryValue<long>(
"SELECT COUNT(*) FROM `sqc_migrations` WHERE `migration_name` = @name",
namespace SharpChat.EventStorage;
public partial class MariaDBEventStorage {
private void DoMigration(string name, Action action) {
bool done = RunQueryValue<long>(
"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)
) > 0;
if(!done) {
Logger.Write($"Running migration '{name}'...");
action();
RunCommand(
"INSERT INTO `sqc_migrations` (`migration_name`) VALUES (@name)",
new MySqlParameter("name", name)
);
}
}
public void RunMigrations() {
RunCommand(
"CREATE TABLE IF NOT EXISTS `sqc_migrations` ("
+ "`migration_name` VARCHAR(255) NOT NULL,"
+ "`migration_completed` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ "UNIQUE INDEX `migration_name` (`migration_name`),"
+ "INDEX `migration_completed` (`migration_completed`)"
+ ") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
DoMigration("create_events_table", CreateEventsTable);
DoMigration("allow_null_target", AllowNullTarget);
DoMigration("event_data_as_medium_blob", EventDataAsMediumBlob);
DoMigration("event_user_and_nick_name_to_1000", EventUserAndNickNameTo1000);
}
private void EventUserAndNickNameTo1000() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_sender_name` `event_sender_name` VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER `event_sender`,"
+ " CHANGE COLUMN `event_sender_nick` `event_sender_nick` VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER `event_sender_rank`;"
);
}
private void EventDataAsMediumBlob() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_data` `event_data` MEDIUMBLOB NULL DEFAULT NULL AFTER `event_flags`;"
);
}
private void AllowNullTarget() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_target` `event_target` VARBINARY(255) NULL AFTER `event_type`;"
);
}
private void CreateEventsTable() {
RunCommand(
"CREATE TABLE `sqc_events` ("
+ "`event_id` BIGINT(20) NOT NULL,"
+ "`event_sender` BIGINT(20) UNSIGNED NULL DEFAULT NULL,"
+ "`event_sender_name` VARCHAR(255) NULL DEFAULT NULL,"
+ "`event_sender_colour` INT(11) NULL DEFAULT NULL,"
+ "`event_sender_rank` INT(11) NULL DEFAULT NULL,"
+ "`event_sender_nick` VARCHAR(255) NULL DEFAULT NULL,"
+ "`event_sender_perms` INT(11) NULL DEFAULT NULL,"
+ "`event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ "`event_deleted` TIMESTAMP NULL DEFAULT NULL,"
+ "`event_type` VARBINARY(255) NOT NULL,"
+ "`event_target` VARBINARY(255) NOT NULL,"
+ "`event_flags` TINYINT(3) UNSIGNED NOT NULL,"
+ "`event_data` BLOB NULL DEFAULT NULL,"
+ "PRIMARY KEY (`event_id`),"
+ "INDEX `event_target` (`event_target`),"
+ "INDEX `event_type` (`event_type`),"
+ "INDEX `event_sender` (`event_sender`),"
+ "INDEX `event_datetime` (`event_created`),"
+ "INDEX `event_deleted` (`event_deleted`)"
+ ") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
}
}
public void RunMigrations() {
RunCommand(
"CREATE TABLE IF NOT EXISTS `sqc_migrations` ("
+ "`migration_name` VARCHAR(255) NOT NULL,"
+ "`migration_completed` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ "UNIQUE INDEX `migration_name` (`migration_name`),"
+ "INDEX `migration_completed` (`migration_completed`)"
+ ") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
DoMigration("create_events_table", CreateEventsTable);
DoMigration("allow_null_target", AllowNullTarget);
DoMigration("event_data_as_medium_blob", EventDataAsMediumBlob);
DoMigration("event_user_and_nick_name_to_1000", EventUserAndNickNameTo1000);
}
private void EventUserAndNickNameTo1000() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_sender_name` `event_sender_name` VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER `event_sender`,"
+ " CHANGE COLUMN `event_sender_nick` `event_sender_nick` VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER `event_sender_rank`;"
);
}
private void EventDataAsMediumBlob() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_data` `event_data` MEDIUMBLOB NULL DEFAULT NULL AFTER `event_flags`;"
);
}
private void AllowNullTarget() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_target` `event_target` VARBINARY(255) NULL AFTER `event_type`;"
);
}
private void CreateEventsTable() {
RunCommand(
"CREATE TABLE `sqc_events` ("
+ "`event_id` BIGINT(20) NOT NULL,"
+ "`event_sender` BIGINT(20) UNSIGNED NULL DEFAULT NULL,"
+ "`event_sender_name` VARCHAR(255) NULL DEFAULT NULL,"
+ "`event_sender_colour` INT(11) NULL DEFAULT NULL,"
+ "`event_sender_rank` INT(11) NULL DEFAULT NULL,"
+ "`event_sender_nick` VARCHAR(255) NULL DEFAULT NULL,"
+ "`event_sender_perms` INT(11) NULL DEFAULT NULL,"
+ "`event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ "`event_deleted` TIMESTAMP NULL DEFAULT NULL,"
+ "`event_type` VARBINARY(255) NOT NULL,"
+ "`event_target` VARBINARY(255) NOT NULL,"
+ "`event_flags` TINYINT(3) UNSIGNED NOT NULL,"
+ "`event_data` BLOB NULL DEFAULT NULL,"
+ "PRIMARY KEY (`event_id`),"
+ "INDEX `event_target` (`event_target`),"
+ "INDEX `event_type` (`event_type`),"
+ "INDEX `event_sender` (`event_sender`),"
+ "INDEX `event_datetime` (`event_created`),"
+ "INDEX `event_deleted` (`event_deleted`)"
+ ") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
}
}

View file

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

View file

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

View file

@ -1,65 +1,65 @@
using System.Text.Json;
namespace SharpChat.EventStorage {
public class VirtualEventStorage : EventStorage {
private readonly Dictionary<long, StoredEventInfo> Events = [];
namespace SharpChat.EventStorage;
public void AddEvent(
long id,
string type,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNick,
UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
Events.Add(
public class VirtualEventStorage : EventStorage {
private readonly Dictionary<long, StoredEventInfo> Events = [];
public void AddEvent(
long id,
string type,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNick,
UserPermissions senderPerms,
object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
Events.Add(
id,
new(
id,
new(
id,
type,
long.TryParse(senderId, out long senderId64) && senderId64 > 0
? new User(
senderId,
senderName,
senderColour,
senderRank,
senderPerms,
senderNick
)
: null,
DateTimeOffset.Now,
null,
channelName,
JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data)),
flags
)
);
type,
long.TryParse(senderId, out long senderId64) && senderId64 > 0
? new User(
senderId,
senderName,
senderColour,
senderRank,
senderPerms,
senderNick
)
: null,
DateTimeOffset.Now,
null,
channelName,
JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data)),
flags
)
);
}
public StoredEventInfo? GetEvent(long seqId) {
return Events.TryGetValue(seqId, out StoredEventInfo? evt) ? evt : null;
}
public void RemoveEvent(StoredEventInfo evt) {
ArgumentNullException.ThrowIfNull(evt);
Events.Remove(evt.Id);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
IEnumerable<StoredEventInfo> subset = Events.Values.Where(ev => ev.ChannelName == channelName);
int start = subset.Count() - offset - amount;
if(start < 0) {
amount += start;
start = 0;
}
public StoredEventInfo? GetEvent(long seqId) {
return Events.TryGetValue(seqId, out StoredEventInfo? evt) ? evt : null;
}
public void RemoveEvent(StoredEventInfo evt) {
ArgumentNullException.ThrowIfNull(evt);
Events.Remove(evt.Id);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
IEnumerable<StoredEventInfo> subset = Events.Values.Where(ev => ev.ChannelName == channelName);
int start = subset.Count() - offset - amount;
if(start < 0) {
amount += start;
start = 0;
}
return [.. subset.Skip(start).Take(amount)];
}
return [.. subset.Skip(start).Take(amount)];
}
}

View file

@ -1,3 +1,3 @@
namespace SharpChat.Events {
public interface ChatEvent {}
}
namespace SharpChat.Events;
public interface ChatEvent {}

View file

@ -1,31 +1,31 @@
namespace SharpChat.Events {
public class MessageCreateEvent(
long msgId,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNickName,
UserPermissions senderPerms,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) : ChatEvent {
public long MessageId { get; } = msgId;
public string ChannelName { get; } = channelName;
public string SenderId { get; } = senderId;
public string SenderName { get; } = senderName;
public ColourInheritable SenderColour { get; } = senderColour;
public int SenderRank { get; } = senderRank;
public string SenderNickName { get; } = senderNickName;
public UserPermissions SenderPerms { get; } = senderPerms;
public DateTimeOffset MessageCreated { get; } = msgCreated;
public string MessageText { get; } = msgText;
public bool IsPrivate { get; } = isPrivate;
public bool IsAction { get; } = isAction;
public bool IsBroadcast { get; } = isBroadcast;
}
namespace SharpChat.Events;
public class MessageCreateEvent(
long msgId,
string channelName,
string senderId,
string senderName,
ColourInheritable senderColour,
int senderRank,
string senderNickName,
UserPermissions senderPerms,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) : ChatEvent {
public long MessageId { get; } = msgId;
public string ChannelName { get; } = channelName;
public string SenderId { get; } = senderId;
public string SenderName { get; } = senderName;
public ColourInheritable SenderColour { get; } = senderColour;
public int SenderRank { get; } = senderRank;
public string SenderNickName { get; } = senderNickName;
public UserPermissions SenderPerms { get; } = senderPerms;
public DateTimeOffset MessageCreated { get; } = msgCreated;
public string MessageText { get; } = msgText;
public bool IsPrivate { get; } = isPrivate;
public bool IsAction { get; } = isAction;
public bool IsBroadcast { get; } = isBroadcast;
}

View file

@ -3,145 +3,145 @@ using SharpChat.EventStorage;
using SharpChat.Flashii;
using System.Text;
namespace SharpChat {
public class Program {
public const string CONFIG = "sharpchat.cfg";
namespace SharpChat;
public static void Main() {
Console.WriteLine(@" _____ __ ________ __ ");
Console.WriteLine(@" / ___// /_ ____ __________ / ____/ /_ ____ _/ /_");
Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/");
Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ ");
Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ ");
/**/Console.Write(@" /__/");
if(SharpInfo.IsDebugBuild) {
Console.WriteLine();
Console.Write(@"== ");
Console.Write(SharpInfo.VersionString);
Console.WriteLine(@" == DBG ==");
} else
Console.WriteLine(SharpInfo.VersionStringShort.PadLeft(28, ' '));
public class Program {
public const string CONFIG = "sharpchat.cfg";
using ManualResetEvent mre = new(false);
bool hasCancelled = false;
public static void Main() {
Console.WriteLine(@" _____ __ ________ __ ");
Console.WriteLine(@" / ___// /_ ____ __________ / ____/ /_ ____ _/ /_");
Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/");
Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ ");
Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ ");
/**/Console.Write(@" /__/");
if(SharpInfo.IsDebugBuild) {
Console.WriteLine();
Console.Write(@"== ");
Console.Write(SharpInfo.VersionString);
Console.WriteLine(@" == DBG ==");
} else
Console.WriteLine(SharpInfo.VersionStringShort.PadLeft(28, ' '));
void cancelKeyPressHandler(object? sender, ConsoleCancelEventArgs ev) {
Console.CancelKeyPress -= cancelKeyPressHandler;
hasCancelled = true;
ev.Cancel = true;
mre.Set();
};
Console.CancelKeyPress += cancelKeyPressHandler;
using ManualResetEvent mre = new(false);
bool hasCancelled = false;
if(hasCancelled) return;
void cancelKeyPressHandler(object? sender, ConsoleCancelEventArgs ev) {
Console.CancelKeyPress -= cancelKeyPressHandler;
hasCancelled = true;
ev.Cancel = true;
mre.Set();
};
Console.CancelKeyPress += cancelKeyPressHandler;
string configFile = CONFIG;
if(hasCancelled) return;
// If the config file doesn't exist and we're using the default path, run the converter
if(!File.Exists(configFile) && configFile == CONFIG)
ConvertConfiguration();
string configFile = CONFIG;
using StreamConfig config = StreamConfig.FromPath(configFile);
// If the config file doesn't exist and we're using the default path, run the converter
if(!File.Exists(configFile) && configFile == CONFIG)
ConvertConfiguration();
if(hasCancelled) return;
using StreamConfig config = StreamConfig.FromPath(configFile);
using HttpClient httpClient = new(new HttpClientHandler() {
UseProxy = false,
});
httpClient.DefaultRequestHeaders.Add("User-Agent", SharpInfo.ProgramName);
if(hasCancelled) return;
if(hasCancelled) return;
using HttpClient httpClient = new(new HttpClientHandler() {
UseProxy = false,
});
httpClient.DefaultRequestHeaders.Add("User-Agent", SharpInfo.ProgramName);
FlashiiClient flashii = new(httpClient, config.ScopeTo("msz"));
if(hasCancelled) return;
if(hasCancelled) return;
FlashiiClient flashii = new(httpClient, config.ScopeTo("msz"));
EventStorage.EventStorage evtStore;
if(string.IsNullOrWhiteSpace(config.SafeReadValue("mariadb:host", string.Empty))) {
evtStore = new VirtualEventStorage();
} else {
MariaDBEventStorage mdbes = new(MariaDBEventStorage.BuildConnString(config.ScopeTo("mariadb")));
evtStore = mdbes;
mdbes.RunMigrations();
}
if(hasCancelled) return;
if(hasCancelled) return;
using SockChatServer scs = new(flashii, flashii, evtStore, config.ScopeTo("chat"));
scs.Listen(mre);
mre.WaitOne();
EventStorage.EventStorage evtStore;
if(string.IsNullOrWhiteSpace(config.SafeReadValue("mariadb:host", string.Empty))) {
evtStore = new VirtualEventStorage();
} else {
MariaDBEventStorage mdbes = new(MariaDBEventStorage.BuildConnString(config.ScopeTo("mariadb")));
evtStore = mdbes;
mdbes.RunMigrations();
}
private static void ConvertConfiguration() {
using Stream s = new FileStream(CONFIG, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
s.SetLength(0);
s.Flush();
if(hasCancelled) return;
using StreamWriter sw = new(s, new UTF8Encoding(false));
sw.WriteLine("# and ; can be used at the start of a line for comments.");
sw.WriteLine();
using SockChatServer scs = new(flashii, flashii, evtStore, config.ScopeTo("chat"));
scs.Listen(mre);
sw.WriteLine("# General Configuration");
sw.WriteLine($"#chat:port {SockChatServer.DEFAULT_PORT}");
sw.WriteLine($"#chat:msgMaxLength {SockChatServer.DEFAULT_MSG_LENGTH_MAX}");
sw.WriteLine($"#chat:connMaxCount {SockChatServer.DEFAULT_MAX_CONNECTIONS}");
sw.WriteLine($"#chat:floodKickLength {SockChatServer.DEFAULT_FLOOD_KICK_LENGTH}");
mre.WaitOne();
}
private static void ConvertConfiguration() {
using Stream s = new FileStream(CONFIG, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
s.SetLength(0);
s.Flush();
using StreamWriter sw = new(s, new UTF8Encoding(false));
sw.WriteLine("# and ; can be used at the start of a line for comments.");
sw.WriteLine();
sw.WriteLine("# General Configuration");
sw.WriteLine($"#chat:port {SockChatServer.DEFAULT_PORT}");
sw.WriteLine($"#chat:msgMaxLength {SockChatServer.DEFAULT_MSG_LENGTH_MAX}");
sw.WriteLine($"#chat:connMaxCount {SockChatServer.DEFAULT_MAX_CONNECTIONS}");
sw.WriteLine($"#chat:floodKickLength {SockChatServer.DEFAULT_FLOOD_KICK_LENGTH}");
sw.WriteLine();
sw.WriteLine("# Channels");
sw.WriteLine("chat:channels lounge staff");
sw.WriteLine();
sw.WriteLine();
sw.WriteLine("# Channels");
sw.WriteLine("chat:channels lounge staff");
sw.WriteLine();
sw.WriteLine("# Lounge channel settings");
sw.WriteLine("chat:channels:lounge:name Lounge");
sw.WriteLine("chat:channels:lounge:autoJoin true");
sw.WriteLine();
sw.WriteLine("# Lounge channel settings");
sw.WriteLine("chat:channels:lounge:name Lounge");
sw.WriteLine("chat:channels:lounge:autoJoin true");
sw.WriteLine();
sw.WriteLine("# Staff channel settings");
sw.WriteLine("chat:channels:staff:name Staff");
sw.WriteLine("chat:channels:staff:minRank 5");
sw.WriteLine("# Staff channel settings");
sw.WriteLine("chat:channels:staff:name Staff");
sw.WriteLine("chat:channels:staff:minRank 5");
const string msz_secret = "login_key.txt";
const string msz_url = "msz_url.txt";
const string msz_secret = "login_key.txt";
const string msz_url = "msz_url.txt";
sw.WriteLine();
sw.WriteLine("# Misuzu integration settings");
if(File.Exists(msz_secret))
sw.WriteLine(string.Format("msz:secret {0}", File.ReadAllText(msz_secret).Trim()));
else
sw.WriteLine("#msz:secret woomy");
if(File.Exists(msz_url))
sw.WriteLine(string.Format("msz:url {0}/_sockchat", File.ReadAllText(msz_url).Trim()));
else
sw.WriteLine("#msz:url https://flashii.net/_sockchat");
sw.WriteLine();
sw.WriteLine("# Misuzu integration settings");
if(File.Exists(msz_secret))
sw.WriteLine(string.Format("msz:secret {0}", File.ReadAllText(msz_secret).Trim()));
else
sw.WriteLine("#msz:secret woomy");
if(File.Exists(msz_url))
sw.WriteLine(string.Format("msz:url {0}/_sockchat", File.ReadAllText(msz_url).Trim()));
else
sw.WriteLine("#msz:url https://flashii.net/_sockchat");
const string mdb_config = @"mariadb.txt";
string[] mdbCfg = File.Exists(mdb_config) ? File.ReadAllLines(mdb_config) : [];
const string mdb_config = @"mariadb.txt";
string[] mdbCfg = File.Exists(mdb_config) ? File.ReadAllLines(mdb_config) : [];
sw.WriteLine();
sw.WriteLine("# MariaDB configuration");
if(mdbCfg.Length > 0)
sw.WriteLine($"mariadb:host {mdbCfg[0]}");
else
sw.WriteLine($"#mariadb:host <username>");
if(mdbCfg.Length > 1)
sw.WriteLine($"mariadb:user {mdbCfg[1]}");
else
sw.WriteLine($"#mariadb:user <username>");
if(mdbCfg.Length > 2)
sw.WriteLine($"mariadb:pass {mdbCfg[2]}");
else
sw.WriteLine($"#mariadb:pass <password>");
if(mdbCfg.Length > 3)
sw.WriteLine($"mariadb:db {mdbCfg[3]}");
else
sw.WriteLine($"#mariadb:db <database>");
sw.WriteLine();
sw.WriteLine("# MariaDB configuration");
if(mdbCfg.Length > 0)
sw.WriteLine($"mariadb:host {mdbCfg[0]}");
else
sw.WriteLine($"#mariadb:host <username>");
if(mdbCfg.Length > 1)
sw.WriteLine($"mariadb:user {mdbCfg[1]}");
else
sw.WriteLine($"#mariadb:user <username>");
if(mdbCfg.Length > 2)
sw.WriteLine($"mariadb:pass {mdbCfg[2]}");
else
sw.WriteLine($"#mariadb:pass <password>");
if(mdbCfg.Length > 3)
sw.WriteLine($"mariadb:db {mdbCfg[3]}");
else
sw.WriteLine($"#mariadb:db <database>");
sw.Flush();
}
sw.Flush();
}
}

View file

@ -12,156 +12,156 @@ using System.Text;
// 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 {
namespace SharpChat;
private readonly string _scheme;
private readonly IPAddress _locationIP;
private Action<IWebSocketConnection> _config;
public class SharpChatWebSocketServer : IWebSocketServer {
public SharpChatWebSocketServer(string location, bool supportDualStack = true) {
Uri uri = new(location);
private readonly string _scheme;
private readonly IPAddress _locationIP;
private Action<IWebSocketConnection> _config;
Port = uri.Port;
Location = location;
SupportDualStack = supportDualStack;
public SharpChatWebSocketServer(string location, bool supportDualStack = true) {
Uri uri = new(location);
_locationIP = ParseIPAddress(uri);
_scheme = uri.Scheme;
Socket socket = new(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
Port = uri.Port;
Location = location;
SupportDualStack = supportDualStack;
if(SupportDualStack && Type.GetType("Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false);
}
_locationIP = ParseIPAddress(uri);
_scheme = uri.Scheme;
Socket socket = new(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
ListenerSocket = new SocketWrapper(socket);
SupportedSubProtocols = [];
if(SupportDualStack && Type.GetType("Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false);
}
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; }
ListenerSocket = new SocketWrapper(socket);
SupportedSubProtocols = [];
}
public bool IsSecure {
get { return _scheme == "wss" && Certificate != null; }
}
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 void Dispose() {
ListenerSocket.Dispose();
GC.SuppressFinalize(this);
}
public bool IsSecure {
get { return _scheme == "wss" && Certificate != null; }
}
private static IPAddress ParseIPAddress(Uri uri) {
string ipStr = uri.Host;
public void Dispose() {
ListenerSocket.Dispose();
GC.SuppressFinalize(this);
}
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);
}
}
}
private static IPAddress ParseIPAddress(Uri uri) {
string ipStr = uri.Host;
public void Start(Action<IWebSocketConnection> config) {
IPEndPoint ipLocal = new(_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;
}
// makes dotnet shut up, TLS is handled by NGINX anyway
// 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(_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();
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(_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;
}
// makes dotnet shut up, TLS is handled by NGINX anyway
// 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(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
ListenerSocket = new SocketWrapper(socket);
Start(_config);
FleckLog.Info("Listener socket restarted");
} catch(Exception ex) {
FleckLog.Error("Listener could not be restarted", ex);
}
}
});
}
private void OnClientConnect(ISocket clientSocket) {
if(clientSocket == null) return; // socket closed
FleckLog.Debug(string.Format("Client connected from {0}:{1}", clientSocket.RemoteIpAddress, clientSocket.RemotePort.ToString()));
ListenForClients();
WebSocketConnection connection = null;
connection = new WebSocketConnection(
clientSocket,
_config,
bytes => RequestParser.Parse(bytes, _scheme),
r => {
try {
return HandlerFactory.BuildHandler(
r, s => connection.OnMessage(s), connection.Close, b => connection.OnBinary(b),
b => connection.OnPing(b), b => connection.OnPong(b)
);
} catch(WebSocketException) {
const string responseMsg = "HTTP/1.1 200 OK\r\n"
+ "Date: {0}\r\n"
+ "Server: SharpChat\r\n"
+ "Content-Length: {1}\r\n"
+ "Content-Type: text/html; charset=utf-8\r\n"
+ "Connection: close\r\n"
+ "\r\n"
+ "{2}";
string responseBody = File.Exists("http-motd.txt") ? File.ReadAllText("http-motd.txt") : "SharpChat";
clientSocket.Stream.Write(Encoding.UTF8.GetBytes(string.Format(
responseMsg, DateTimeOffset.Now.ToString("r"), Encoding.UTF8.GetByteCount(responseBody), responseBody
)));
clientSocket.Close();
return null;
}
},
s => SubProtocolNegotiator.Negotiate(SupportedSubProtocols, s));
if(IsSecure) {
FleckLog.Debug("Authenticating Secure Connection");
clientSocket
.Authenticate(Certificate,
EnabledSslProtocols,
connection.StartReceiving,
e => FleckLog.Warn("Failed to Authenticate", e));
} else {
connection.StartReceiving();
}
}
}

View file

@ -1,115 +1,115 @@
using SharpChat.EventStorage;
using System.Text;
namespace SharpChat.SockChat.S2CPackets {
public class ContextMessageS2CPacket(StoredEventInfo evt, bool notify = false) : S2CPacket {
public StoredEventInfo Event { get; private set; } = evt ?? throw new ArgumentNullException(nameof(evt));
namespace SharpChat.SockChat.S2CPackets;
public string Pack() {
bool isAction = Event.Flags.HasFlag(StoredEventFlags.Action);
bool isBroadcast = Event.Flags.HasFlag(StoredEventFlags.Broadcast);
bool isPrivate = Event.Flags.HasFlag(StoredEventFlags.Private);
public class ContextMessageS2CPacket(StoredEventInfo evt, bool notify = false) : S2CPacket {
public StoredEventInfo Event { get; private set; } = evt ?? throw new ArgumentNullException(nameof(evt));
StringBuilder sb = new();
public string Pack() {
bool isAction = Event.Flags.HasFlag(StoredEventFlags.Action);
bool isBroadcast = Event.Flags.HasFlag(StoredEventFlags.Broadcast);
bool isPrivate = Event.Flags.HasFlag(StoredEventFlags.Private);
sb.Append("7\t1\t");
sb.Append(Event.Created.ToUnixTimeSeconds());
sb.Append('\t');
StringBuilder sb = new();
switch(Event.Type) {
case "msg:add":
case "SharpChat.Events.ChatMessage":
if(isBroadcast || Event.Sender is null) {
sb.Append("-1\tChatBot\tinherit\t\t0\fsay\f");
} else {
sb.Append(Event.Sender.UserId);
sb.Append('\t');
sb.Append(Event.Sender.LegacyNameWithStatus);
sb.Append('\t');
sb.Append(Event.Sender.Colour);
sb.Append('\t');
sb.Append(Event.Sender.Rank);
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.CreateChannel) ? (Event.Sender.Permissions.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
}
sb.Append("7\t1\t");
sb.Append(Event.Created.ToUnixTimeSeconds());
sb.Append('\t');
if(isAction)
sb.Append("<i>");
switch(Event.Type) {
case "msg:add":
case "SharpChat.Events.ChatMessage":
if(isBroadcast || Event.Sender is null) {
sb.Append("-1\tChatBot\tinherit\t\t0\fsay\f");
} else {
sb.Append(Event.Sender.UserId);
sb.Append('\t');
sb.Append(Event.Sender.LegacyNameWithStatus);
sb.Append('\t');
sb.Append(Event.Sender.Colour);
sb.Append('\t');
sb.Append(Event.Sender.Rank);
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(Event.Sender.Permissions.HasFlag(UserPermissions.CreateChannel) ? (Event.Sender.Permissions.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
sb.Append('\t');
}
sb.Append(
(Event.Data.RootElement.GetProperty("text").GetString()?
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")) ?? string.Empty
);
if(isAction)
sb.Append("<i>");
if(isAction)
sb.Append("</i>");
break;
sb.Append(
(Event.Data.RootElement.GetProperty("text").GetString()?
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")) ?? string.Empty
);
case "user:connect":
case "SharpChat.Events.UserConnectEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\fjoin\f");
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
if(isAction)
sb.Append("</i>");
break;
case "chan:join":
case "SharpChat.Events.UserChannelJoinEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\fjchan\f");
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
case "user:connect":
case "SharpChat.Events.UserConnectEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\fjoin\f");
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
case "chan:leave":
case "SharpChat.Events.UserChannelLeaveEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\flchan\f");
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
case "chan:join":
case "SharpChat.Events.UserChannelJoinEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\fjchan\f");
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
case "user:disconnect":
case "SharpChat.Events.UserDisconnectEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\f");
case "chan:leave":
case "SharpChat.Events.UserChannelLeaveEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\flchan\f");
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
switch((UserDisconnectS2CPacket.Reason)Event.Data.RootElement.GetProperty("reason").GetByte()) {
case UserDisconnectS2CPacket.Reason.Flood:
sb.Append("flood");
break;
case UserDisconnectS2CPacket.Reason.Kicked:
sb.Append("kick");
break;
case UserDisconnectS2CPacket.Reason.TimeOut:
sb.Append("timeout");
break;
case UserDisconnectS2CPacket.Reason.Leave:
default:
sb.Append("leave");
break;
}
case "user:disconnect":
case "SharpChat.Events.UserDisconnectEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\f");
sb.Append('\f');
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
}
switch((UserDisconnectS2CPacket.Reason)Event.Data.RootElement.GetProperty("reason").GetByte()) {
case UserDisconnectS2CPacket.Reason.Flood:
sb.Append("flood");
break;
case UserDisconnectS2CPacket.Reason.Kicked:
sb.Append("kick");
break;
case UserDisconnectS2CPacket.Reason.TimeOut:
sb.Append("timeout");
break;
case UserDisconnectS2CPacket.Reason.Leave:
default:
sb.Append("leave");
break;
}
sb.Append('\t');
sb.Append(Event.Id);
sb.Append('\t');
sb.Append(notify ? '1' : '0');
sb.AppendFormat(
"\t1{0}0{1}{2}",
isAction ? '1' : '0',
isAction ? '0' : '1',
isPrivate ? '1' : '0'
);
return sb.ToString();
sb.Append('\f');
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
}
sb.Append('\t');
sb.Append(Event.Id);
sb.Append('\t');
sb.Append(notify ? '1' : '0');
sb.AppendFormat(
"\t1{0}0{1}{2}",
isAction ? '1' : '0',
isAction ? '0' : '1',
isPrivate ? '1' : '0'
);
return sb.ToString();
}
}

View file

@ -7,234 +7,234 @@ using SharpChat.Configuration;
using SharpChat.SockChat.S2CPackets;
using System.Net;
namespace SharpChat {
public class SockChatServer : IDisposable {
public const ushort DEFAULT_PORT = 6770;
public const int DEFAULT_MSG_LENGTH_MAX = 5000;
public const int DEFAULT_MAX_CONNECTIONS = 5;
public const int DEFAULT_FLOOD_KICK_LENGTH = 30;
public const int DEFAULT_FLOOD_KICK_EXEMPT_RANK = 9;
namespace SharpChat;
public IWebSocketServer Server { get; }
public Context Context { get; }
public class SockChatServer : IDisposable {
public const ushort DEFAULT_PORT = 6770;
public const int DEFAULT_MSG_LENGTH_MAX = 5000;
public const int DEFAULT_MAX_CONNECTIONS = 5;
public const int DEFAULT_FLOOD_KICK_LENGTH = 30;
public const int DEFAULT_FLOOD_KICK_EXEMPT_RANK = 9;
private readonly BansClient BansClient;
public IWebSocketServer Server { get; }
public Context Context { get; }
private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections;
private readonly CachedValue<int> FloodKickLength;
private readonly CachedValue<int> FloodKickExemptRank;
private readonly BansClient BansClient;
private readonly List<C2SPacketHandler> GuestHandlers = [];
private readonly List<C2SPacketHandler> AuthedHandlers = [];
private readonly SendMessageC2SPacketHandler SendMessageHandler;
private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections;
private readonly CachedValue<int> FloodKickLength;
private readonly CachedValue<int> FloodKickExemptRank;
private bool IsShuttingDown = false;
private readonly List<C2SPacketHandler> GuestHandlers = [];
private readonly List<C2SPacketHandler> AuthedHandlers = [];
private readonly SendMessageC2SPacketHandler SendMessageHandler;
private static readonly string[] DEFAULT_CHANNELS = ["lounge"];
private bool IsShuttingDown = false;
private Channel DefaultChannel { get; set; }
private static readonly string[] DEFAULT_CHANNELS = ["lounge"];
public SockChatServer(
AuthClient authClient,
BansClient bansClient,
EventStorage.EventStorage evtStore,
Config config
) {
Logger.Write("Initialising Sock Chat server...");
private Channel DefaultChannel { get; set; }
BansClient = bansClient;
public SockChatServer(
AuthClient authClient,
BansClient bansClient,
EventStorage.EventStorage evtStore,
Config config
) {
Logger.Write("Initialising Sock Chat server...");
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
FloodKickExemptRank = config.ReadCached("floodKickExemptRank", DEFAULT_FLOOD_KICK_EXEMPT_RANK);
BansClient = bansClient;
Context = new Context(evtStore);
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
FloodKickExemptRank = config.ReadCached("floodKickExemptRank", DEFAULT_FLOOD_KICK_EXEMPT_RANK);
string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS);
if(channelNames is not null)
foreach(string channelName in channelNames) {
Config channelCfg = config.ScopeTo($"channels:{channelName}");
Context = new Context(evtStore);
string name = channelCfg.SafeReadValue("name", string.Empty)!;
if(string.IsNullOrWhiteSpace(name))
name = channelName;
string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS);
if(channelNames is not null)
foreach(string channelName in channelNames) {
Config channelCfg = config.ScopeTo($"channels:{channelName}");
Channel channelInfo = new(
name,
channelCfg.SafeReadValue("password", string.Empty)!,
rank: channelCfg.SafeReadValue("minRank", 0)
);
string name = channelCfg.SafeReadValue("name", string.Empty)!;
if(string.IsNullOrWhiteSpace(name))
name = channelName;
Context.Channels.Add(channelInfo);
DefaultChannel ??= channelInfo;
}
Channel channelInfo = new(
name,
channelCfg.SafeReadValue("password", string.Empty)!,
rank: channelCfg.SafeReadValue("minRank", 0)
);
if(DefaultChannel is null)
throw new Exception("The default channel could not be determined.");
Context.Channels.Add(channelInfo);
DefaultChannel ??= channelInfo;
}
GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, DefaultChannel, MaxMessageLength, MaxConnections));
if(DefaultChannel is null)
throw new Exception("The default channel could not be determined.");
AuthedHandlers.AddRange([
new PingC2SPacketHandler(authClient),
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
]);
GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, DefaultChannel, MaxMessageLength, MaxConnections));
SendMessageHandler.AddCommands([
new AFKClientCommand(),
new NickClientCommand(),
new WhisperClientCommand(),
new ActionClientCommand(),
new WhoClientCommand(),
new JoinChannelClientCommand(),
new CreateChannelClientCommand(),
new DeleteChannelClientCommand(),
new PasswordChannelClientCommand(),
new RankChannelClientCommand(),
new BroadcastClientCommand(),
new DeleteMessageClientCommand(),
new KickBanClientCommand(bansClient),
new PardonUserClientCommand(bansClient),
new PardonAddressClientCommand(bansClient),
new BanListClientCommand(bansClient),
new RemoteAddressClientCommand(),
]);
AuthedHandlers.AddRange([
new PingC2SPacketHandler(authClient),
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
]);
ushort port = config.SafeReadValue("port", DEFAULT_PORT);
Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}");
SendMessageHandler.AddCommands([
new AFKClientCommand(),
new NickClientCommand(),
new WhisperClientCommand(),
new ActionClientCommand(),
new WhoClientCommand(),
new JoinChannelClientCommand(),
new CreateChannelClientCommand(),
new DeleteChannelClientCommand(),
new PasswordChannelClientCommand(),
new RankChannelClientCommand(),
new BroadcastClientCommand(),
new DeleteMessageClientCommand(),
new KickBanClientCommand(bansClient),
new PardonUserClientCommand(bansClient),
new PardonAddressClientCommand(bansClient),
new BanListClientCommand(bansClient),
new RemoteAddressClientCommand(),
]);
ushort port = config.SafeReadValue("port", DEFAULT_PORT);
Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}");
}
public void Listen(ManualResetEvent waitHandle) {
if(waitHandle != null)
SendMessageHandler.AddCommand(new ShutdownRestartClientCommand(waitHandle, () => !IsShuttingDown && (IsShuttingDown = true)));
Server.Start(sock => {
if(IsShuttingDown) {
sock.Close(1013);
return;
}
Connection conn = new(sock);
Context.Connections.Add(conn);
sock.OnOpen = () => OnOpen(conn).Wait();
sock.OnClose = () => OnClose(conn).Wait();
sock.OnError = err => OnError(conn, err).Wait();
sock.OnMessage = msg => OnMessage(conn, msg).Wait();
});
Logger.Write("Listening...");
}
private async Task OnOpen(Connection conn) {
Logger.Write($"Connection opened from {conn.RemoteAddress}:{conn.RemotePort}");
await Context.SafeUpdate();
}
private async Task OnError(Connection conn, Exception ex) {
Logger.Write($"[{conn.Id} {conn.RemoteAddress}] {ex}");
await Context.SafeUpdate();
}
private async Task OnClose(Connection conn) {
Logger.Write($"Connection closed from {conn.RemoteAddress}:{conn.RemotePort}");
Context.ContextAccess.Wait();
try {
Context.Connections.Remove(conn);
if(conn.User != null && !Context.Connections.Any(c => c.User == conn.User))
await Context.HandleDisconnect(conn.User);
await Context.Update();
} finally {
Context.ContextAccess.Release();
}
}
public void Listen(ManualResetEvent waitHandle) {
if(waitHandle != null)
SendMessageHandler.AddCommand(new ShutdownRestartClientCommand(waitHandle, () => !IsShuttingDown && (IsShuttingDown = true)));
private async Task OnMessage(Connection conn, string msg) {
await Context.SafeUpdate();
Server.Start(sock => {
if(IsShuttingDown) {
sock.Close(1013);
return;
}
Connection conn = new(sock);
Context.Connections.Add(conn);
sock.OnOpen = () => OnOpen(conn).Wait();
sock.OnClose = () => OnClose(conn).Wait();
sock.OnError = err => OnError(conn, err).Wait();
sock.OnMessage = msg => OnMessage(conn, msg).Wait();
});
Logger.Write("Listening...");
}
private async Task OnOpen(Connection conn) {
Logger.Write($"Connection opened from {conn.RemoteAddress}:{conn.RemotePort}");
await Context.SafeUpdate();
}
private async Task OnError(Connection conn, Exception ex) {
Logger.Write($"[{conn.Id} {conn.RemoteAddress}] {ex}");
await Context.SafeUpdate();
}
private async Task OnClose(Connection conn) {
Logger.Write($"Connection closed from {conn.RemoteAddress}:{conn.RemotePort}");
// this doesn't affect non-authed connections?????
if(conn.User is not null && conn.User.Rank < FloodKickExemptRank) {
User? banUser = null;
string banAddr = string.Empty;
TimeSpan banDuration = TimeSpan.MinValue;
Context.ContextAccess.Wait();
try {
Context.Connections.Remove(conn);
if(!Context.UserRateLimiters.TryGetValue(conn.User.UserId, out RateLimiter? rateLimiter))
Context.UserRateLimiters.Add(conn.User.UserId, rateLimiter = new RateLimiter(
User.DEFAULT_SIZE,
User.DEFAULT_MINIMUM_DELAY,
User.DEFAULT_RISKY_OFFSET
));
if(conn.User != null && !Context.Connections.Any(c => c.User == conn.User))
await Context.HandleDisconnect(conn.User);
rateLimiter.Update();
await Context.Update();
if(rateLimiter.IsExceeded) {
banDuration = TimeSpan.FromSeconds(FloodKickLength);
banUser = conn.User;
banAddr = conn.RemoteAddress.ToString();
} else if(rateLimiter.IsRisky) {
banUser = conn.User;
}
if(banUser is not null) {
if(banDuration == TimeSpan.MinValue) {
await Context.SendTo(conn.User, new CommandResponseS2CPacket(Context.RandomSnowflake.Next(), LCR.FLOOD_WARN, false));
} else {
await Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood);
if(banDuration > TimeSpan.Zero)
await BansClient.BanCreateAsync(
BanKind.User,
banDuration,
conn.RemoteAddress,
conn.User.UserId,
"Kicked from chat for flood protection.",
IPAddress.IPv6Loopback
);
return;
}
}
} finally {
Context.ContextAccess.Release();
}
}
private async Task OnMessage(Connection conn, string msg) {
await Context.SafeUpdate();
C2SPacketHandlerContext context = new(msg, Context, conn);
C2SPacketHandler? handler = conn.User is null
? GuestHandlers.FirstOrDefault(h => h.IsMatch(context))
: AuthedHandlers.FirstOrDefault(h => h.IsMatch(context));
// this doesn't affect non-authed connections?????
if(conn.User is not null && conn.User.Rank < FloodKickExemptRank) {
User? banUser = null;
string banAddr = string.Empty;
TimeSpan banDuration = TimeSpan.MinValue;
if(handler is not null)
await handler.Handle(context);
}
Context.ContextAccess.Wait();
try {
if(!Context.UserRateLimiters.TryGetValue(conn.User.UserId, out RateLimiter? rateLimiter))
Context.UserRateLimiters.Add(conn.User.UserId, rateLimiter = new RateLimiter(
User.DEFAULT_SIZE,
User.DEFAULT_MINIMUM_DELAY,
User.DEFAULT_RISKY_OFFSET
));
private bool IsDisposed;
rateLimiter.Update();
~SockChatServer() {
DoDispose();
}
if(rateLimiter.IsExceeded) {
banDuration = TimeSpan.FromSeconds(FloodKickLength);
banUser = conn.User;
banAddr = conn.RemoteAddress.ToString();
} else if(rateLimiter.IsRisky) {
banUser = conn.User;
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
if(banUser is not null) {
if(banDuration == TimeSpan.MinValue) {
await Context.SendTo(conn.User, new CommandResponseS2CPacket(Context.RandomSnowflake.Next(), LCR.FLOOD_WARN, false));
} else {
await Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood);
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
IsShuttingDown = true;
if(banDuration > TimeSpan.Zero)
await BansClient.BanCreateAsync(
BanKind.User,
banDuration,
conn.RemoteAddress,
conn.User.UserId,
"Kicked from chat for flood protection.",
IPAddress.IPv6Loopback
);
foreach(Connection conn in Context.Connections)
conn.Dispose();
return;
}
}
} finally {
Context.ContextAccess.Release();
}
}
C2SPacketHandlerContext context = new(msg, Context, conn);
C2SPacketHandler? handler = conn.User is null
? GuestHandlers.FirstOrDefault(h => h.IsMatch(context))
: AuthedHandlers.FirstOrDefault(h => h.IsMatch(context));
if(handler is not null)
await handler.Handle(context);
}
private bool IsDisposed;
~SockChatServer() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
IsShuttingDown = true;
foreach(Connection conn in Context.Connections)
conn.Dispose();
Server?.Dispose();
}
Server?.Dispose();
}
}

View file

@ -2,72 +2,72 @@ using SharpChat.ClientCommands;
using System.Globalization;
using System.Text;
namespace SharpChat {
public class User(
string userId,
string userName,
ColourInheritable colour,
int rank,
UserPermissions perms,
string nickName = "",
UserStatus status = UserStatus.Online,
string statusText = ""
) {
public const int DEFAULT_SIZE = 30;
public const int DEFAULT_MINIMUM_DELAY = 10000;
public const int DEFAULT_RISKY_OFFSET = 5;
namespace SharpChat;
public string UserId { get; } = userId;
public string UserName { get; set; } = userName ?? throw new ArgumentNullException(nameof(userName));
public ColourInheritable Colour { get; set; } = colour;
public int Rank { get; set; } = rank;
public UserPermissions Permissions { get; set; } = perms;
public string NickName { get; set; } = nickName;
public UserStatus Status { get; set; } = status;
public string StatusText { get; set; } = statusText;
public class User(
string userId,
string userName,
ColourInheritable colour,
int rank,
UserPermissions perms,
string nickName = "",
UserStatus status = UserStatus.Online,
string statusText = ""
) {
public const int DEFAULT_SIZE = 30;
public const int DEFAULT_MINIMUM_DELAY = 10000;
public const int DEFAULT_RISKY_OFFSET = 5;
public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
public string UserId { get; } = userId;
public string UserName { get; set; } = userName ?? throw new ArgumentNullException(nameof(userName));
public ColourInheritable Colour { get; set; } = colour;
public int Rank { get; set; } = rank;
public UserPermissions Permissions { get; set; } = perms;
public string NickName { get; set; } = nickName;
public UserStatus Status { get; set; } = status;
public string StatusText { get; set; } = statusText;
public string LegacyNameWithStatus {
get {
StringBuilder sb = new();
public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
if(Status == UserStatus.Away) {
string statusText = StatusText.Trim();
StringInfo sti = new(statusText);
if(Encoding.UTF8.GetByteCount(statusText) > AFKClientCommand.MAX_BYTES
|| sti.LengthInTextElements > AFKClientCommand.MAX_GRAPHEMES)
statusText = sti.SubstringByTextElements(0, Math.Min(sti.LengthInTextElements, AFKClientCommand.MAX_GRAPHEMES)).Trim();
public string LegacyNameWithStatus {
get {
StringBuilder sb = new();
sb.AppendFormat("&lt;{0}&gt;_", statusText.ToUpperInvariant());
}
if(Status == UserStatus.Away) {
string statusText = StatusText.Trim();
StringInfo sti = new(statusText);
if(Encoding.UTF8.GetByteCount(statusText) > AFKClientCommand.MAX_BYTES
|| sti.LengthInTextElements > AFKClientCommand.MAX_GRAPHEMES)
statusText = sti.SubstringByTextElements(0, Math.Min(sti.LengthInTextElements, AFKClientCommand.MAX_GRAPHEMES)).Trim();
sb.Append(LegacyName);
return sb.ToString();
sb.AppendFormat("&lt;{0}&gt;_", statusText.ToUpperInvariant());
}
}
public bool Can(UserPermissions perm, bool strict = false) {
UserPermissions perms = Permissions & perm;
return strict ? perms == perm : perms > 0;
}
sb.Append(LegacyName);
public bool NameEquals(string name) {
return string.Equals(name, UserName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, NickName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, LegacyName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, LegacyNameWithStatus, StringComparison.InvariantCultureIgnoreCase);
}
public override int GetHashCode() {
return UserId.GetHashCode();
}
public static string GetDMChannelName(User user1, User user2) {
return string.Compare(user1.UserId, user2.UserId, StringComparison.InvariantCultureIgnoreCase) > 0
? $"@{user2.UserId}-{user1.UserId}"
: $"@{user1.UserId}-{user2.UserId}";
return sb.ToString();
}
}
public bool Can(UserPermissions perm, bool strict = false) {
UserPermissions perms = Permissions & perm;
return strict ? perms == perm : perms > 0;
}
public bool NameEquals(string name) {
return string.Equals(name, UserName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, NickName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, LegacyName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, LegacyNameWithStatus, StringComparison.InvariantCultureIgnoreCase);
}
public override int GetHashCode() {
return UserId.GetHashCode();
}
public static string GetDMChannelName(User user1, User user2) {
return string.Compare(user1.UserId, user2.UserId, StringComparison.InvariantCultureIgnoreCase) > 0
? $"@{user2.UserId}-{user1.UserId}"
: $"@{user1.UserId}-{user2.UserId}";
}
}

View file

@ -1,7 +1,7 @@
namespace SharpChat {
public enum UserStatus {
Online,
Away,
Offline,
}
namespace SharpChat;
public enum UserStatus {
Online,
Away,
Offline,
}

View file

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

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

View file

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

View file

@ -1,9 +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();
}
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

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

View file

@ -1,18 +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();
}
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

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

View file

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

View file

@ -1,14 +1,14 @@
namespace SharpChat {
public readonly record struct ColourInheritable(ColourRgb? rgb) {
public static readonly ColourInheritable None = new(null);
public override string ToString() => rgb.HasValue ? rgb.Value.ToString() : "inherit";
namespace SharpChat;
public static ColourInheritable FromRaw(int? raw) => raw is null ? None : new(new ColourRgb(raw.Value));
public static ColourInheritable FromRgb(byte red, byte green, byte blue) => new(ColourRgb.FromRgb(red, green, blue));
public readonly record struct ColourInheritable(ColourRgb? rgb) {
public static readonly ColourInheritable None = new(null);
public override string ToString() => rgb.HasValue ? rgb.Value.ToString() : "inherit";
// these should go Away
private const int MSZ_INHERIT = 0x40000000;
public int ToMisuzu() => rgb.HasValue ? rgb.Value.Raw : MSZ_INHERIT;
public static ColourInheritable FromMisuzu(int msz) => (msz & MSZ_INHERIT) > 0 ? None : new(new ColourRgb(msz & 0xFFFFFF));
}
public static ColourInheritable FromRaw(int? raw) => raw is null ? None : new(new ColourRgb(raw.Value));
public static ColourInheritable FromRgb(byte red, byte green, byte blue) => new(ColourRgb.FromRgb(red, green, blue));
// these should go Away
private const int MSZ_INHERIT = 0x40000000;
public int ToMisuzu() => rgb.HasValue ? rgb.Value.Raw : MSZ_INHERIT;
public static ColourInheritable FromMisuzu(int msz) => (msz & MSZ_INHERIT) > 0 ? None : new(new ColourRgb(msz & 0xFFFFFF));
}

View file

@ -1,9 +1,9 @@
namespace SharpChat {
public readonly record struct ColourRgb(int Raw) {
public byte Red => (byte)((Raw >> 16) & 0xFF);
public byte Green => (byte)((Raw >> 8) & 0xFF);
public byte Blue => (byte)(Raw & 0xFF);
public override string ToString() => string.Format("#{0:x2}{1:x2}{2:x2}", Red, Green, Blue);
public static ColourRgb FromRgb(byte red, byte green, byte blue) => new(red << 16 | green << 8 | blue);
}
namespace SharpChat;
public readonly record struct ColourRgb(int Raw) {
public byte Red => (byte)((Raw >> 16) & 0xFF);
public byte Green => (byte)((Raw >> 8) & 0xFF);
public byte Blue => (byte)(Raw & 0xFF);
public override string ToString() => string.Format("#{0:x2}{1:x2}{2:x2}", Red, Green, Blue);
public static ColourRgb FromRgb(byte red, byte green, byte blue) => new(red << 16 | green << 8 | blue);
}

View file

@ -1,34 +1,34 @@
namespace SharpChat.Configuration {
public class CachedValue<T>(Config config, string name, TimeSpan lifetime, T? fallback) {
private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
private string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
private object ConfigAccess { get; } = new();
namespace SharpChat.Configuration;
private object? CurrentValue { get; set; } = default(T);
private DateTimeOffset LastRead { get; set; }
public class CachedValue<T>(Config config, string name, TimeSpan lifetime, T? fallback) {
private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
private string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
private object ConfigAccess { get; } = new();
public T? Value {
get {
lock(ConfigAccess) { // this lock doesn't really make sense since it doesn't affect other config calls
DateTimeOffset now = DateTimeOffset.Now;
if((now - LastRead) >= lifetime) {
LastRead = now;
CurrentValue = Config.ReadValue(Name, fallback);
Logger.Debug($"Read {Name} ({CurrentValue})");
}
private object? CurrentValue { get; set; } = default(T);
private DateTimeOffset LastRead { get; set; }
public T? Value {
get {
lock(ConfigAccess) { // this lock doesn't really make sense since it doesn't affect other config calls
DateTimeOffset now = DateTimeOffset.Now;
if((now - LastRead) >= lifetime) {
LastRead = now;
CurrentValue = Config.ReadValue(Name, fallback);
Logger.Debug($"Read {Name} ({CurrentValue})");
}
return (T?)CurrentValue;
}
}
public static implicit operator T?(CachedValue<T?> val) => val.Value;
public void Refresh() {
LastRead = DateTimeOffset.MinValue;
}
public override string ToString() {
return Value?.ToString() ?? string.Empty;
return (T?)CurrentValue;
}
}
public static implicit operator T?(CachedValue<T?> val) => val.Value;
public void Refresh() {
LastRead = DateTimeOffset.MinValue;
}
public override string ToString() {
return Value?.ToString() ?? string.Empty;
}
}

View file

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

View file

@ -1,9 +1,9 @@
namespace SharpChat.Configuration {
public abstract class ConfigException : Exception {
public ConfigException(string message) : base(message) { }
public ConfigException(string message, Exception ex) : base(message, ex) { }
}
namespace SharpChat.Configuration;
public class ConfigLockException() : ConfigException("Unable to acquire lock for reading configuration.") {}
public class ConfigTypeException(Exception ex) : ConfigException("Given type does not match the value in the configuration.", ex) {}
public abstract class ConfigException : Exception {
public ConfigException(string message) : base(message) { }
public ConfigException(string message, Exception ex) : base(message, ex) { }
}
public class ConfigLockException() : ConfigException("Unable to acquire lock for reading configuration.") {}
public class ConfigTypeException(Exception ex) : ConfigException("Given type does not match the value in the configuration.", ex) {}

View file

@ -1,34 +1,34 @@
namespace SharpChat.Configuration {
public class ScopedConfig(Config config, string prefix) : Config {
private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
private string Prefix { get; } = prefix ?? throw new ArgumentNullException(nameof(prefix));
namespace SharpChat.Configuration;
private string GetName(string name) {
return Prefix + name;
}
public class ScopedConfig(Config config, string prefix) : Config {
private Config Config { get; } = config ?? throw new ArgumentNullException(nameof(config));
private string Prefix { get; } = prefix ?? throw new ArgumentNullException(nameof(prefix));
public string? ReadValue(string name, string? fallback = null) {
return Config.ReadValue(GetName(name), fallback);
}
private string GetName(string name) {
return Prefix + name;
}
public T? ReadValue<T>(string name, T? fallback = default) {
return Config.ReadValue(GetName(name), fallback);
}
public string? ReadValue(string name, string? fallback = null) {
return Config.ReadValue(GetName(name), fallback);
}
public T? SafeReadValue<T>(string name, T? fallback) {
return Config.SafeReadValue(GetName(name), fallback);
}
public T? ReadValue<T>(string name, T? fallback = default) {
return Config.ReadValue(GetName(name), fallback);
}
public Config ScopeTo(string prefix) {
return Config.ScopeTo(GetName(prefix));
}
public T? SafeReadValue<T>(string name, T? fallback) {
return Config.SafeReadValue(GetName(name), fallback);
}
public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
return Config.ReadCached(GetName(name), fallback, lifetime);
}
public Config ScopeTo(string prefix) {
return Config.ScopeTo(GetName(prefix));
}
public void Dispose() {
GC.SuppressFinalize(this);
}
public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
return Config.ReadCached(GetName(name), fallback, lifetime);
}
public void Dispose() {
GC.SuppressFinalize(this);
}
}

View file

@ -1,115 +1,115 @@
using System.Text;
namespace SharpChat.Configuration {
public class StreamConfig : Config {
private Stream Stream { get; }
private StreamReader StreamReader { get; }
private Mutex Lock { get; }
namespace SharpChat.Configuration;
private const int LOCK_TIMEOUT = 10000;
public class StreamConfig : Config {
private Stream Stream { get; }
private StreamReader StreamReader { get; }
private Mutex Lock { get; }
private static readonly TimeSpan CACHE_LIFETIME = TimeSpan.FromMinutes(15);
private const int LOCK_TIMEOUT = 10000;
public StreamConfig(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
if(!Stream.CanRead)
throw new ArgumentException("Provided stream must be readable.", nameof(stream));
if(!Stream.CanSeek)
throw new ArgumentException("Provided stream must be seekable.", nameof(stream));
StreamReader = new StreamReader(stream, new UTF8Encoding(false), false);
Lock = new Mutex();
}
private static readonly TimeSpan CACHE_LIFETIME = TimeSpan.FromMinutes(15);
public static StreamConfig FromPath(string fileName) {
return new StreamConfig(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite));
}
public StreamConfig(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
if(!Stream.CanRead)
throw new ArgumentException("Provided stream must be readable.", nameof(stream));
if(!Stream.CanSeek)
throw new ArgumentException("Provided stream must be seekable.", nameof(stream));
StreamReader = new StreamReader(stream, new UTF8Encoding(false), false);
Lock = new Mutex();
}
public string? ReadValue(string name, string? fallback = null) {
if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong
throw new ConfigLockException();
public static StreamConfig FromPath(string fileName) {
return new StreamConfig(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite));
}
try {
Stream.Seek(0, SeekOrigin.Begin);
public string? ReadValue(string name, string? fallback = null) {
if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong
throw new ConfigLockException();
string? line;
while((line = StreamReader.ReadLine()) != null) {
if(string.IsNullOrWhiteSpace(line))
continue;
try {
Stream.Seek(0, SeekOrigin.Begin);
line = line.TrimStart();
if(line.StartsWith(';') || line.StartsWith('#'))
continue;
string? line;
while((line = StreamReader.ReadLine()) != null) {
if(string.IsNullOrWhiteSpace(line))
continue;
string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if(parts.Length < 2 || !string.Equals(parts[0], name))
continue;
line = line.TrimStart();
if(line.StartsWith(';') || line.StartsWith('#'))
continue;
return parts[1];
}
} finally {
Lock.ReleaseMutex();
string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if(parts.Length < 2 || !string.Equals(parts[0], name))
continue;
return parts[1];
}
} finally {
Lock.ReleaseMutex();
}
return fallback;
}
public T? ReadValue<T>(string name, T? fallback = default) {
object? value = ReadValue(name);
if(value == null)
return fallback;
Type type = typeof(T);
if(value is string strVal) {
if(type == typeof(bool))
value = !string.Equals(strVal, "0", StringComparison.InvariantCultureIgnoreCase)
&& !string.Equals(strVal, "false", StringComparison.InvariantCultureIgnoreCase);
else if(type == typeof(string[]))
value = strVal.Split(' ');
}
public T? ReadValue<T>(string name, T? fallback = default) {
object? value = ReadValue(name);
if(value == null)
return fallback;
Type type = typeof(T);
if(value is string strVal) {
if(type == typeof(bool))
value = !string.Equals(strVal, "0", StringComparison.InvariantCultureIgnoreCase)
&& !string.Equals(strVal, "false", StringComparison.InvariantCultureIgnoreCase);
else if(type == typeof(string[]))
value = strVal.Split(' ');
}
try {
return (T)Convert.ChangeType(value, type);
} catch(InvalidCastException ex) {
throw new ConfigTypeException(ex);
}
}
public T? SafeReadValue<T>(string name, T? fallback) {
try {
return ReadValue(name, fallback);
} catch(ConfigTypeException) {
return fallback;
}
}
public Config ScopeTo(string prefix) {
if(string.IsNullOrWhiteSpace(prefix))
throw new ArgumentException("Prefix must exist.", nameof(prefix));
if(prefix[^1] != ':')
prefix += ':';
return new ScopedConfig(this, prefix);
}
public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
return new CachedValue<T>(this, name, lifetime ?? CACHE_LIFETIME, fallback);
}
private bool IsDisposed;
~StreamConfig()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
StreamReader.Dispose();
Stream.Dispose();
Lock.Dispose();
try {
return (T)Convert.ChangeType(value, type);
} catch(InvalidCastException ex) {
throw new ConfigTypeException(ex);
}
}
public T? SafeReadValue<T>(string name, T? fallback) {
try {
return ReadValue(name, fallback);
} catch(ConfigTypeException) {
return fallback;
}
}
public Config ScopeTo(string prefix) {
if(string.IsNullOrWhiteSpace(prefix))
throw new ArgumentException("Prefix must exist.", nameof(prefix));
if(prefix[^1] != ':')
prefix += ':';
return new ScopedConfig(this, prefix);
}
public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
return new CachedValue<T>(this, name, lifetime ?? CACHE_LIFETIME, fallback);
}
private bool IsDisposed;
~StreamConfig()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
StreamReader.Dispose();
Stream.Dispose();
Lock.Dispose();
}
}

View file

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

View file

@ -1,82 +1,82 @@
using System.Buffers;
using System.Buffers;
using System.Security.Cryptography;
namespace SharpChat {
public static class RNG {
public const string CHARS = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789";
namespace SharpChat;
private static Random NormalRandom { get; } = new();
private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create();
public static class RNG {
public const string CHARS = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789";
public static int Next() {
return NormalRandom.Next();
}
private static Random NormalRandom { get; } = new();
private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create();
public static int Next(int max) {
return NormalRandom.Next(max);
}
public static int Next() {
return NormalRandom.Next();
}
public static int Next(int min, int max) {
return NormalRandom.Next(min, max);
}
public static int Next(int max) {
return NormalRandom.Next(max);
}
public static void NextBytes(byte[] buffer) {
public static int Next(int min, int max) {
return NormalRandom.Next(min, max);
}
public static void NextBytes(byte[] buffer) {
SecureRandom.GetBytes(buffer);
}
public static int SecureNext() {
return SecureNext(int.MaxValue);
}
public static int SecureNext(int max) {
return SecureNext(0, max);
}
public static int SecureNext(int min, int max) {
--max;
if(min == max)
return min;
uint umax = (uint)max - (uint)min;
uint num;
byte[] buffer = ArrayPool<byte>.Shared.Rent(4);
try {
SecureRandom.GetBytes(buffer);
}
num = BitConverter.ToUInt32(buffer);
public static int SecureNext() {
return SecureNext(int.MaxValue);
}
if(umax != uint.MaxValue) {
++umax;
public static int SecureNext(int max) {
return SecureNext(0, max);
}
if((umax & (umax - 1)) != 0) {
uint limit = uint.MaxValue - (uint.MaxValue & umax) - 1;
public static int SecureNext(int min, int max) {
--max;
if(min == max)
return min;
uint umax = (uint)max - (uint)min;
uint num;
byte[] buffer = ArrayPool<byte>.Shared.Rent(4);
try {
SecureRandom.GetBytes(buffer);
num = BitConverter.ToUInt32(buffer);
if(umax != uint.MaxValue) {
++umax;
if((umax & (umax - 1)) != 0) {
uint limit = uint.MaxValue - (uint.MaxValue & umax) - 1;
while(num > limit) {
SecureRandom.GetBytes(buffer);
num = BitConverter.ToUInt32(buffer);
}
while(num > limit) {
SecureRandom.GetBytes(buffer);
num = BitConverter.ToUInt32(buffer);
}
}
} finally {
ArrayPool<byte>.Shared.Return(buffer);
}
return (int)((num % umax) + min);
} finally {
ArrayPool<byte>.Shared.Return(buffer);
}
private static string RandomStringInternal(Func<int, int> next, int length) {
char[] str = new char[length];
for(int i = 0; i < length; ++i)
str[i] = CHARS[next(CHARS.Length)];
return new string(str);
}
return (int)((num % umax) + min);
}
public static string RandomString(int length) {
return RandomStringInternal(Next, length);
}
private static string RandomStringInternal(Func<int, int> next, int length) {
char[] str = new char[length];
for(int i = 0; i < length; ++i)
str[i] = CHARS[next(CHARS.Length)];
return new string(str);
}
public static string SecureRandomString(int length) {
return RandomStringInternal(SecureNext, length);
}
public static string RandomString(int length) {
return RandomStringInternal(Next, length);
}
public static string SecureRandomString(int length) {
return RandomStringInternal(SecureNext, length);
}
}

View file

@ -1,35 +1,35 @@
namespace SharpChat {
public class RateLimiter {
private readonly int Size;
private readonly int MinimumDelay;
private readonly int RiskyOffset;
private readonly long[] TimePoints;
namespace SharpChat;
public RateLimiter(int size, int minDelay, int riskyOffset = 0) {
if(size < 2)
throw new ArgumentException("Size is too small.", nameof(size));
if(minDelay < 1000)
throw new ArgumentException("Minimum delay is inhuman.", nameof(minDelay));
if(riskyOffset != 0) {
if(riskyOffset >= size)
throw new ArgumentException("Risky offset may not be greater or equal to the size.", nameof(riskyOffset));
else if(riskyOffset < 0)
throw new ArgumentException("Risky offset may not be negative.", nameof(riskyOffset));
}
public class RateLimiter {
private readonly int Size;
private readonly int MinimumDelay;
private readonly int RiskyOffset;
private readonly long[] TimePoints;
Size = size;
MinimumDelay = minDelay;
RiskyOffset = riskyOffset;
TimePoints = new long[Size];
public RateLimiter(int size, int minDelay, int riskyOffset = 0) {
if(size < 2)
throw new ArgumentException("Size is too small.", nameof(size));
if(minDelay < 1000)
throw new ArgumentException("Minimum delay is inhuman.", nameof(minDelay));
if(riskyOffset != 0) {
if(riskyOffset >= size)
throw new ArgumentException("Risky offset may not be greater or equal to the size.", nameof(riskyOffset));
else if(riskyOffset < 0)
throw new ArgumentException("Risky offset may not be negative.", nameof(riskyOffset));
}
public bool IsRisky => TimePoints[RiskyOffset] != 0 && TimePoints[RiskyOffset + 1] != 0 && TimePoints[RiskyOffset] + MinimumDelay >= TimePoints[Size - 1];
public bool IsExceeded => TimePoints[0] != 0 && TimePoints[1] != 0 && TimePoints[0] + MinimumDelay >= TimePoints[Size - 1];
Size = size;
MinimumDelay = minDelay;
RiskyOffset = riskyOffset;
TimePoints = new long[Size];
}
public void Update() {
for(int i = 1; i < Size; ++i)
TimePoints[i - 1] = TimePoints[i];
TimePoints[Size - 1] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
public bool IsRisky => TimePoints[RiskyOffset] != 0 && TimePoints[RiskyOffset + 1] != 0 && TimePoints[RiskyOffset] + MinimumDelay >= TimePoints[Size - 1];
public bool IsExceeded => TimePoints[0] != 0 && TimePoints[1] != 0 && TimePoints[0] + MinimumDelay >= TimePoints[Size - 1];
public void Update() {
for(int i = 1; i < Size; ++i)
TimePoints[i - 1] = TimePoints[i];
TimePoints[Size - 1] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
}

View file

@ -1,36 +1,36 @@
using System.Reflection;
using System.Text;
namespace SharpChat {
public static class SharpInfo {
private const string NAME = @"SharpChat";
private const string UNKNOWN = @"XXXXXXXXXX";
namespace SharpChat;
public static string VersionString { get; }
public static string VersionStringShort { get; }
public static bool IsDebugBuild { get; }
public static class SharpInfo {
private const string NAME = @"SharpChat";
private const string UNKNOWN = @"XXXXXXXXXX";
public static string ProgramName { get; }
public static string VersionString { get; }
public static string VersionStringShort { get; }
public static bool IsDebugBuild { get; }
static SharpInfo() {
public static string ProgramName { get; }
static SharpInfo() {
#if DEBUG
IsDebugBuild = true;
IsDebugBuild = true;
#endif
try {
using Stream s = Assembly.GetEntryAssembly()!.GetManifestResourceStream(@"SharpChat.version.txt")!;
using StreamReader sr = new(s);
VersionString = sr.ReadLine()!.Trim();
VersionStringShort = VersionString.Length > 10 ? VersionString[..10] : VersionString;
} catch {
VersionStringShort = VersionString = UNKNOWN;
}
StringBuilder sb = new();
sb.Append(NAME);
sb.Append('/');
sb.Append(VersionStringShort);
ProgramName = sb.ToString();
try {
using Stream s = Assembly.GetEntryAssembly()!.GetManifestResourceStream(@"SharpChat.version.txt")!;
using StreamReader sr = new(s);
VersionString = sr.ReadLine()!.Trim();
VersionStringShort = VersionString.Length > 10 ? VersionString[..10] : VersionString;
} catch {
VersionStringShort = VersionString = UNKNOWN;
}
StringBuilder sb = new();
sb.Append(NAME);
sb.Append('/');
sb.Append(VersionStringShort);
ProgramName = sb.ToString();
}
}

View file

@ -1,13 +1,13 @@
using System.Security.Cryptography;
using System.Security.Cryptography;
namespace SharpChat.Snowflake {
public class RandomSnowflake(
SnowflakeGenerator? generator = null
) {
public readonly SnowflakeGenerator Generator = generator ?? new SnowflakeGenerator();
namespace SharpChat.Snowflake;
public long Next(DateTimeOffset? at = null) {
return Generator.Next(Math.Abs(BitConverter.ToInt64(RandomNumberGenerator.GetBytes(8))), at);
}
public class RandomSnowflake(
SnowflakeGenerator? generator = null
) {
public readonly SnowflakeGenerator Generator = generator ?? new SnowflakeGenerator();
public long Next(DateTimeOffset? at = null) {
return Generator.Next(Math.Abs(BitConverter.ToInt64(RandomNumberGenerator.GetBytes(8))), at);
}
}

View file

@ -1,35 +1,35 @@
namespace SharpChat.Snowflake {
public class SnowflakeGenerator {
public const long MASK = 0x7FFFFFFFFFFFFFFF;
// previous default epoch was 1588377600000, but snowflakes are much larger than SharpIds
public const long EPOCH = 1356998400000;
public const byte SHIFT = 16;
namespace SharpChat.Snowflake;
public readonly long Epoch;
public readonly byte Shift;
public readonly long TimestampMask;
public readonly long SequenceMask;
public class SnowflakeGenerator {
public const long MASK = 0x7FFFFFFFFFFFFFFF;
// previous default epoch was 1588377600000, but snowflakes are much larger than SharpIds
public const long EPOCH = 1356998400000;
public const byte SHIFT = 16;
public SnowflakeGenerator(long epoch = EPOCH, byte shift = SHIFT) {
if(epoch is < 0 or > MASK)
throw new ArgumentException("Epoch must be a positive int64.", nameof(epoch));
if(shift is < 1 or > 63)
throw new ArgumentException("Shift must be between or equal to 1 and 63", nameof(shift));
public readonly long Epoch;
public readonly byte Shift;
public readonly long TimestampMask;
public readonly long SequenceMask;
Epoch = epoch;
Shift = shift;
public SnowflakeGenerator(long epoch = EPOCH, byte shift = SHIFT) {
if(epoch is < 0 or > MASK)
throw new ArgumentException("Epoch must be a positive int64.", nameof(epoch));
if(shift is < 1 or > 63)
throw new ArgumentException("Shift must be between or equal to 1 and 63", nameof(shift));
// i think Index only does this as a hack for how integers work in PHP but its gonna run Once per application instance lol
TimestampMask = ~(~0L << (63 - shift));
SequenceMask = ~(~0L << shift);
}
Epoch = epoch;
Shift = shift;
public long Now(DateTimeOffset? at = null) {
return Math.Max(0, (at ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds() - Epoch);
}
// i think Index only does this as a hack for how integers work in PHP but its gonna run Once per application instance lol
TimestampMask = ~(~0L << (63 - shift));
SequenceMask = ~(~0L << shift);
}
public long Next(long sequence, DateTimeOffset? at = null) {
return ((Now(at) & TimestampMask) << Shift) | (sequence & SequenceMask);
}
public long Now(DateTimeOffset? at = null) {
return Math.Max(0, (at ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds() - Epoch);
}
public long Next(long sequence, DateTimeOffset? at = null) {
return ((Now(at) & TimestampMask) << Shift) | (sequence & SequenceMask);
}
}

View file

@ -1,24 +1,24 @@
namespace SharpChat {
[Flags]
public enum UserPermissions : 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,
ViewLogs = 0x00040000,
}
namespace SharpChat;
[Flags]
public enum UserPermissions : 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,
ViewLogs = 0x00040000,
}