using Microsoft.Extensions.Logging; using SharpChat.Auth; using SharpChat.Bans; using SharpChat.Configuration; using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; using ZLogger; namespace SharpChat.Flashii; public class FlashiiClient(ILogger logger, HttpClient httpClient, Config config) : AuthClient, BansClient { private const string DEFAULT_BASE_URL = "https://flashii.net/_sockchat"; private readonly CachedValue<string> BaseURL = config.ReadCached("url", DEFAULT_BASE_URL); private const string DEFAULT_SECRET_KEY = "woomy"; private readonly CachedValue<string> SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY); private string CreateStringSignature(string str) { return CreateBufferSignature(Encoding.UTF8.GetBytes(str)); } private string CreateBufferSignature(byte[] bytes) { using HMACSHA256 algo = new(Encoding.UTF8.GetBytes(SecretKey!)); return string.Concat(algo.ComputeHash(bytes).Select(c => c.ToString("x2"))); } private const string AUTH_VERIFY_URL = "{0}/verify"; private const string AUTH_VERIFY_SIG = "verify#{0}#{1}#{2}"; public async Task<AuthResult> AuthVerify(IPAddress remoteAddr, string scheme, string token) { logger.ZLogInformation($"Verifying authentication data for {remoteAddr}..."); logger.ZLogTrace($"AuthVerify({remoteAddr}, {scheme}, {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); logger.ZLogTrace($"AuthVerify() -> HTTP {response.StatusCode}"); 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 AuthBumpUsersOnline(IEnumerable<(IPAddress remoteAddr, string userId)> entries) { if(!entries.Any()) return; logger.ZLogInformation($"Bumping online users list..."); string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); StringBuilder sb = new(); sb.AppendFormat("bump#{0}", now); Dictionary<string, string> formData = new() { { "t", now }, }; foreach(var (remoteAddr, userId) in entries) { string remoteAddrStr = remoteAddr.ToString(); sb.AppendFormat("#{0}:{1}", userId, remoteAddrStr); formData.Add(string.Format("u[{0}]", userId), remoteAddrStr); } HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_BUMP_USERS_ONLINE_URL, BaseURL)) { Headers = { { "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) } }, Content = new FormUrlEncodedContent(formData), }; using HttpResponseMessage response = await httpClient.SendAsync(request); logger.ZLogTrace($"AuthBumpUsersOnline() -> HTTP {response.StatusCode}"); response.EnsureSuccessStatusCode(); } private const string BANS_CREATE_URL = "{0}/bans/create"; private const string BANS_CREATE_SIG = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}"; public async Task BanCreate( BanKind kind, TimeSpan duration, IPAddress remoteAddr, string? userId = null, string? reason = null, IPAddress? issuerRemoteAddr = null, string? issuerUserId = null ) { logger.ZLogInformation($"Creating ban of kind {kind} with duration {duration} for {remoteAddr}/{userId} issued by {issuerRemoteAddr}/{issuerUserId}..."); if(duration <= TimeSpan.Zero || kind != BanKind.User) return; issuerUserId ??= string.Empty; userId ??= string.Empty; reason ??= string.Empty; issuerRemoteAddr ??= IPAddress.IPv6None; string isPerma = duration == TimeSpan.MaxValue ? "1" : "0"; string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString(); string remoteAddrStr = remoteAddr.ToString(); string issuerRemoteAddrStr = issuerRemoteAddr.ToString(); string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); string sig = string.Format( BANS_CREATE_SIG, now, userId, remoteAddrStr, issuerUserId, issuerRemoteAddrStr, durationStr, isPerma, reason ); HttpRequestMessage request = new(HttpMethod.Post, string.Format(BANS_CREATE_URL, BaseURL)) { Headers = { { "X-SharpChat-Signature", CreateStringSignature(sig) }, }, Content = new FormUrlEncodedContent(new Dictionary<string, string> { { "t", now }, { "ui", userId }, { "ua", remoteAddrStr }, { "mi", issuerUserId }, { "ma", issuerRemoteAddrStr }, { "d", durationStr }, { "p", isPerma }, { "r", reason }, }), }; using HttpResponseMessage response = await httpClient.SendAsync(request); logger.ZLogTrace($"BanCreate() -> HTTP {response.StatusCode}"); response.EnsureSuccessStatusCode(); } private const string BANS_REVOKE_URL = "{0}/bans/revoke?t={1}&s={2}&x={3}"; private const string BANS_REVOKE_SIG = "revoke#{0}#{1}#{2}"; public async Task<bool> BanRevoke(BanInfo info) { string type; string target; if(info is UserBanInfo ubi) { if(info.Kind != BanKind.User) throw new ArgumentException("info argument is an instance of UserBanInfo but Kind was not set to BanKind.User", nameof(info)); type = "user"; target = ubi.UserId; } else if(info is IPAddressBanInfo iabi) { if(info.Kind != BanKind.IPAddress) throw new ArgumentException("info argument is an instance of IPAddressBanInfo but Kind was not set to BanKind.IPAddress", nameof(info)); type = "addr"; target = iabi.Address.ToString(); } else throw new ArgumentException("info argument is set to unsupported implementation", nameof(info)); logger.ZLogInformation($"Revoking ban of kind {info.Kind} issued on {target}..."); string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); string url = string.Format(BANS_REVOKE_URL, BaseURL, Uri.EscapeDataString(type), Uri.EscapeDataString(target), Uri.EscapeDataString(now)); string sig = string.Format(BANS_REVOKE_SIG, now, type, target); HttpRequestMessage request = new(HttpMethod.Delete, url) { Headers = { { "X-SharpChat-Signature", CreateStringSignature(sig) }, }, }; using HttpResponseMessage response = await httpClient.SendAsync(request); if(response.StatusCode == HttpStatusCode.NotFound) return false; logger.ZLogTrace($"BanRevoke() -> HTTP {response.StatusCode}"); response.EnsureSuccessStatusCode(); return response.StatusCode == HttpStatusCode.NoContent; } private const string BANS_CHECK_URL = "{0}/bans/check?u={1}&a={2}&x={3}&n={4}"; private const string BANS_CHECK_SIG = "check#{0}#{1}#{2}#{3}"; public async Task<BanInfo?> BanGet(string? userIdOrName = null, IPAddress? remoteAddr = null) { userIdOrName ??= "0"; remoteAddr ??= IPAddress.None; logger.ZLogInformation($"Requesting ban info for {remoteAddr}/{userIdOrName}..."); string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString(); bool usingUserName = string.IsNullOrEmpty(userIdOrName) || userIdOrName.Any(c => c is < '0' or > '9'); string remoteAddrStr = remoteAddr.ToString(); string usingUserNameStr = usingUserName ? "1" : "0"; string url = string.Format(BANS_CHECK_URL, BaseURL, Uri.EscapeDataString(userIdOrName), Uri.EscapeDataString(remoteAddrStr), Uri.EscapeDataString(now), Uri.EscapeDataString(usingUserNameStr)); string sig = string.Format(BANS_CHECK_SIG, now, userIdOrName, remoteAddrStr, usingUserNameStr); HttpRequestMessage request = new(HttpMethod.Get, url) { Headers = { { "X-SharpChat-Signature", CreateStringSignature(sig) }, }, }; using HttpResponseMessage response = await httpClient.SendAsync(request); logger.ZLogTrace($"BanGet() -> HTTP {response.StatusCode}"); 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[]> BanGetList() { logger.ZLogInformation($"Requesting ban list..."); 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); logger.ZLogTrace($"BanGetList() -> HTTP {response.StatusCode}"); 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)); })]; } }