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_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent 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_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent csharp_style_prefer_top_level_statements = true:silent
csharp_style_prefer_primary_constructors = true:suggestion csharp_style_prefer_primary_constructors = true:suggestion

View file

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

View file

@ -1,9 +1,9 @@
using SharpChat.Bans; using SharpChat.Bans;
using System.Net; using System.Net;
namespace SharpChat.Flashii { 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 class FlashiiIPAddressBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.IPAddress, rawBanInfo), IPAddressBanInfo {
public override string ToString() => Address.ToString(); 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 { namespace SharpChat.Flashii;
public class FlashiiRawBanInfo {
[JsonPropertyName("is_ban")]
public bool IsBanned { get; set; }
[JsonPropertyName("user_id")] public class FlashiiRawBanInfo {
public string? UserId { get; set; } [JsonPropertyName("is_ban")]
public bool IsBanned { get; set; }
[JsonPropertyName("user_name")] [JsonPropertyName("user_id")]
public string? UserName { get; set; } public string? UserId { get; set; }
[JsonPropertyName("user_colour")] [JsonPropertyName("user_name")]
public int UserColourRaw { get; set; } public string? UserName { get; set; }
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
[JsonPropertyName("ip_addr")] [JsonPropertyName("user_colour")]
public string? RemoteAddress { get; set; } public int UserColourRaw { get; set; }
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
[JsonPropertyName("is_perma")] [JsonPropertyName("ip_addr")]
public bool IsPermanent { get; set; } public string? RemoteAddress { get; set; }
[JsonPropertyName("expires")] [JsonPropertyName("is_perma")]
public DateTimeOffset ExpiresAt { get; set; } 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 { namespace SharpChat.Flashii;
public class FlashiiUserBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.User, rawBanInfo), UserBanInfo {
public string UserId { get; } = rawBanInfo.UserId ?? string.Empty; public class FlashiiUserBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.User, rawBanInfo), UserBanInfo {
public string UserName { get; } = rawBanInfo.UserName ?? $"({rawBanInfo.UserId ?? string.Empty})"; public string UserId { get; } = rawBanInfo.UserId ?? string.Empty;
public ColourInheritable UserColour { get; } = ColourInheritable.FromMisuzu(rawBanInfo.UserColourRaw); public string UserName { get; } = rawBanInfo.UserName ?? $"({rawBanInfo.UserId ?? string.Empty})";
public override string ToString() => UserName; public ColourInheritable UserColour { get; } = ColourInheritable.FromMisuzu(rawBanInfo.UserColourRaw);
} public override string ToString() => UserName;
} }

View file

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

View file

@ -1,43 +1,43 @@
using System.Text; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class AuthFailS2CPacket(
AuthFailS2CPacket.Reason reason, public class AuthFailS2CPacket(
DateTimeOffset? expiresAt = null AuthFailS2CPacket.Reason reason,
) : S2CPacket { DateTimeOffset? expiresAt = null
public enum Reason { ) : S2CPacket {
AuthInvalid, public enum Reason {
MaxSessions, AuthInvalid,
Banned, MaxSessions,
Exception, 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() { return sb.ToString();
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();
}
} }
} }

View file

@ -1,40 +1,40 @@
using System.Text; using System.Text;
namespace SharpChat.SockChat.S2CPackets { 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();
sb.Append("1\ty\t"); public class AuthSuccessS2CPacket(
sb.Append(userId); string userId,
sb.Append('\t'); string userName,
sb.Append(userName); ColourInheritable userColour,
sb.Append('\t'); int userRank,
sb.Append(userColour); UserPermissions userPerms,
sb.Append('\t'); string channelName,
sb.Append(userRank); int maxMsgLength
sb.Append(' '); ) : S2CPacket {
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0'); public string Pack() {
sb.Append(' '); StringBuilder sb = new();
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(); 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 SharpChat.Bans;
using System.Text; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class BanListS2CPacket(
long msgId,
IEnumerable<BanListS2CPacket.Entry> entries
) : S2CPacket {
public record Entry(BanKind type, string value);
public string Pack() { public class BanListS2CPacket(
StringBuilder sb = new(); long msgId,
IEnumerable<BanListS2CPacket.Entry> entries
) : S2CPacket {
public record Entry(BanKind type, string value);
sb.Append("2\t"); public string Pack() {
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); StringBuilder sb = new();
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(); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ChannelCreateS2CPacket(
string name,
bool hasPassword,
bool isTemporary
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("4\t0\t"); public class ChannelCreateS2CPacket(
sb.Append(name); string name,
sb.Append('\t'); bool hasPassword,
sb.Append(hasPassword ? '1' : '0'); bool isTemporary
sb.Append('\t'); ) : S2CPacket {
sb.Append(isTemporary ? '1' : '0'); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ChannelDeleteS2CPacket(
string channelName
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("4\t2\t"); public class ChannelDeleteS2CPacket(
sb.Append(channelName); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ChannelUpdateS2CPacket(
string previousName,
string newName,
bool hasPassword,
bool isTemporary
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("4\t1\t"); public class ChannelUpdateS2CPacket(
sb.Append(previousName); string previousName,
sb.Append('\t'); string newName,
sb.Append(newName); bool hasPassword,
sb.Append('\t'); bool isTemporary
sb.Append(hasPassword ? '1' : '0'); ) : S2CPacket {
sb.Append('\t'); public string Pack() {
sb.Append(isTemporary ? '1' : '0'); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { 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();
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("2\t");
sb.Append('\t');
sb.Append(userId); sb.Append(created.ToUnixTimeSeconds());
sb.Append('\t'); sb.Append('\t');
if(isAction) sb.Append(userId);
sb.Append("<i>"); sb.Append('\t');
sb.Append( if(isAction)
text.Replace("<", "&lt;") sb.Append("<i>");
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")
);
if(isAction) sb.Append(
sb.Append("</i>"); text.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")
);
sb.Append('\t'); if(isAction)
sb.Append(msgId); sb.Append("</i>");
sb.AppendFormat(
"\t1{0}0{1}{2}",
isAction ? '1' : '0',
isAction ? '0' : '1',
isPrivate ? '1' : '0'
);
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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ChatMessageDeleteS2CPacket(long eventId) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("6\t"); public class ChatMessageDeleteS2CPacket(long eventId) : S2CPacket {
sb.Append(eventId); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class CommandResponseS2CPacket(
long msgId,
string stringId,
bool isError = true,
params object[] args
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
if(stringId == LCR.WELCOME) { public class CommandResponseS2CPacket(
sb.Append("7\t1\t"); long msgId,
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); string stringId,
sb.Append("\t-1\tChatBot\tinherit\t\t"); bool isError = true,
} else { params object[] args
sb.Append("2\t"); ) : S2CPacket {
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds()); public string Pack() {
sb.Append("\t-1\t"); 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('\t');
sb.Append('\f');
sb.Append(stringId == LCR.WELCOME ? LCR.BROADCAST : stringId);
if(args.Length > 0) if(stringId == LCR.WELCOME) {
foreach(object arg in args) { sb.Append(stringId);
sb.Append('\f'); sb.Append("\t0");
sb.Append(arg); } 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) { return sb.ToString();
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";
} }
} }
// 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ContextChannelsS2CPacket(IEnumerable<ContextChannelsS2CPacket.Entry> entries) : S2CPacket {
public record Entry(string name, bool hasPassword, bool isTemporary);
public string Pack() { public class ContextChannelsS2CPacket(IEnumerable<ContextChannelsS2CPacket.Entry> entries) : S2CPacket {
StringBuilder sb = new(); public record Entry(string name, bool hasPassword, bool isTemporary);
sb.Append("7\t2\t"); public string Pack() {
sb.Append(entries.Count()); StringBuilder sb = new();
foreach(Entry entry in entries) { sb.Append("7\t2\t");
sb.Append('\t'); sb.Append(entries.Count());
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(); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ContextClearS2CPacket(ContextClearS2CPacket.Mode mode) : S2CPacket {
public enum Mode {
Messages = 0,
Users = 1,
Channels = 2,
MessagesUsers = 3,
MessagesUsersChannels = 4,
}
public string Pack() { public class ContextClearS2CPacket(ContextClearS2CPacket.Mode mode) : S2CPacket {
StringBuilder sb = new(); public enum Mode {
Messages = 0,
Users = 1,
Channels = 2,
MessagesUsers = 3,
MessagesUsersChannels = 4,
}
sb.Append("8\t"); public string Pack() {
sb.Append((int)mode); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { 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);
public string Pack() { public class ContextUsersS2CPacket(IEnumerable<ContextUsersS2CPacket.Entry> entries) : S2CPacket {
StringBuilder sb = new(); public record Entry(string id, string name, ColourInheritable colour, int rank, UserPermissions perms, bool visible);
sb.Append("7\t0\t"); public string Pack() {
sb.Append(entries.Count()); StringBuilder sb = new();
foreach(Entry entry in entries) { sb.Append("7\t0\t");
sb.Append('\t'); sb.Append(entries.Count());
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(); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class ForceDisconnectS2CPacket(DateTimeOffset? expires = null) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
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("9\t");
sb.Append("1\t");
if(expires.Value < DateTimeOffset.MaxValue) if(expires.HasValue && expires.Value > DateTimeOffset.UtcNow) {
sb.Append(expires.Value.ToUnixTimeSeconds()); sb.Append("1\t");
else if(expires.Value < DateTimeOffset.MaxValue)
sb.Append("-1"); sb.Append(expires.Value.ToUnixTimeSeconds());
} else else
sb.Append('0'); sb.Append("-1");
} else
return sb.ToString(); sb.Append('0');
}
return sb.ToString();
} }
} }

View file

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

View file

@ -1,14 +1,14 @@
using System.Text; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class UserChannelForceJoinS2CPacket(string channelName) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("5\t2\t"); public class UserChannelForceJoinS2CPacket(string channelName) : S2CPacket {
sb.Append(channelName); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { 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();
sb.Append("5\t0\t"); public class UserChannelJoinS2CPacket(
sb.Append(userId); long msgId,
sb.Append('\t'); string userId,
sb.Append(userName); string userName,
sb.Append('\t'); ColourInheritable userColour,
sb.Append(userColour); int userRank,
sb.Append('\t'); UserPermissions userPerms
sb.Append(userRank); ) : S2CPacket {
sb.Append(' '); public string Pack() {
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0'); StringBuilder sb = new();
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(); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class UserChannelLeaveS2CPacket(long msgId, string userId) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("5\t1\t"); public class UserChannelLeaveS2CPacket(long msgId, string userId) : S2CPacket {
sb.Append(userId); public string Pack() {
sb.Append('\t'); StringBuilder sb = new();
sb.Append(msgId);
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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { 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();
sb.Append("1\t"); public class UserConnectS2CPacket(
sb.Append(joined.ToUnixTimeSeconds()); long msgId,
sb.Append('\t'); DateTimeOffset joined,
sb.Append(userId); string userId,
sb.Append('\t'); string userName,
sb.Append(userName); ColourInheritable userColour,
sb.Append('\t'); int userRank,
sb.Append(userColour); UserPermissions userPerms
sb.Append('\t'); ) : S2CPacket {
sb.Append(userRank); public string Pack() {
sb.Append(' '); StringBuilder sb = new();
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(); 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; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class UserDisconnectS2CPacket(
long msgId, public class UserDisconnectS2CPacket(
DateTimeOffset disconnected, long msgId,
string userId, DateTimeOffset disconnected,
string userName, string userId,
UserDisconnectS2CPacket.Reason reason string userName,
) : S2CPacket { UserDisconnectS2CPacket.Reason reason
public enum Reason { ) : S2CPacket {
Leave, public enum Reason {
TimeOut, Leave,
Kicked, TimeOut,
Flood, 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() { sb.Append('\t');
StringBuilder sb = new(); sb.Append(disconnected.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(msgId);
sb.Append("3\t"); return sb.ToString();
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();
}
} }
} }

View file

@ -1,34 +1,34 @@
using System.Text; using System.Text;
namespace SharpChat.SockChat.S2CPackets { namespace SharpChat.SockChat.S2CPackets;
public class UserUpdateS2CPacket(
string userId,
string userName,
ColourInheritable userColour,
int userRank,
UserPermissions userPerms
) : S2CPacket {
public string Pack() {
StringBuilder sb = new();
sb.Append("10\t"); public class UserUpdateS2CPacket(
sb.Append(userId); string userId,
sb.Append('\t'); string userName,
sb.Append(userName); ColourInheritable userColour,
sb.Append('\t'); int userRank,
sb.Append(userColour); UserPermissions userPerms
sb.Append('\t'); ) : S2CPacket {
sb.Append(userRank); public string Pack() {
sb.Append(' '); StringBuilder sb = new();
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(); 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 { namespace SharpChat;
public interface C2SPacketHandler {
bool IsMatch(C2SPacketHandlerContext ctx); public interface C2SPacketHandler {
Task Handle(C2SPacketHandlerContext ctx); bool IsMatch(C2SPacketHandlerContext ctx);
} Task Handle(C2SPacketHandlerContext ctx);
} }

View file

@ -1,19 +1,19 @@
namespace SharpChat { 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));
public bool CheckPacketId(string packetId) { public class C2SPacketHandlerContext(
return Text == packetId || Text.StartsWith(packetId + '\t'); 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) { public bool CheckPacketId(string packetId) {
return Text.Split('\t', expect + 1); 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.Configuration;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.C2SPacketHandlers { 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));
public bool IsMatch(C2SPacketHandlerContext ctx) { public class AuthC2SPacketHandler(
return ctx.CheckPacketId("1"); 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) { if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) {
string[] args = ctx.SplitText(3); string[] tokenParts = authToken.Split(':', 2);
authMethod = tokenParts[0];
authToken = tokenParts[1];
}
string? authMethod = args.ElementAtOrDefault(1); try {
string? authToken = args.ElementAtOrDefault(2); AuthResult authResult = await authClient.AuthVerifyAsync(
ctx.Connection.RemoteAddress,
authMethod,
authToken
);
if(string.IsNullOrWhiteSpace(authMethod) || string.IsNullOrWhiteSpace(authToken)) { BanInfo? banInfo = await bansClient.BanGetAsync(authResult.UserId, ctx.Connection.RemoteAddress);
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid)); 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(); ctx.Connection.Dispose();
return; return;
} }
if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) { await ctx.Chat.ContextAccess.WaitAsync();
string[] tokenParts = authToken.Split(':', 2);
authMethod = tokenParts[0];
authToken = tokenParts[1];
}
try { try {
AuthResult authResult = await authClient.AuthVerifyAsync( User? user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == authResult.UserId);
ctx.Connection.RemoteAddress,
authMethod,
authToken
);
BanInfo? banInfo = await bansClient.BanGetAsync(authResult.UserId, ctx.Connection.RemoteAddress); if(user == null)
if(banInfo is not null) { user = new User(
Logger.Write($"<{ctx.Connection.Id}> User is banned."); authResult.UserId,
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Banned, banInfo.IsPermanent ? DateTimeOffset.MaxValue : banInfo.ExpiresAt)); 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(); ctx.Connection.Dispose();
return; return;
} }
await ctx.Chat.ContextAccess.WaitAsync(); ctx.Connection.BumpPing();
try { ctx.Connection.User = user;
User? user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == authResult.UserId); await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, $"Welcome to Flashii Chat, {user.UserName}!"));
if(user == null) if(File.Exists("welcome.txt")) {
user = new User( IEnumerable<string> lines = File.ReadAllLines("welcome.txt").Where(x => !string.IsNullOrWhiteSpace(x));
authResult.UserId, string? line = lines.ElementAtOrDefault(RNG.Next(lines.Count()));
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(!string.IsNullOrWhiteSpace(line))
if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) { await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line));
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();
} }
} catch(AuthFailedException ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}"); await ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength);
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid)); } finally {
ctx.Connection.Dispose(); ctx.Chat.ContextAccess.Release();
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;
} }
} 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 SharpChat.SockChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat.C2SPacketHandlers { namespace SharpChat.C2SPacketHandlers;
public class PingC2SPacketHandler(AuthClient authClient) : C2SPacketHandler {
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
public bool IsMatch(C2SPacketHandlerContext ctx) { public class PingC2SPacketHandler(AuthClient authClient) : C2SPacketHandler {
return ctx.CheckPacketId("0"); private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
} private DateTimeOffset LastBump = DateTimeOffset.MinValue;
public async Task Handle(C2SPacketHandlerContext ctx) { public bool IsMatch(C2SPacketHandlerContext ctx) {
string[] parts = ctx.SplitText(2); return ctx.CheckPacketId("0");
}
if(!int.TryParse(parts.FirstOrDefault(), out int pTime)) public async Task Handle(C2SPacketHandlerContext ctx) {
return; string[] parts = ctx.SplitText(2);
ctx.Connection.BumpPing(); if(!int.TryParse(parts.FirstOrDefault(), out int pTime))
await ctx.Connection.Send(new PongS2CPacket()); return;
ctx.Chat.ContextAccess.Wait(); ctx.Connection.BumpPing();
try { await ctx.Connection.Send(new PongS2CPacket());
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))];
if(bumpList.Length > 0) ctx.Chat.ContextAccess.Wait();
await authClient.AuthBumpUsersOnlineAsync(bumpList); 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; if(bumpList.Length > 0)
} await authClient.AuthBumpUsersOnlineAsync(bumpList);
} finally {
ctx.Chat.ContextAccess.Release(); LastBump = DateTimeOffset.UtcNow;
} }
} finally {
ctx.Chat.ContextAccess.Release();
} }
} }
} }

View file

@ -4,86 +4,86 @@ using SharpChat.Snowflake;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace SharpChat.C2SPacketHandlers { namespace SharpChat.C2SPacketHandlers;
public class SendMessageC2SPacketHandler(
RandomSnowflake randomSnowflake,
CachedValue<int> maxMsgLength
) : C2SPacketHandler {
private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
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) { private List<ClientCommand> Commands { get; } = [];
Commands.Add(command ?? throw new ArgumentNullException(nameof(command)));
}
public void AddCommands(IEnumerable<ClientCommand> commands) { public void AddCommand(ClientCommand command) {
Commands.AddRange(commands ?? throw new ArgumentNullException(nameof(commands))); Commands.Add(command ?? throw new ArgumentNullException(nameof(command)));
} }
public bool IsMatch(C2SPacketHandlerContext ctx) { public void AddCommands(IEnumerable<ClientCommand> commands) {
return ctx.CheckPacketId("2"); Commands.AddRange(commands ?? throw new ArgumentNullException(nameof(commands)));
} }
public async Task Handle(C2SPacketHandlerContext ctx) { public bool IsMatch(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3); return ctx.CheckPacketId("2");
}
User? user = ctx.Connection.User; public async Task Handle(C2SPacketHandlerContext ctx) {
string? messageText = args.ElementAtOrDefault(2); 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; return;
// Extra validation step, not necessary at all but enforces proper formatting in SCv1. if(user.Status != UserStatus.Online)
if(!long.TryParse(args[1], out long mUserId) || user.UserId != mUserId.ToString()) await ctx.Chat.UpdateUser(user, status: UserStatus.Online);
return;
ctx.Chat.ContextAccess.Wait(); int maxMsgLength = MaxMessageLength;
try { StringInfo messageTextInfo = new(messageText);
if(!ctx.Chat.UserLastChannel.TryGetValue(user.UserId, out Channel? channel) if(Encoding.UTF8.GetByteCount(messageText) > (maxMsgLength * 10)
&& (channel is null || !ctx.Chat.IsInChannel(user, channel))) || messageTextInfo.LengthInTextElements > maxMsgLength)
return; messageText = messageTextInfo.SubstringByTextElements(0, Math.Min(messageTextInfo.LengthInTextElements, maxMsgLength));
if(user.Status != UserStatus.Online) messageText = messageText.Trim();
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();
#if DEBUG #if DEBUG
Logger.Write($"<{ctx.Connection.Id} {user.UserName}> {messageText}"); Logger.Write($"<{ctx.Connection.Id} {user.UserName}> {messageText}");
#endif #endif
if(messageText.StartsWith('/')) { if(messageText.StartsWith('/')) {
ClientCommandContext context = new(messageText, ctx.Chat, user, ctx.Connection, channel); ClientCommandContext context = new(messageText, ctx.Chat, user, ctx.Connection, channel);
foreach(ClientCommand cmd in Commands) foreach(ClientCommand cmd in Commands)
if(cmd.IsMatch(context)) { if(cmd.IsMatch(context)) {
await cmd.Dispatch(context); await cmd.Dispatch(context);
return; 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();
} }
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 { 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;
public bool HasPassword public class Channel(
=> !string.IsNullOrWhiteSpace(Password); 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) { public bool HasPassword
return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase); => !string.IsNullOrWhiteSpace(Password);
}
public bool IsOwner(User user) { public bool NameEquals(string name) {
return string.IsNullOrEmpty(OwnerId) return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase);
&& user != null }
&& OwnerId == user.UserId;
}
public override int GetHashCode() { public bool IsOwner(User user) {
return Name.GetHashCode(); return string.IsNullOrEmpty(OwnerId)
} && user != null
&& OwnerId == user.UserId;
}
public static bool CheckName(string name) { public override int GetHashCode() {
return !string.IsNullOrWhiteSpace(name) && name.All(CheckNameChar); return Name.GetHashCode();
} }
public static bool CheckNameChar(char c) { public static bool CheckName(string name) {
return char.IsLetter(c) || char.IsNumber(c) || c == '-' || c == '_'; 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 { namespace SharpChat;
public interface ClientCommand {
bool IsMatch(ClientCommandContext ctx); public interface ClientCommand {
Task Dispatch(ClientCommandContext ctx); bool IsMatch(ClientCommandContext ctx);
} Task Dispatch(ClientCommandContext ctx);
} }

View file

@ -1,49 +1,49 @@
namespace SharpChat { 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; }
public ClientCommandContext( public class ClientCommandContext {
string text, public string Name { get; }
Context chat, public string[] Args { get; }
User user, public Context Chat { get; }
Connection connection, public User User { get; }
Channel channel public Connection Connection { get; }
) { public Channel Channel { get; }
ArgumentNullException.ThrowIfNull(text);
Chat = chat ?? throw new ArgumentNullException(nameof(chat)); public ClientCommandContext(
User = user ?? throw new ArgumentNullException(nameof(user)); string text,
Connection = connection ?? throw new ArgumentNullException(nameof(connection)); Context chat,
Channel = channel ?? throw new ArgumentNullException(nameof(channel)); User user,
Connection connection,
Channel channel
) {
ArgumentNullException.ThrowIfNull(text);
string[] parts = text[1..].Split(' '); Chat = chat ?? throw new ArgumentNullException(nameof(chat));
Name = parts.First().Replace(".", string.Empty); User = user ?? throw new ArgumentNullException(nameof(user));
Args = [.. parts.Skip(1)]; Connection = connection ?? throw new ArgumentNullException(nameof(connection));
} Channel = channel ?? throw new ArgumentNullException(nameof(channel));
public ClientCommandContext( string[] parts = text[1..].Split(' ');
string name, Name = parts.First().Replace(".", string.Empty);
string[] args, Args = [.. parts.Skip(1)];
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) { public ClientCommandContext(
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase); 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.Globalization;
using System.Text; using System.Text;
namespace SharpChat.ClientCommands { 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;
public bool IsMatch(ClientCommandContext ctx) { public class AFKClientCommand : ClientCommand {
return ctx.NameEquals("afk"); 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) { await ctx.Chat.UpdateUser(
string? statusText = ctx.Args.FirstOrDefault(); ctx.User,
if(string.IsNullOrWhiteSpace(statusText)) status: UserStatus.Away,
statusText = DEFAULT; statusText: statusText
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
);
}
} }
} }

View file

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

View file

@ -1,30 +1,30 @@
using SharpChat.Bans; using SharpChat.Bans;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class BanListClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class BanListClientCommand(BansClient bansClient) : ClientCommand {
return ctx.NameEquals("bans") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("banned"); 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) { try {
long msgId = ctx.Chat.RandomSnowflake.Next(); BanInfo[] banInfos = await bansClient.BanGetListAsync();
await ctx.Chat.SendTo(ctx.User, new BanListS2CPacket(
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) { msgId,
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); banInfos.Select(bi => new BanListS2CPacket.Entry(bi.Kind, bi.ToString()))
return; ));
} } 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.Events;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class BroadcastClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class BroadcastClientCommand : ClientCommand {
return ctx.NameEquals("say") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("broadcast"); 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) { await ctx.Chat.DispatchEvent(new MessageCreateEvent(
long msgId = ctx.Chat.RandomSnowflake.Next(); msgId,
string.Empty,
if(!ctx.User.Can(UserPermissions.Broadcast)) { ctx.User.UserId,
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); ctx.User.UserName,
return; ctx.User.Colour,
} ctx.User.Rank,
ctx.User.NickName,
await ctx.Chat.DispatchEvent(new MessageCreateEvent( ctx.User.Permissions,
msgId, DateTimeOffset.Now,
string.Empty, string.Join(' ', ctx.Args),
ctx.User.UserId, false, false, true
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; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class CreateChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class CreateChannelClientCommand : ClientCommand {
return ctx.NameEquals("create"); 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) { string firstArg = ctx.Args.First();
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.CreateChannel)) { bool createChanHasHierarchy;
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); if(ctx.Args.Length < 1 || (createChanHasHierarchy = firstArg.All(char.IsDigit) && ctx.Args.Length < 2)) {
return; await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
} 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));
} }
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; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class DeleteChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class DeleteChannelClientCommand : ClientCommand {
return ctx.NameEquals("delchan") || ( public bool IsMatch(ClientCommandContext ctx) {
ctx.NameEquals("delete") return ctx.NameEquals("delchan") || (
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false 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) { string delChanName = string.Join('_', ctx.Args);
long msgId = ctx.Chat.RandomSnowflake.Next(); Channel? delChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(delChanName));
if(ctx.Args.Length < 1 || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) { if(delChan == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR)); await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, delChanName));
return; 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(!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.EventStorage;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands namespace SharpChat.ClientCommands;
{
public class DeleteMessageClientCommand : ClientCommand { public class DeleteMessageClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("delmsg") || ( return ctx.NameEquals("delmsg") || (
ctx.NameEquals("delete") ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true && 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) { string? firstArg = ctx.Args.FirstOrDefault();
long msgId = ctx.Chat.RandomSnowflake.Next();
bool deleteAnyMessage = ctx.User.Can(UserPermissions.DeleteAnyMessage);
if(!deleteAnyMessage && !ctx.User.Can(UserPermissions.DeleteOwnMessage)) { 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_NOT_ALLOWED, true, $"/{ctx.Name}")); await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
return; 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));
} }
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; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class JoinChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class JoinChannelClientCommand : ClientCommand {
return ctx.NameEquals("join"); 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) { await ctx.Chat.SwitchChannel(ctx.User, joinChan, string.Join(' ', ctx.Args.Skip(1)));
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)));
}
} }
} }

View file

@ -2,72 +2,72 @@ using SharpChat.Bans;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class KickBanClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class KickBanClientCommand(BansClient bansClient) : ClientCommand {
return ctx.NameEquals("kick") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("ban"); 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) { string? banUserTarget = ctx.Args.ElementAtOrDefault(0);
bool isBanning = ctx.NameEquals("ban"); string? banDurationStr = ctx.Args.ElementAtOrDefault(1);
long msgId = ctx.Chat.RandomSnowflake.Next(); int banReasonIndex = 1;
User? banUser = null;
if(!ctx.User.Can(isBanning ? UserPermissions.BanUser : UserPermissions.KickUser)) { if(banUserTarget == null || (banUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(banUserTarget))) == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); 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; return;
} }
string? banUserTarget = ctx.Args.ElementAtOrDefault(0); duration = TimeSpan.FromSeconds(durationSeconds);
string? banDurationStr = ctx.Args.ElementAtOrDefault(1); ++banReasonIndex;
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
);
if(duration <= TimeSpan.Zero) {
await ctx.Chat.BanUser(banUser, duration); 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.Globalization;
using System.Text; using System.Text;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class NickClientCommand : ClientCommand {
private const int MAX_GRAPHEMES = 16;
private const int MAX_BYTES = MAX_GRAPHEMES * 10;
public bool IsMatch(ClientCommandContext ctx) { public class NickClientCommand : ClientCommand {
return ctx.NameEquals("nick"); 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) { User? targetUser = null;
long msgId = ctx.Chat.RandomSnowflake.Next(); int offset = 0;
bool setOthersNick = ctx.User.Can(UserPermissions.SetOthersNickname);
if(!setOthersNick && !ctx.User.Can(UserPermissions.SetOwnNickname)) { if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == targetUserId.ToString());
return; ++offset;
}
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);
} }
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 SharpChat.SockChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class PardonAddressClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class PardonAddressClientCommand(BansClient bansClient) : ClientCommand {
return ctx.NameEquals("pardonip") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("unbanip"); 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) { string? unbanAddrTarget = ctx.Args.FirstOrDefault();
long msgId = ctx.Chat.RandomSnowflake.Next(); if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress? unbanAddr)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) { return;
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));
} }
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.Bans;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class PardonUserClientCommand(BansClient bansClient) : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class PardonUserClientCommand(BansClient bansClient) : ClientCommand {
return ctx.NameEquals("pardon") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("unban"); 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) { string? unbanUserTarget = ctx.Args.FirstOrDefault();
long msgId = ctx.Chat.RandomSnowflake.Next(); if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
if(!ctx.User.Can(UserPermissions.BanUser | UserPermissions.KickUser)) { return;
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 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; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class PasswordChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class PasswordChannelClientCommand : ClientCommand {
return ctx.NameEquals("pwd") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("password"); 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) { string chanPass = string.Join(' ', ctx.Args).Trim();
long msgId = ctx.Chat.RandomSnowflake.Next();
if(!ctx.User.Can(UserPermissions.SetChannelPassword) || ctx.Channel.IsOwner(ctx.User)) { if(string.IsNullOrWhiteSpace(chanPass))
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}")); chanPass = string.Empty;
return;
}
string chanPass = string.Join(' ', ctx.Args).Trim(); await ctx.Chat.UpdateChannel(ctx.Channel, password: chanPass);
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_PASSWORD_CHANGED, false));
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));
}
} }
} }

View file

@ -1,28 +1,28 @@
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class RankChannelClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class RankChannelClientCommand : ClientCommand {
return ctx.NameEquals("rank") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("privilege") return ctx.NameEquals("rank")
|| ctx.NameEquals("priv"); || 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) { if(ctx.Args.Length < 1 || !int.TryParse(ctx.Args.First(), out int chanHierarchy) || chanHierarchy > ctx.User.Rank) {
long msgId = ctx.Chat.RandomSnowflake.Next(); await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
return;
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));
} }
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 SharpChat.SockChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class RemoteAddressClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class RemoteAddressClientCommand : ClientCommand {
return ctx.NameEquals("ip") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("whois"); 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) { string? ipUserStr = ctx.Args.FirstOrDefault();
long msgId = ctx.Chat.RandomSnowflake.Next(); User? ipUser = null;
if(!ctx.User.Can(UserPermissions.SeeIPAddress)) { if(string.IsNullOrWhiteSpace(ipUserStr) || (ipUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(ipUserStr))) == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, "/ip")); await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, ipUserStr ?? "User"));
return; 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));
} }
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; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { 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));
public bool IsMatch(ClientCommandContext ctx) { public class ShutdownRestartClientCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) : ClientCommand {
return ctx.NameEquals("shutdown") private readonly ManualResetEvent WaitHandle = waitHandle ?? throw new ArgumentNullException(nameof(waitHandle));
|| ctx.NameEquals("restart"); 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(!ShutdownCheck())
if(!ctx.User.UserId.Equals("1")) { return;
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()) if(ctx.NameEquals("restart"))
return; foreach(Connection conn in ctx.Chat.Connections)
conn.PrepareForRestart();
if(ctx.NameEquals("restart")) await ctx.Chat.Update();
foreach(Connection conn in ctx.Chat.Connections) WaitHandle?.Set();
conn.PrepareForRestart();
await ctx.Chat.Update();
WaitHandle?.Set();
}
} }
} }

View file

@ -1,45 +1,45 @@
using SharpChat.Events; using SharpChat.Events;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class WhisperClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) { public class WhisperClientCommand : ClientCommand {
return ctx.NameEquals("whisper") public bool IsMatch(ClientCommandContext ctx) {
|| ctx.NameEquals("msg"); 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) { string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
long msgId = ctx.Chat.RandomSnowflake.Next(); User? whisperUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(whisperUserStr));
if(ctx.Args.Length < 2) { if(whisperUser == null) {
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR)); await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, whisperUserStr));
return; 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 == 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 SharpChat.SockChat.S2CPackets;
using System.Text; using System.Text;
namespace SharpChat.ClientCommands { namespace SharpChat.ClientCommands;
public class WhoClientCommand : ClientCommand {
public bool IsMatch(ClientCommandContext ctx) {
return ctx.NameEquals("who");
}
public async Task Dispatch(ClientCommandContext ctx) { public class WhoClientCommand : ClientCommand {
long msgId = ctx.Chat.RandomSnowflake.Next(); public bool IsMatch(ClientCommandContext ctx) {
StringBuilder whoChanSB = new(); return ctx.NameEquals("who");
string? whoChanStr = ctx.Args.FirstOrDefault(); }
if(string.IsNullOrEmpty(whoChanStr)) { public async Task Dispatch(ClientCommandContext ctx) {
foreach(User whoUser in ctx.Chat.Users) { long msgId = ctx.Chat.RandomSnowflake.Next();
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);"""); StringBuilder whoChanSB = new();
string? whoChanStr = ctx.Args.FirstOrDefault();
if(whoUser == ctx.User) if(string.IsNullOrEmpty(whoChanStr)) {
whoChanSB.Append(@" style=""font-weight: bold;"""); foreach(User whoUser in ctx.Chat.Users) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
whoChanSB.Append('>'); if(whoUser == ctx.User)
whoChanSB.Append(whoUser.LegacyName); whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append("</a>, ");
}
if(whoChanSB.Length > 2) whoChanSB.Append('>');
whoChanSB.Length -= 2; whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
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));
} }
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 SharpChat.SockChat;
using System.Net; using System.Net;
namespace SharpChat { namespace SharpChat;
public class Connection : IDisposable {
public const int ID_LENGTH = 20; public class Connection : IDisposable {
public const int ID_LENGTH = 20;
#if DEBUG #if DEBUG
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1); public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1);
#else #else
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5); public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5);
#endif #endif
public IWebSocketConnection Socket { get; } public IWebSocketConnection Socket { get; }
public string Id { get; } public string Id { get; }
public bool IsDisposed { get; private set; } public bool IsDisposed { get; private set; }
public DateTimeOffset LastPing { get; set; } = DateTimeOffset.Now; public DateTimeOffset LastPing { get; set; } = DateTimeOffset.Now;
public User? User { get; set; } public User? User { get; set; }
private int CloseCode { get; set; } = 1000; private int CloseCode { get; set; } = 1000;
public IPAddress RemoteAddress { get; } public IPAddress RemoteAddress { get; }
public ushort RemotePort { get; } public ushort RemotePort { get; }
public bool IsAlive => !IsDisposed && !HasTimedOut; public bool IsAlive => !IsDisposed && !HasTimedOut;
public Connection(IWebSocketConnection sock) { public Connection(IWebSocketConnection sock) {
Socket = sock; Socket = sock;
Id = RNG.SecureRandomString(ID_LENGTH); Id = RNG.SecureRandomString(ID_LENGTH);
if(!IPAddress.TryParse(sock.ConnectionInfo.ClientIpAddress, out IPAddress? addr)) if(!IPAddress.TryParse(sock.ConnectionInfo.ClientIpAddress, out IPAddress? addr))
throw new Exception("Unable to parse remote address?????"); throw new Exception("Unable to parse remote address?????");
if(IPAddress.IsLoopback(addr) if(IPAddress.IsLoopback(addr)
&& sock.ConnectionInfo.Headers.TryGetValue("X-Real-IP", out string? addrStr) && sock.ConnectionInfo.Headers.TryGetValue("X-Real-IP", out string? addrStr)
&& IPAddress.TryParse(addrStr, out IPAddress? realAddr)) && IPAddress.TryParse(addrStr, out IPAddress? realAddr))
addr = realAddr; addr = realAddr;
RemoteAddress = addr; RemoteAddress = addr;
RemotePort = (ushort)sock.ConnectionInfo.ClientPort; RemotePort = (ushort)sock.ConnectionInfo.ClientPort;
} }
public async Task Send(S2CPacket packet) { public async Task Send(S2CPacket packet) {
if(!Socket.IsAvailable) if(!Socket.IsAvailable)
return; return;
string data = packet.Pack(); string data = packet.Pack();
if(!string.IsNullOrWhiteSpace(data)) if(!string.IsNullOrWhiteSpace(data))
await Socket.Send(data); await Socket.Send(data);
} }
public void BumpPing() { public void BumpPing() {
LastPing = DateTimeOffset.Now; LastPing = DateTimeOffset.Now;
} }
public bool HasTimedOut public bool HasTimedOut
=> DateTimeOffset.Now - LastPing > SessionTimeOut; => DateTimeOffset.Now - LastPing > SessionTimeOut;
public void PrepareForRestart() { public void PrepareForRestart() {
CloseCode = 1012; CloseCode = 1012;
} }
~Connection() { ~Connection() {
DoDispose(); DoDispose();
} }
public void Dispose() { public void Dispose() {
DoDispose(); DoDispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
private void DoDispose() { private void DoDispose() {
if(IsDisposed) if(IsDisposed)
return; return;
IsDisposed = true; IsDisposed = true;
Socket.Close(CloseCode); Socket.Close(CloseCode);
} }
public override string ToString() { public override string ToString() {
return Id; return Id;
} }
public override int GetHashCode() { public override int GetHashCode() {
return Id.GetHashCode(); return Id.GetHashCode();
}
} }
} }

View file

@ -5,409 +5,409 @@ using SharpChat.SockChat;
using SharpChat.SockChat.S2CPackets; using SharpChat.SockChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat { namespace SharpChat;
public class Context {
public record ChannelUserAssoc(string UserId, string ChannelName);
public readonly SemaphoreSlim ContextAccess = new(1, 1); public class Context {
public record ChannelUserAssoc(string UserId, string ChannelName);
public SnowflakeGenerator SnowflakeGenerator { get; } = new(); public readonly SemaphoreSlim ContextAccess = new(1, 1);
public RandomSnowflake RandomSnowflake { get; }
public HashSet<Channel> Channels { get; } = []; public SnowflakeGenerator SnowflakeGenerator { get; } = new();
public HashSet<Connection> Connections { get; } = []; public RandomSnowflake RandomSnowflake { 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 Context(EventStorage.EventStorage evtStore) { public HashSet<Channel> Channels { get; } = [];
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore)); public HashSet<Connection> Connections { get; } = [];
RandomSnowflake = new(SnowflakeGenerator); 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) { public Context(EventStorage.EventStorage evtStore) {
if(eventInfo is MessageCreateEvent mce) { Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
if(mce.IsBroadcast) { RandomSnowflake = new(SnowflakeGenerator);
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
// this entire routine is garbage, channels should probably in the db public async Task DispatchEvent(ChatEvent eventInfo) {
if(!mce.ChannelName.StartsWith('@')) if(eventInfo is MessageCreateEvent mce) {
return; 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()); // this entire routine is garbage, channels should probably in the db
if(uids.Count() != 2) if(!mce.ChannelName.StartsWith('@'))
return; return;
IEnumerable<User> users = Users.Where(u => uids.Any(uid => uid == u.UserId)); IEnumerable<string> uids = mce.ChannelName[1..].Split('-', 3).Select(u => (long.TryParse(u, out long up) ? up : -1).ToString());
User? target = users.FirstOrDefault(u => u.UserId != mce.SenderId); if(uids.Count() != 2)
if(target == null) return;
return;
foreach(User user in users) IEnumerable<User> users = Users.Where(u => uids.Any(uid => uid == u.UserId));
await SendTo(user, new ChatMessageAddS2CPacket( User? target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
mce.MessageId, if(target == null)
DateTimeOffset.Now, return;
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
));
}
Events.AddEvent( foreach(User user in users)
mce.MessageId, "msg:add", await SendTo(user, new ChatMessageAddS2CPacket(
mce.ChannelName, mce.MessageId,
mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms, DateTimeOffset.Now,
new { text = mce.MessageText }, mce.SenderId,
(mce.IsBroadcast ? StoredEventFlags.Broadcast : 0) mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText,
| (mce.IsAction ? StoredEventFlags.Action : 0) mce.IsAction,
| (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,
true true
)) ));
)); } else {
Channel? channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName));
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name)) if(channel is not null)
await conn.Send(new ContextMessageS2CPacket(msg)); await SendTo(channel, new ChatMessageAddS2CPacket(
mce.MessageId,
await conn.Send(new ContextChannelsS2CPacket( DateTimeOffset.Now,
Channels.Where(c => c.Rank <= user.Rank) mce.SenderId,
.Select(c => new ContextChannelsS2CPacket.Entry(c.Name, c.HasPassword, c.IsTemporary)) mce.MessageText,
)); mce.IsAction,
false
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);
} }
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(nickName != null && !user.NickName.Equals(nickName)) {
if(UserLastChannel.TryGetValue(user.UserId, out Channel? ulc) && chan == ulc) { 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); await ForceChannel(user);
return; return;
} }
if(!user.Can(UserPermissions.JoinAnyChannel) && chan.IsOwner(user)) { if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) {
if(chan.Rank > user.Rank) { await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
await SendTo(user, new CommandResponseS2CPacket(RandomSnowflake.Next(), LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name)); await ForceChannel(user);
await ForceChannel(user); return;
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) { await ForceChannelSwitch(user, chan);
if(!Channels.Contains(chan)) }
return;
Channel oldChan = UserLastChannel[user.UserId]; public async Task ForceChannelSwitch(User user, Channel chan) {
if(!Channels.Contains(chan))
return;
long leaveId = RandomSnowflake.Next(); Channel oldChan = UserLastChannel[user.UserId];
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);
long joinId = RandomSnowflake.Next(); long leaveId = RandomSnowflake.Next();
await SendTo(chan, new UserChannelJoinS2CPacket(joinId, user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions)); await SendTo(oldChan, new UserChannelLeaveS2CPacket(leaveId, user.UserId));
Events.AddEvent(joinId, "chan:join", chan.Name, user.UserId, user.LegacyName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log); 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)); long joinId = RandomSnowflake.Next();
await SendTo(user, new ContextUsersS2CPacket( await SendTo(chan, new UserChannelJoinS2CPacket(joinId, user.UserId, user.LegacyNameWithStatus, user.Colour, user.Rank, user.Permissions));
GetChannelUsers(chan).Except([user]).OrderByDescending(u => u.Rank) Events.AddEvent(joinId, "chan:join", chan.Name, user.UserId, user.LegacyName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log);
.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 SendTo(user, new ContextClearS2CPacket(ContextClearS2CPacket.Mode.MessagesUsers));
await SendTo(user, new ContextMessageS2CPacket(msg)); 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)); await ForceChannel(user, chan);
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
if(oldChan.IsTemporary && oldChan.IsOwner(user)) ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
await RemoveChannel(oldChan); ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
} UserLastChannel[user.UserId] = chan;
public async Task Send(S2CPacket packet) { if(oldChan.IsTemporary && oldChan.IsOwner(user))
foreach(Connection conn in Connections) await RemoveChannel(oldChan);
if(conn.IsAlive && conn.User is not null) }
await conn.Send(packet);
}
public async Task SendTo(User user, S2CPacket packet) { public async Task Send(S2CPacket packet) {
foreach(Connection conn in Connections) foreach(Connection conn in Connections)
if(conn.IsAlive && conn.User == user) if(conn.IsAlive && conn.User is not null)
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)
await conn.Send(packet); await conn.Send(packet);
} }
public async Task SendToUserChannels(User user, S2CPacket packet) { public async Task SendTo(User user, S2CPacket packet) {
IEnumerable<Channel> chans = Channels.Where(c => IsInChannel(user, c)); foreach(Connection conn in Connections)
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)))); if(conn.IsAlive && conn.User == user)
foreach(Connection conn in conns)
await conn.Send(packet); await conn.Send(packet);
} }
public IPAddress[] GetRemoteAddresses(User user) { public async Task SendTo(Channel channel, S2CPacket packet) {
return [.. Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct()]; // 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) { public async Task SendToUserChannels(User user, S2CPacket packet) {
ArgumentNullException.ThrowIfNull(user); 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)) public IPAddress[] GetRemoteAddresses(User user) {
throw new ArgumentException("no channel???"); 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) { if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan))
ArgumentNullException.ThrowIfNull(channel); throw new ArgumentException("no channel???");
if(!Channels.Contains(channel))
throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel));
if(temporary.HasValue) await SendTo(user, new UserChannelForceJoinS2CPacket(chan.Name));
channel.IsTemporary = temporary.Value; }
if(hierarchy.HasValue) public async Task UpdateChannel(Channel channel, bool? temporary = null, int? hierarchy = null, string? password = null) {
channel.Rank = hierarchy.Value; ArgumentNullException.ThrowIfNull(channel);
if(!Channels.Contains(channel))
throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel));
if(password != null) if(temporary.HasValue)
channel.Password = password; 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 if(hierarchy.HasValue)
foreach(User user in Users.Where(u => u.Rank >= channel.Rank)) channel.Rank = hierarchy.Value;
await SendTo(user, new ChannelUpdateS2CPacket(channel.Name, channel.Name, channel.HasPassword, channel.IsTemporary));
}
public async Task RemoveChannel(Channel channel) { if(password != null)
if(channel == null || Channels.Count < 1) channel.Password = password;
return;
Channel? defaultChannel = Channels.FirstOrDefault(); // 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
if(defaultChannel is null) foreach(User user in Users.Where(u => u.Rank >= channel.Rank))
return; await SendTo(user, new ChannelUpdateS2CPacket(channel.Name, channel.Name, channel.HasPassword, channel.IsTemporary));
}
// Remove channel from the listing public async Task RemoveChannel(Channel channel) {
Channels.Remove(channel); if(channel == null || Channels.Count < 1)
return;
// Move all users back to the main channel Channel? defaultChannel = Channels.FirstOrDefault();
// TODO: Replace this with a kick. SCv2 supports being in 0 channels, SCv1 should force the user back to DefaultChannel. if(defaultChannel is null)
foreach(User user in GetChannelUsers(channel)) return;
await SwitchChannel(user, defaultChannel, string.Empty);
// Broadcast deletion of channel // Remove channel from the listing
foreach(User user in Users.Where(u => u.Rank >= channel.Rank)) Channels.Remove(channel);
await SendTo(user, new ChannelDeleteS2CPacket(channel.Name));
} // 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 { namespace SharpChat.EventStorage;
public interface EventStorage {
void AddEvent( public interface EventStorage {
long id, void AddEvent(
string type, long id,
string channelName, string type,
string senderId, string channelName,
string senderName, string senderId,
ColourInheritable senderColour, string senderName,
int senderRank, ColourInheritable senderColour,
string senderNick, int senderRank,
UserPermissions senderPerms, string senderNick,
object? data = null, UserPermissions senderPerms,
StoredEventFlags flags = StoredEventFlags.None object? data = null,
); StoredEventFlags flags = StoredEventFlags.None
void RemoveEvent(StoredEventInfo evt); );
StoredEventInfo? GetEvent(long seqId); void RemoveEvent(StoredEventInfo evt);
IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0); 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;
using System.Text.Json; using System.Text.Json;
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage;
public partial class MariaDBEventStorage(string connString) : EventStorage {
private string ConnectionString { get; } = connString ?? throw new ArgumentNullException(nameof(connString));
public void AddEvent( public partial class MariaDBEventStorage(string connString) : EventStorage {
long id, private string ConnectionString { get; } = connString ?? throw new ArgumentNullException(nameof(connString));
string type,
string channelName, public void AddEvent(
string senderId, long id,
string senderName, string type,
ColourInheritable senderColour, string channelName,
int senderRank, string senderId,
string senderNick, string senderName,
UserPermissions senderPerms, ColourInheritable senderColour,
object? data = null, int senderRank,
StoredEventFlags flags = StoredEventFlags.None string senderNick,
) { UserPermissions senderPerms,
RunCommand( object? data = null,
"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`" StoredEventFlags flags = StoredEventFlags.None
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)" ) {
+ " VALUES (@id, NOW(), @type, @target, @flags, @data" RunCommand(
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)", "INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`"
new MySqlParameter("id", id), + ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
new MySqlParameter("type", type), + " VALUES (@id, NOW(), @type, @target, @flags, @data"
new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName), + ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter("flags", (byte)flags), new MySqlParameter("id", id),
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)), new MySqlParameter("type", type),
new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null), new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new MySqlParameter("sender_name", senderName), new MySqlParameter("flags", (byte)flags),
new MySqlParameter("sender_colour", senderColour.ToMisuzu()), new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
new MySqlParameter("sender_rank", senderRank), new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick), new MySqlParameter("sender_name", senderName),
new MySqlParameter("sender_perms", senderPerms) 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) { if(reader is null)
try { return null;
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) while(reader.Read()) {
return null; StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
while(reader.Read()) { return evt;
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
return evt;
}
} catch(MySqlException ex) {
Logger.Write(ex);
} }
} catch(MySqlException ex) {
return null; Logger.Write(ex);
} }
private static StoredEventInfo ReadEvent(MySqlDataReader reader) { return null;
return new StoredEventInfo( }
reader.GetInt64("event_id"),
Encoding.ASCII.GetString((byte[])reader["event_type"]), private static StoredEventInfo ReadEvent(MySqlDataReader reader) {
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new User( return new StoredEventInfo(
reader.GetInt64("event_sender").ToString(), reader.GetInt64("event_id"),
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"), Encoding.ASCII.GetString((byte[])reader["event_type"]),
ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")), reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new User(
reader.GetInt32("event_sender_rank"), reader.GetInt64("event_sender").ToString(),
(UserPermissions)reader.GetInt32("event_sender_perms"), reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick") ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")),
), reader.GetInt32("event_sender_rank"),
DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")), (UserPermissions)reader.GetInt32("event_sender_perms"),
reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")), reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick")
reader.IsDBNull(reader.GetOrdinal("event_target")) ? null : Encoding.ASCII.GetString((byte[])reader["event_target"]), ),
JsonDocument.Parse(Encoding.ASCII.GetString((byte[])reader["event_data"])), DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
(StoredEventFlags)reader.GetByte("event_flags") 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) { while(reader.Read()) {
List<StoredEventInfo> events = []; StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
try { events.Add(evt);
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);
} }
} catch(MySqlException ex) {
events.Reverse(); Logger.Write(ex);
return events;
} }
public void RemoveEvent(StoredEventInfo evt) { events.Reverse();
ArgumentNullException.ThrowIfNull(evt);
RunCommand( return events;
"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL", }
new MySqlParameter("id", evt.Id)
); 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 MySqlConnector;
using SharpChat.Configuration; using SharpChat.Configuration;
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage;
public partial class MariaDBEventStorage {
public static string BuildConnString(Configuration.Config config) { public partial class MariaDBEventStorage {
return BuildConnString( public static string BuildConnString(Configuration.Config config) {
config.ReadValue("host", "localhost"), return BuildConnString(
config.ReadValue("user", string.Empty), config.ReadValue("host", "localhost"),
config.ReadValue("pass", string.Empty), config.ReadValue("user", string.Empty),
config.ReadValue("db", "sharpchat") 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 0;
return new MySqlConnectionStringBuilder { }
Server = host,
UserID = username, private MySqlDataReader? RunQuery(string command, params MySqlParameter[] parameters) {
Password = password, try {
Database = database, MySqlConnection conn = GetConnection();
OldGuids = false, MySqlCommand cmd = conn.CreateCommand();
TreatTinyAsBoolean = false, if(parameters?.Length > 0)
CharacterSet = "utf8mb4", cmd.Parameters.AddRange(parameters);
SslMode = MySqlSslMode.None, cmd.CommandText = command;
ForceSynchronous = true, return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);
ConnectionTimeout = 5, } catch(MySqlException ex) {
DefaultCommandTimeout = 900, // fuck it, 15 minutes Logger.Write(ex);
}.ToString();
} }
private MySqlConnection GetConnection() { return null;
MySqlConnection conn = new(ConnectionString); }
conn.Open();
return conn; 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) { return default;
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;
}
} }
} }

View file

@ -1,84 +1,84 @@
using MySqlConnector; using MySqlConnector;
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage;
public partial class MariaDBEventStorage {
private void DoMigration(string name, Action action) { public partial class MariaDBEventStorage {
bool done = RunQueryValue<long>( private void DoMigration(string name, Action action) {
"SELECT COUNT(*) FROM `sqc_migrations` WHERE `migration_name` = @name", 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) 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 { namespace SharpChat.EventStorage;
[Flags]
public enum StoredEventFlags { [Flags]
None = 0, public enum StoredEventFlags {
Action = 1, None = 0,
Broadcast = 1 << 1, Action = 1,
Log = 1 << 2, Broadcast = 1 << 1,
Private = 1 << 3, Log = 1 << 2,
} Private = 1 << 3,
} }

View file

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

View file

@ -1,65 +1,65 @@
using System.Text.Json; using System.Text.Json;
namespace SharpChat.EventStorage { namespace SharpChat.EventStorage;
public class VirtualEventStorage : EventStorage {
private readonly Dictionary<long, StoredEventInfo> Events = [];
public void AddEvent( public class VirtualEventStorage : EventStorage {
long id, private readonly Dictionary<long, StoredEventInfo> Events = [];
string type,
string channelName, public void AddEvent(
string senderId, long id,
string senderName, string type,
ColourInheritable senderColour, string channelName,
int senderRank, string senderId,
string senderNick, string senderName,
UserPermissions senderPerms, ColourInheritable senderColour,
object? data = null, int senderRank,
StoredEventFlags flags = StoredEventFlags.None string senderNick,
) { UserPermissions senderPerms,
Events.Add( object? data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
Events.Add(
id,
new(
id, id,
new( type,
id, long.TryParse(senderId, out long senderId64) && senderId64 > 0
type, ? new User(
long.TryParse(senderId, out long senderId64) && senderId64 > 0 senderId,
? new User( senderName,
senderId, senderColour,
senderName, senderRank,
senderColour, senderPerms,
senderRank, senderNick
senderPerms, )
senderNick : null,
) DateTimeOffset.Now,
: null, null,
DateTimeOffset.Now, channelName,
null, JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data)),
channelName, flags
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 [.. subset.Skip(start).Take(amount)];
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)];
}
} }
} }

View file

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

View file

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

View file

@ -3,145 +3,145 @@ using SharpChat.EventStorage;
using SharpChat.Flashii; using SharpChat.Flashii;
using System.Text; using System.Text;
namespace SharpChat { namespace SharpChat;
public class Program {
public const string CONFIG = "sharpchat.cfg";
public static void Main() { public class Program {
Console.WriteLine(@" _____ __ ________ __ "); public const string CONFIG = "sharpchat.cfg";
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, ' '));
using ManualResetEvent mre = new(false); public static void Main() {
bool hasCancelled = false; 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) { using ManualResetEvent mre = new(false);
Console.CancelKeyPress -= cancelKeyPressHandler; bool hasCancelled = false;
hasCancelled = true;
ev.Cancel = true;
mre.Set();
};
Console.CancelKeyPress += cancelKeyPressHandler;
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 string configFile = CONFIG;
if(!File.Exists(configFile) && configFile == CONFIG)
ConvertConfiguration();
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() { if(hasCancelled) return;
UseProxy = false,
});
httpClient.DefaultRequestHeaders.Add("User-Agent", SharpInfo.ProgramName);
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(hasCancelled) return;
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; EventStorage.EventStorage evtStore;
if(string.IsNullOrWhiteSpace(config.SafeReadValue("mariadb:host", string.Empty))) {
using SockChatServer scs = new(flashii, flashii, evtStore, config.ScopeTo("chat")); evtStore = new VirtualEventStorage();
scs.Listen(mre); } else {
MariaDBEventStorage mdbes = new(MariaDBEventStorage.BuildConnString(config.ScopeTo("mariadb")));
mre.WaitOne(); evtStore = mdbes;
mdbes.RunMigrations();
} }
private static void ConvertConfiguration() { if(hasCancelled) return;
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)); using SockChatServer scs = new(flashii, flashii, evtStore, config.ScopeTo("chat"));
sw.WriteLine("# and ; can be used at the start of a line for comments."); scs.Listen(mre);
sw.WriteLine();
sw.WriteLine("# General Configuration"); mre.WaitOne();
sw.WriteLine($"#chat:port {SockChatServer.DEFAULT_PORT}"); }
sw.WriteLine($"#chat:msgMaxLength {SockChatServer.DEFAULT_MSG_LENGTH_MAX}");
sw.WriteLine($"#chat:connMaxCount {SockChatServer.DEFAULT_MAX_CONNECTIONS}"); private static void ConvertConfiguration() {
sw.WriteLine($"#chat:floodKickLength {SockChatServer.DEFAULT_FLOOD_KICK_LENGTH}"); 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();
sw.WriteLine("# Channels"); sw.WriteLine("# Channels");
sw.WriteLine("chat:channels lounge staff"); sw.WriteLine("chat:channels lounge staff");
sw.WriteLine(); sw.WriteLine();
sw.WriteLine("# Lounge channel settings"); sw.WriteLine("# Lounge channel settings");
sw.WriteLine("chat:channels:lounge:name Lounge"); sw.WriteLine("chat:channels:lounge:name Lounge");
sw.WriteLine("chat:channels:lounge:autoJoin true"); sw.WriteLine("chat:channels:lounge:autoJoin true");
sw.WriteLine(); sw.WriteLine();
sw.WriteLine("# Staff channel settings"); sw.WriteLine("# Staff channel settings");
sw.WriteLine("chat:channels:staff:name Staff"); sw.WriteLine("chat:channels:staff:name Staff");
sw.WriteLine("chat:channels:staff:minRank 5"); sw.WriteLine("chat:channels:staff:minRank 5");
const string msz_secret = "login_key.txt"; const string msz_secret = "login_key.txt";
const string msz_url = "msz_url.txt"; const string msz_url = "msz_url.txt";
sw.WriteLine(); sw.WriteLine();
sw.WriteLine("# Misuzu integration settings"); sw.WriteLine("# Misuzu integration settings");
if(File.Exists(msz_secret)) if(File.Exists(msz_secret))
sw.WriteLine(string.Format("msz:secret {0}", File.ReadAllText(msz_secret).Trim())); sw.WriteLine(string.Format("msz:secret {0}", File.ReadAllText(msz_secret).Trim()));
else else
sw.WriteLine("#msz:secret woomy"); sw.WriteLine("#msz:secret woomy");
if(File.Exists(msz_url)) if(File.Exists(msz_url))
sw.WriteLine(string.Format("msz:url {0}/_sockchat", File.ReadAllText(msz_url).Trim())); sw.WriteLine(string.Format("msz:url {0}/_sockchat", File.ReadAllText(msz_url).Trim()));
else else
sw.WriteLine("#msz:url https://flashii.net/_sockchat"); sw.WriteLine("#msz:url https://flashii.net/_sockchat");
const string mdb_config = @"mariadb.txt"; const string mdb_config = @"mariadb.txt";
string[] mdbCfg = File.Exists(mdb_config) ? File.ReadAllLines(mdb_config) : []; string[] mdbCfg = File.Exists(mdb_config) ? File.ReadAllLines(mdb_config) : [];
sw.WriteLine(); sw.WriteLine();
sw.WriteLine("# MariaDB configuration"); sw.WriteLine("# MariaDB configuration");
if(mdbCfg.Length > 0) if(mdbCfg.Length > 0)
sw.WriteLine($"mariadb:host {mdbCfg[0]}"); sw.WriteLine($"mariadb:host {mdbCfg[0]}");
else else
sw.WriteLine($"#mariadb:host <username>"); sw.WriteLine($"#mariadb:host <username>");
if(mdbCfg.Length > 1) if(mdbCfg.Length > 1)
sw.WriteLine($"mariadb:user {mdbCfg[1]}"); sw.WriteLine($"mariadb:user {mdbCfg[1]}");
else else
sw.WriteLine($"#mariadb:user <username>"); sw.WriteLine($"#mariadb:user <username>");
if(mdbCfg.Length > 2) if(mdbCfg.Length > 2)
sw.WriteLine($"mariadb:pass {mdbCfg[2]}"); sw.WriteLine($"mariadb:pass {mdbCfg[2]}");
else else
sw.WriteLine($"#mariadb:pass <password>"); sw.WriteLine($"#mariadb:pass <password>");
if(mdbCfg.Length > 3) if(mdbCfg.Length > 3)
sw.WriteLine($"mariadb:db {mdbCfg[3]}"); sw.WriteLine($"mariadb:db {mdbCfg[3]}");
else else
sw.WriteLine($"#mariadb:db <database>"); 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 // 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 // https://github.com/statianzo/Fleck/blob/1.1.0/src/Fleck/WebSocketServer.cs
namespace SharpChat { namespace SharpChat;
public class SharpChatWebSocketServer : IWebSocketServer {
private readonly string _scheme; public class SharpChatWebSocketServer : IWebSocketServer {
private readonly IPAddress _locationIP;
private Action<IWebSocketConnection> _config;
public SharpChatWebSocketServer(string location, bool supportDualStack = true) { private readonly string _scheme;
Uri uri = new(location); private readonly IPAddress _locationIP;
private Action<IWebSocketConnection> _config;
Port = uri.Port; public SharpChatWebSocketServer(string location, bool supportDualStack = true) {
Location = location; Uri uri = new(location);
SupportDualStack = supportDualStack;
_locationIP = ParseIPAddress(uri); Port = uri.Port;
_scheme = uri.Scheme; Location = location;
Socket socket = new(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP); SupportDualStack = supportDualStack;
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
if(SupportDualStack && Type.GetType("Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { _locationIP = ParseIPAddress(uri);
socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false); _scheme = uri.Scheme;
} Socket socket = new(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
ListenerSocket = new SocketWrapper(socket); if(SupportDualStack && Type.GetType("Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
SupportedSubProtocols = []; socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false);
} }
public ISocket ListenerSocket { get; set; } ListenerSocket = new SocketWrapper(socket);
public string Location { get; private set; } SupportedSubProtocols = [];
public bool SupportDualStack { get; } }
public int Port { get; private set; }
public X509Certificate2 Certificate { get; set; }
public SslProtocols EnabledSslProtocols { get; set; }
public IEnumerable<string> SupportedSubProtocols { get; set; }
public bool RestartAfterListenError { get; set; }
public bool IsSecure { public ISocket ListenerSocket { get; set; }
get { return _scheme == "wss" && Certificate != null; } 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() { public bool IsSecure {
ListenerSocket.Dispose(); get { return _scheme == "wss" && Certificate != null; }
GC.SuppressFinalize(this); }
}
private static IPAddress ParseIPAddress(Uri uri) { public void Dispose() {
string ipStr = uri.Host; ListenerSocket.Dispose();
GC.SuppressFinalize(this);
}
if(ipStr == "0.0.0.0") { private static IPAddress ParseIPAddress(Uri uri) {
return IPAddress.Any; string ipStr = uri.Host;
} 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) { if(ipStr == "0.0.0.0") {
IPEndPoint ipLocal = new(_locationIP, Port); return IPAddress.Any;
ListenerSocket.Bind(ipLocal); } else if(ipStr == "[0000:0000:0000:0000:0000:0000:0000:0000]") {
ListenerSocket.Listen(100); return IPAddress.IPv6Any;
Port = ((IPEndPoint)ListenerSocket.LocalEndPoint).Port; } else {
FleckLog.Info(string.Format("Server started at {0} (actual port {1})", Location, Port)); try {
if(_scheme == "wss") { return IPAddress.Parse(ipStr);
if(Certificate == null) { } catch(Exception ex) {
FleckLog.Error("Scheme cannot be 'wss' without a Certificate"); 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);
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();
} }
} }
} }
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 SharpChat.EventStorage;
using System.Text; using System.Text;
namespace SharpChat.SockChat.S2CPackets { 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));
public string Pack() { public class ContextMessageS2CPacket(StoredEventInfo evt, bool notify = false) : S2CPacket {
bool isAction = Event.Flags.HasFlag(StoredEventFlags.Action); public StoredEventInfo Event { get; private set; } = evt ?? throw new ArgumentNullException(nameof(evt));
bool isBroadcast = Event.Flags.HasFlag(StoredEventFlags.Broadcast);
bool isPrivate = Event.Flags.HasFlag(StoredEventFlags.Private);
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"); StringBuilder sb = new();
sb.Append(Event.Created.ToUnixTimeSeconds());
sb.Append('\t');
switch(Event.Type) { sb.Append("7\t1\t");
case "msg:add": sb.Append(Event.Created.ToUnixTimeSeconds());
case "SharpChat.Events.ChatMessage": sb.Append('\t');
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');
}
if(isAction) switch(Event.Type) {
sb.Append("<i>"); 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( if(isAction)
(Event.Data.RootElement.GetProperty("text").GetString()? sb.Append("<i>");
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")) ?? string.Empty
);
if(isAction) sb.Append(
sb.Append("</i>"); (Event.Data.RootElement.GetProperty("text").GetString()?
break; .Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")) ?? string.Empty
);
case "user:connect": if(isAction)
case "SharpChat.Events.UserConnectEvent": sb.Append("</i>");
sb.Append("-1\tChatBot\tinherit\t\t0\fjoin\f"); break;
sb.Append(Event.Sender?.LegacyName ?? "?????");
break;
case "chan:join": case "user:connect":
case "SharpChat.Events.UserChannelJoinEvent": case "SharpChat.Events.UserConnectEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\fjchan\f"); sb.Append("-1\tChatBot\tinherit\t\t0\fjoin\f");
sb.Append(Event.Sender?.LegacyName ?? "?????"); sb.Append(Event.Sender?.LegacyName ?? "?????");
break; break;
case "chan:leave": case "chan:join":
case "SharpChat.Events.UserChannelLeaveEvent": case "SharpChat.Events.UserChannelJoinEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\flchan\f"); sb.Append("-1\tChatBot\tinherit\t\t0\fjchan\f");
sb.Append(Event.Sender?.LegacyName ?? "?????"); sb.Append(Event.Sender?.LegacyName ?? "?????");
break; break;
case "user:disconnect": case "chan:leave":
case "SharpChat.Events.UserDisconnectEvent": case "SharpChat.Events.UserChannelLeaveEvent":
sb.Append("-1\tChatBot\tinherit\t\t0\f"); 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 "user:disconnect":
case UserDisconnectS2CPacket.Reason.Flood: case "SharpChat.Events.UserDisconnectEvent":
sb.Append("flood"); sb.Append("-1\tChatBot\tinherit\t\t0\f");
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('\f'); switch((UserDisconnectS2CPacket.Reason)Event.Data.RootElement.GetProperty("reason").GetByte()) {
sb.Append(Event.Sender?.LegacyName ?? "?????"); case UserDisconnectS2CPacket.Reason.Flood:
break; 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('\f');
sb.Append(Event.Id); sb.Append(Event.Sender?.LegacyName ?? "?????");
sb.Append('\t'); break;
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('\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 SharpChat.SockChat.S2CPackets;
using System.Net; using System.Net;
namespace SharpChat { 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;
public IWebSocketServer Server { get; } public class SockChatServer : IDisposable {
public Context Context { get; } 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 BansClient BansClient;
private readonly CachedValue<int> MaxConnections;
private readonly CachedValue<int> FloodKickLength;
private readonly CachedValue<int> FloodKickExemptRank;
private readonly List<C2SPacketHandler> GuestHandlers = []; private readonly CachedValue<int> MaxMessageLength;
private readonly List<C2SPacketHandler> AuthedHandlers = []; private readonly CachedValue<int> MaxConnections;
private readonly SendMessageC2SPacketHandler SendMessageHandler; 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( private Channel DefaultChannel { get; set; }
AuthClient authClient,
BansClient bansClient,
EventStorage.EventStorage evtStore,
Config config
) {
Logger.Write("Initialising Sock Chat server...");
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); BansClient = bansClient;
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
FloodKickExemptRank = config.ReadCached("floodKickExemptRank", DEFAULT_FLOOD_KICK_EXEMPT_RANK);
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); Context = new Context(evtStore);
if(channelNames is not null)
foreach(string channelName in channelNames) {
Config channelCfg = config.ScopeTo($"channels:{channelName}");
string name = channelCfg.SafeReadValue("name", string.Empty)!; string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS);
if(string.IsNullOrWhiteSpace(name)) if(channelNames is not null)
name = channelName; foreach(string channelName in channelNames) {
Config channelCfg = config.ScopeTo($"channels:{channelName}");
Channel channelInfo = new( string name = channelCfg.SafeReadValue("name", string.Empty)!;
name, if(string.IsNullOrWhiteSpace(name))
channelCfg.SafeReadValue("password", string.Empty)!, name = channelName;
rank: channelCfg.SafeReadValue("minRank", 0)
);
Context.Channels.Add(channelInfo); Channel channelInfo = new(
DefaultChannel ??= channelInfo; name,
} channelCfg.SafeReadValue("password", string.Empty)!,
rank: channelCfg.SafeReadValue("minRank", 0)
);
if(DefaultChannel is null) Context.Channels.Add(channelInfo);
throw new Exception("The default channel could not be determined."); 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([ GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, DefaultChannel, MaxMessageLength, MaxConnections));
new PingC2SPacketHandler(authClient),
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
]);
SendMessageHandler.AddCommands([ AuthedHandlers.AddRange([
new AFKClientCommand(), new PingC2SPacketHandler(authClient),
new NickClientCommand(), SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
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); SendMessageHandler.AddCommands([
Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}"); 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) { private async Task OnMessage(Connection conn, string msg) {
if(waitHandle != null) await Context.SafeUpdate();
SendMessageHandler.AddCommand(new ShutdownRestartClientCommand(waitHandle, () => !IsShuttingDown && (IsShuttingDown = true)));
Server.Start(sock => { // this doesn't affect non-authed connections?????
if(IsShuttingDown) { if(conn.User is not null && conn.User.Rank < FloodKickExemptRank) {
sock.Close(1013); User? banUser = null;
return; string banAddr = string.Empty;
} TimeSpan banDuration = TimeSpan.MinValue;
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(); Context.ContextAccess.Wait();
try { 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)) rateLimiter.Update();
await Context.HandleDisconnect(conn.User);
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 { } finally {
Context.ContextAccess.Release(); Context.ContextAccess.Release();
} }
} }
private async Task OnMessage(Connection conn, string msg) { C2SPacketHandlerContext context = new(msg, Context, conn);
await Context.SafeUpdate(); 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(handler is not null)
if(conn.User is not null && conn.User.Rank < FloodKickExemptRank) { await handler.Handle(context);
User? banUser = null; }
string banAddr = string.Empty;
TimeSpan banDuration = TimeSpan.MinValue;
Context.ContextAccess.Wait(); private bool IsDisposed;
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
));
rateLimiter.Update(); ~SockChatServer() {
DoDispose();
}
if(rateLimiter.IsExceeded) { public void Dispose() {
banDuration = TimeSpan.FromSeconds(FloodKickLength); DoDispose();
banUser = conn.User; GC.SuppressFinalize(this);
banAddr = conn.RemoteAddress.ToString(); }
} else if(rateLimiter.IsRisky) {
banUser = conn.User;
}
if(banUser is not null) { private void DoDispose() {
if(banDuration == TimeSpan.MinValue) { if(IsDisposed)
await Context.SendTo(conn.User, new CommandResponseS2CPacket(Context.RandomSnowflake.Next(), LCR.FLOOD_WARN, false)); return;
} else { IsDisposed = true;
await Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood); IsShuttingDown = true;
if(banDuration > TimeSpan.Zero) foreach(Connection conn in Context.Connections)
await BansClient.BanCreateAsync( conn.Dispose();
BanKind.User,
banDuration,
conn.RemoteAddress,
conn.User.UserId,
"Kicked from chat for flood protection.",
IPAddress.IPv6Loopback
);
return; Server?.Dispose();
}
}
} 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();
}
} }
} }

View file

@ -2,72 +2,72 @@ using SharpChat.ClientCommands;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace SharpChat { 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;
public string UserId { get; } = userId; public class User(
public string UserName { get; set; } = userName ?? throw new ArgumentNullException(nameof(userName)); string userId,
public ColourInheritable Colour { get; set; } = colour; string userName,
public int Rank { get; set; } = rank; ColourInheritable colour,
public UserPermissions Permissions { get; set; } = perms; int rank,
public string NickName { get; set; } = nickName; UserPermissions perms,
public UserStatus Status { get; set; } = status; string nickName = "",
public string StatusText { get; set; } = statusText; 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 { public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
get {
StringBuilder sb = new();
if(Status == UserStatus.Away) { public string LegacyNameWithStatus {
string statusText = StatusText.Trim(); get {
StringInfo sti = new(statusText); StringBuilder sb = new();
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.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); sb.AppendFormat("&lt;{0}&gt;_", statusText.ToUpperInvariant());
return sb.ToString();
} }
}
public bool Can(UserPermissions perm, bool strict = false) { sb.Append(LegacyName);
UserPermissions perms = Permissions & perm;
return strict ? perms == perm : perms > 0;
}
public bool NameEquals(string name) { return sb.ToString();
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}";
} }
} }
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 { namespace SharpChat;
public enum UserStatus {
Online, public enum UserStatus {
Away, Online,
Offline, Away,
} Offline,
} }

View file

@ -1,8 +1,8 @@
using System.Net; using System.Net;
namespace SharpChat.Auth { namespace SharpChat.Auth;
public interface AuthClient {
Task<AuthResult> AuthVerifyAsync(IPAddress remoteAddr, string scheme, string token); public interface AuthClient {
Task AuthBumpUsersOnlineAsync(IEnumerable<(IPAddress remoteAddr, string userId)> entries); 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 { namespace SharpChat.Auth;
public class AuthFailedException(string message) : Exception(message) {}
} public class AuthFailedException(string message) : Exception(message) {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
namespace SharpChat { 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";
public static ColourInheritable FromRaw(int? raw) => raw is null ? None : new(new ColourRgb(raw.Value)); public readonly record struct ColourInheritable(ColourRgb? rgb) {
public static ColourInheritable FromRgb(byte red, byte green, byte blue) => new(ColourRgb.FromRgb(red, green, blue)); public static readonly ColourInheritable None = new(null);
public override string ToString() => rgb.HasValue ? rgb.Value.ToString() : "inherit";
// these should go Away public static ColourInheritable FromRaw(int? raw) => raw is null ? None : new(new ColourRgb(raw.Value));
private const int MSZ_INHERIT = 0x40000000; public static ColourInheritable FromRgb(byte red, byte green, byte blue) => new(ColourRgb.FromRgb(red, green, blue));
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)); // 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 { namespace SharpChat;
public readonly record struct ColourRgb(int Raw) {
public byte Red => (byte)((Raw >> 16) & 0xFF); public readonly record struct ColourRgb(int Raw) {
public byte Green => (byte)((Raw >> 8) & 0xFF); public byte Red => (byte)((Raw >> 16) & 0xFF);
public byte Blue => (byte)(Raw & 0xFF); public byte Green => (byte)((Raw >> 8) & 0xFF);
public override string ToString() => string.Format("#{0:x2}{1:x2}{2:x2}", Red, Green, Blue); public byte Blue => (byte)(Raw & 0xFF);
public static ColourRgb FromRgb(byte red, byte green, byte blue) => new(red << 16 | green << 8 | blue); 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 { 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();
private object? CurrentValue { get; set; } = default(T); public class CachedValue<T>(Config config, string name, TimeSpan lifetime, T? fallback) {
private DateTimeOffset LastRead { get; set; } 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 { private object? CurrentValue { get; set; } = default(T);
get { private DateTimeOffset LastRead { get; set; }
lock(ConfigAccess) { // this lock doesn't really make sense since it doesn't affect other config calls
DateTimeOffset now = DateTimeOffset.Now; public T? Value {
if((now - LastRead) >= lifetime) { get {
LastRead = now; lock(ConfigAccess) { // this lock doesn't really make sense since it doesn't affect other config calls
CurrentValue = Config.ReadValue(Name, fallback); DateTimeOffset now = DateTimeOffset.Now;
Logger.Debug($"Read {Name} ({CurrentValue})"); if((now - LastRead) >= lifetime) {
} LastRead = now;
CurrentValue = Config.ReadValue(Name, fallback);
Logger.Debug($"Read {Name} ({CurrentValue})");
} }
return (T?)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;
} }
} }
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 { 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);
/// <summary> public interface Config : IDisposable {
/// Reads a raw (string) value from the config. /// <summary>
/// </summary> /// Creates a proxy object that forces all names to start with the given prefix.
string? ReadValue(string name, string? fallback = null); /// </summary>
Config ScopeTo(string prefix);
/// <summary> /// <summary>
/// Reads and casts value from the config. /// Reads a raw (string) value from the config.
/// </summary> /// </summary>
/// <exception cref="ConfigTypeException">Type conversion failed.</exception> string? ReadValue(string name, string? fallback = null);
T? ReadValue<T>(string name, T? fallback = default);
/// <summary> /// <summary>
/// Reads and casts a value from the config. Returns fallback when type conversion fails. /// Reads and casts value from the config.
/// </summary> /// </summary>
T? SafeReadValue<T>(string name, T? fallback); /// <exception cref="ConfigTypeException">Type conversion failed.</exception>
T? ReadValue<T>(string name, T? fallback = default);
/// <summary> /// <summary>
/// Creates an object that caches the read value for a certain amount of time, avoiding disk reads for frequently used non-static values. /// Reads and casts a value from the config. Returns fallback when type conversion fails.
/// </summary> /// </summary>
CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null); 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 { namespace SharpChat.Configuration;
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 abstract class ConfigException : Exception {
public class ConfigTypeException(Exception ex) : ConfigException("Given type does not match the value in the configuration.", ex) {} 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 { 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));
private string GetName(string name) { public class ScopedConfig(Config config, string prefix) : Config {
return Prefix + name; 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) { private string GetName(string name) {
return Config.ReadValue(GetName(name), fallback); return Prefix + name;
} }
public T? ReadValue<T>(string name, T? fallback = default) { public string? ReadValue(string name, string? fallback = null) {
return Config.ReadValue(GetName(name), fallback); return Config.ReadValue(GetName(name), fallback);
} }
public T? SafeReadValue<T>(string name, T? fallback) { public T? ReadValue<T>(string name, T? fallback = default) {
return Config.SafeReadValue(GetName(name), fallback); return Config.ReadValue(GetName(name), fallback);
} }
public Config ScopeTo(string prefix) { public T? SafeReadValue<T>(string name, T? fallback) {
return Config.ScopeTo(GetName(prefix)); return Config.SafeReadValue(GetName(name), fallback);
} }
public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) { public Config ScopeTo(string prefix) {
return Config.ReadCached(GetName(name), fallback, lifetime); return Config.ScopeTo(GetName(prefix));
} }
public void Dispose() { public CachedValue<T> ReadCached<T>(string name, T? fallback = default, TimeSpan? lifetime = null) {
GC.SuppressFinalize(this); return Config.ReadCached(GetName(name), fallback, lifetime);
} }
public void Dispose() {
GC.SuppressFinalize(this);
} }
} }

View file

@ -1,115 +1,115 @@
using System.Text; using System.Text;
namespace SharpChat.Configuration { namespace SharpChat.Configuration;
public class StreamConfig : Config {
private Stream Stream { get; }
private StreamReader StreamReader { get; }
private Mutex Lock { get; }
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) { private static readonly TimeSpan CACHE_LIFETIME = TimeSpan.FromMinutes(15);
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 static StreamConfig FromPath(string fileName) { public StreamConfig(Stream stream) {
return new StreamConfig(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite)); 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) { public static StreamConfig FromPath(string fileName) {
if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong return new StreamConfig(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite));
throw new ConfigLockException(); }
try { public string? ReadValue(string name, string? fallback = null) {
Stream.Seek(0, SeekOrigin.Begin); if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong
throw new ConfigLockException();
string? line; try {
while((line = StreamReader.ReadLine()) != null) { Stream.Seek(0, SeekOrigin.Begin);
if(string.IsNullOrWhiteSpace(line))
continue;
line = line.TrimStart(); string? line;
if(line.StartsWith(';') || line.StartsWith('#')) while((line = StreamReader.ReadLine()) != null) {
continue; if(string.IsNullOrWhiteSpace(line))
continue;
string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); line = line.TrimStart();
if(parts.Length < 2 || !string.Equals(parts[0], name)) if(line.StartsWith(';') || line.StartsWith('#'))
continue; continue;
return parts[1]; string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
} if(parts.Length < 2 || !string.Equals(parts[0], name))
} finally { continue;
Lock.ReleaseMutex();
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; 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) { try {
object? value = ReadValue(name); return (T)Convert.ChangeType(value, type);
if(value == null) } catch(InvalidCastException ex) {
return fallback; throw new ConfigTypeException(ex);
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();
} }
} }
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; using System.Text;
namespace SharpChat { namespace SharpChat;
public static class Logger {
public static void Write(string str) {
Console.WriteLine(string.Format("[{1}] {0}", str, DateTime.Now));
}
public static void Write(byte[] bytes) { public static class Logger {
Write(Encoding.UTF8.GetString(bytes)); public static void Write(string str) {
} Console.WriteLine(string.Format("[{1}] {0}", str, DateTime.Now));
}
public static void Write(object obj) { public static void Write(byte[] bytes) {
Write(obj?.ToString() ?? string.Empty); Write(Encoding.UTF8.GetString(bytes));
} }
[Conditional("DEBUG")] public static void Write(object obj) {
public static void Debug(string str) { Write(obj?.ToString() ?? string.Empty);
Write(str); }
}
[Conditional("DEBUG")] [Conditional("DEBUG")]
public static void Debug(byte[] bytes) { public static void Debug(string str) {
Write(bytes); Write(str);
} }
[Conditional("DEBUG")] [Conditional("DEBUG")]
public static void Debug(object obj) { public static void Debug(byte[] bytes) {
Write(obj); 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; using System.Security.Cryptography;
namespace SharpChat { namespace SharpChat;
public static class RNG {
public const string CHARS = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789";
private static Random NormalRandom { get; } = new(); public static class RNG {
private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create(); public const string CHARS = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789";
public static int Next() { private static Random NormalRandom { get; } = new();
return NormalRandom.Next(); private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create();
}
public static int Next(int max) { public static int Next() {
return NormalRandom.Next(max); return NormalRandom.Next();
} }
public static int Next(int min, int max) { public static int Next(int max) {
return NormalRandom.Next(min, 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); SecureRandom.GetBytes(buffer);
} num = BitConverter.ToUInt32(buffer);
public static int SecureNext() { if(umax != uint.MaxValue) {
return SecureNext(int.MaxValue); ++umax;
}
public static int SecureNext(int max) { if((umax & (umax - 1)) != 0) {
return SecureNext(0, max); uint limit = uint.MaxValue - (uint.MaxValue & umax) - 1;
}
public static int SecureNext(int min, int max) { while(num > limit) {
--max; SecureRandom.GetBytes(buffer);
if(min == max) num = BitConverter.ToUInt32(buffer);
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);
}
} }
} }
} finally {
ArrayPool<byte>.Shared.Return(buffer);
} }
} finally {
return (int)((num % umax) + min); ArrayPool<byte>.Shared.Return(buffer);
} }
private static string RandomStringInternal(Func<int, int> next, int length) { return (int)((num % umax) + min);
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 RandomString(int length) { private static string RandomStringInternal(Func<int, int> next, int length) {
return RandomStringInternal(Next, 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) { public static string RandomString(int length) {
return RandomStringInternal(SecureNext, length); return RandomStringInternal(Next, length);
} }
public static string SecureRandomString(int length) {
return RandomStringInternal(SecureNext, length);
} }
} }

View file

@ -1,35 +1,35 @@
namespace SharpChat { namespace SharpChat;
public class RateLimiter {
private readonly int Size;
private readonly int MinimumDelay;
private readonly int RiskyOffset;
private readonly long[] TimePoints;
public RateLimiter(int size, int minDelay, int riskyOffset = 0) { public class RateLimiter {
if(size < 2) private readonly int Size;
throw new ArgumentException("Size is too small.", nameof(size)); private readonly int MinimumDelay;
if(minDelay < 1000) private readonly int RiskyOffset;
throw new ArgumentException("Minimum delay is inhuman.", nameof(minDelay)); private readonly long[] TimePoints;
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));
}
Size = size; public RateLimiter(int size, int minDelay, int riskyOffset = 0) {
MinimumDelay = minDelay; if(size < 2)
RiskyOffset = riskyOffset; throw new ArgumentException("Size is too small.", nameof(size));
TimePoints = new long[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]; Size = size;
public bool IsExceeded => TimePoints[0] != 0 && TimePoints[1] != 0 && TimePoints[0] + MinimumDelay >= TimePoints[Size - 1]; MinimumDelay = minDelay;
RiskyOffset = riskyOffset;
TimePoints = new long[Size];
}
public void Update() { public bool IsRisky => TimePoints[RiskyOffset] != 0 && TimePoints[RiskyOffset + 1] != 0 && TimePoints[RiskyOffset] + MinimumDelay >= TimePoints[Size - 1];
for(int i = 1; i < Size; ++i) public bool IsExceeded => TimePoints[0] != 0 && TimePoints[1] != 0 && TimePoints[0] + MinimumDelay >= TimePoints[Size - 1];
TimePoints[i - 1] = TimePoints[i];
TimePoints[Size - 1] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); 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.Reflection;
using System.Text; using System.Text;
namespace SharpChat { namespace SharpChat;
public static class SharpInfo {
private const string NAME = @"SharpChat";
private const string UNKNOWN = @"XXXXXXXXXX";
public static string VersionString { get; } public static class SharpInfo {
public static string VersionStringShort { get; } private const string NAME = @"SharpChat";
public static bool IsDebugBuild { get; } 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 #if DEBUG
IsDebugBuild = true; IsDebugBuild = true;
#endif #endif
try { try {
using Stream s = Assembly.GetEntryAssembly()!.GetManifestResourceStream(@"SharpChat.version.txt")!; using Stream s = Assembly.GetEntryAssembly()!.GetManifestResourceStream(@"SharpChat.version.txt")!;
using StreamReader sr = new(s); using StreamReader sr = new(s);
VersionString = sr.ReadLine()!.Trim(); VersionString = sr.ReadLine()!.Trim();
VersionStringShort = VersionString.Length > 10 ? VersionString[..10] : VersionString; VersionStringShort = VersionString.Length > 10 ? VersionString[..10] : VersionString;
} catch { } catch {
VersionStringShort = VersionString = UNKNOWN; VersionStringShort = VersionString = UNKNOWN;
}
StringBuilder sb = new();
sb.Append(NAME);
sb.Append('/');
sb.Append(VersionStringShort);
ProgramName = sb.ToString();
} }
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 { namespace SharpChat.Snowflake;
public class RandomSnowflake(
SnowflakeGenerator? generator = null
) {
public readonly SnowflakeGenerator Generator = generator ?? new SnowflakeGenerator();
public long Next(DateTimeOffset? at = null) { public class RandomSnowflake(
return Generator.Next(Math.Abs(BitConverter.ToInt64(RandomNumberGenerator.GetBytes(8))), at); 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 { 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;
public readonly long Epoch; public class SnowflakeGenerator {
public readonly byte Shift; public const long MASK = 0x7FFFFFFFFFFFFFFF;
public readonly long TimestampMask; // previous default epoch was 1588377600000, but snowflakes are much larger than SharpIds
public readonly long SequenceMask; public const long EPOCH = 1356998400000;
public const byte SHIFT = 16;
public SnowflakeGenerator(long epoch = EPOCH, byte shift = SHIFT) { public readonly long Epoch;
if(epoch is < 0 or > MASK) public readonly byte Shift;
throw new ArgumentException("Epoch must be a positive int64.", nameof(epoch)); public readonly long TimestampMask;
if(shift is < 1 or > 63) public readonly long SequenceMask;
throw new ArgumentException("Shift must be between or equal to 1 and 63", nameof(shift));
Epoch = epoch; public SnowflakeGenerator(long epoch = EPOCH, byte shift = SHIFT) {
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 Epoch = epoch;
TimestampMask = ~(~0L << (63 - shift)); Shift = shift;
SequenceMask = ~(~0L << shift);
}
public long Now(DateTimeOffset? at = null) { // i think Index only does this as a hack for how integers work in PHP but its gonna run Once per application instance lol
return Math.Max(0, (at ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds() - Epoch); TimestampMask = ~(~0L << (63 - shift));
} SequenceMask = ~(~0L << shift);
}
public long Next(long sequence, DateTimeOffset? at = null) { public long Now(DateTimeOffset? at = null) {
return ((Now(at) & TimestampMask) << Shift) | (sequence & SequenceMask); 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 { namespace SharpChat;
[Flags]
public enum UserPermissions : int { [Flags]
KickUser = 0x00000001, public enum UserPermissions : int {
BanUser = 0x00000002, KickUser = 0x00000001,
//SilenceUser = 0x00000004, BanUser = 0x00000002,
Broadcast = 0x00000008, //SilenceUser = 0x00000004,
SetOwnNickname = 0x00000010, Broadcast = 0x00000008,
SetOthersNickname = 0x00000020, SetOwnNickname = 0x00000010,
CreateChannel = 0x00000040, SetOthersNickname = 0x00000020,
DeleteChannel = 0x00010000, CreateChannel = 0x00000040,
SetChannelPermanent = 0x00000080, DeleteChannel = 0x00010000,
SetChannelPassword = 0x00000100, SetChannelPermanent = 0x00000080,
SetChannelHierarchy = 0x00000200, SetChannelPassword = 0x00000100,
JoinAnyChannel = 0x00020000, SetChannelHierarchy = 0x00000200,
SendMessage = 0x00000400, JoinAnyChannel = 0x00020000,
DeleteOwnMessage = 0x00000800, SendMessage = 0x00000400,
DeleteAnyMessage = 0x00001000, DeleteOwnMessage = 0x00000800,
EditOwnMessage = 0x00002000, DeleteAnyMessage = 0x00001000,
EditAnyMessage = 0x00004000, EditOwnMessage = 0x00002000,
SeeIPAddress = 0x00008000, EditAnyMessage = 0x00004000,
ViewLogs = 0x00040000, SeeIPAddress = 0x00008000,
} ViewLogs = 0x00040000,
} }