Ported the config system from old master.

This commit is contained in:
flash 2023-02-09 00:53:42 +01:00
parent 56a818254e
commit e1e3def62c
23 changed files with 814 additions and 438 deletions

2
.gitignore vendored
View File

@ -7,6 +7,8 @@ login_key.txt
http-motd.txt
_webdb.txt
msz_url.txt
sharpchat.cfg
SharpChat/version.txt
# User-specific files
*.suo

View File

@ -31,13 +31,13 @@ namespace SharpChat {
UserLeave(user.Channel, user, reason);
}
public void HandleJoin(ChatUser user, ChatChannel chan, ChatUserSession sess) {
public void HandleJoin(ChatUser user, ChatChannel chan, ChatUserSession sess, int maxMsgLength) {
if(!chan.HasUser(user)) {
chan.Send(new UserConnectPacket(DateTimeOffset.Now, user));
Events.Add(new UserConnectEvent(DateTimeOffset.Now, user, chan));
}
sess.Send(new AuthSuccessPacket(user, chan, sess));
sess.Send(new AuthSuccessPacket(user, chan, sess, maxMsgLength));
sess.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);

View File

@ -1,4 +1,4 @@
using SharpChat.Flashii;
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
@ -120,12 +120,12 @@ namespace SharpChat {
public ChatUser() {
}
public ChatUser(FlashiiAuthInfo auth) {
public ChatUser(MisuzuAuthInfo auth) {
UserId = auth.UserId;
ApplyAuth(auth, true);
}
public void ApplyAuth(FlashiiAuthInfo auth, bool invalidateRestrictions = false) {
public void ApplyAuth(MisuzuAuthInfo auth, bool invalidateRestrictions = false) {
Username = auth.Username;
if(Status == ChatUserStatus.Offline)

View File

@ -0,0 +1,49 @@
using System;
namespace SharpChat.Config {
public class CachedValue<T> {
private IConfig Config { get; }
private string Name { get; }
private TimeSpan Lifetime { get; }
private T Fallback { get; }
private object Sync { get; } = new();
private object CurrentValue { get; set; }
private DateTimeOffset LastRead { get; set; }
public T Value {
get {
lock(Sync) {
DateTimeOffset now = DateTimeOffset.Now;
if((now - LastRead) >= Lifetime) {
LastRead = now;
CurrentValue = Config.ReadValue(Name, Fallback);
Logger.Debug($"Read {Name} ({CurrentValue})");
}
}
return (T)CurrentValue;
}
}
public static implicit operator T(CachedValue<T> val) => val.Value;
public CachedValue(IConfig config, string name, TimeSpan lifetime, T fallback) {
Config = config ?? throw new ArgumentNullException(nameof(config));
Name = name ?? throw new ArgumentNullException(nameof(name));
Lifetime = lifetime;
Fallback = fallback;
if(string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be empty.", nameof(name));
}
public void Refresh() {
lock(Sync) {
LastRead = DateTimeOffset.MinValue;
}
}
public override string ToString() {
return Value.ToString();
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,45 @@
using System;
namespace SharpChat.Config {
public class ScopedConfig : IConfig {
private IConfig Config { get; }
private string Prefix { get; }
public ScopedConfig(IConfig config, string prefix) {
Config = config ?? throw new ArgumentNullException(nameof(config));
Prefix = prefix ?? throw new ArgumentNullException(nameof(prefix));
if(string.IsNullOrWhiteSpace(prefix))
throw new ArgumentException("Prefix must exist.", nameof(prefix));
if(Prefix[^1] != ':')
Prefix += ':';
}
private string GetName(string name) {
return Prefix + name;
}
public string ReadValue(string name, string fallback = null) {
return Config.ReadValue(GetName(name), fallback);
}
public T ReadValue<T>(string name, T fallback = default) {
return Config.ReadValue(GetName(name), fallback);
}
public T SafeReadValue<T>(string name, T fallback) {
return Config.SafeReadValue(GetName(name), fallback);
}
public IConfig ScopeTo(string prefix) {
return Config.ScopeTo(GetName(prefix));
}
public CachedValue<T> ReadCached<T>(string name, T fallback = default, TimeSpan? lifetime = null) {
return Config.ReadCached(GetName(name), fallback, lifetime);
}
public void Dispose() {
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
namespace SharpChat.Config {
public class StreamConfig : IConfig {
private Stream Stream { get; }
private StreamReader StreamReader { get; }
private Mutex Lock { get; }
private const int LOCK_TIMEOUT = 10000;
private static readonly TimeSpan CACHE_LIFETIME = TimeSpan.FromMinutes(15);
public StreamConfig(string fileName)
: this(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite)) { }
public StreamConfig(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
if(!Stream.CanRead)
throw new ArgumentException("Provided stream must be readable.", nameof(stream));
if(!Stream.CanSeek)
throw new ArgumentException("Provided stream must be seekable.", nameof(stream));
StreamReader = new StreamReader(stream, new UTF8Encoding(false), false);
Lock = new Mutex();
}
public string ReadValue(string name, string fallback = null) {
if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong
throw new ConfigLockException();
try {
Stream.Seek(0, SeekOrigin.Begin);
string line;
while((line = StreamReader.ReadLine()) != null) {
if(string.IsNullOrWhiteSpace(line))
continue;
line = line.TrimStart();
if(line.StartsWith(";") || line.StartsWith("#"))
continue;
string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if(parts.Length < 2 || !string.Equals(parts[0], name))
continue;
return parts[1];
}
} finally {
Lock.ReleaseMutex();
}
return fallback;
}
public T ReadValue<T>(string name, T fallback = default) {
object value = ReadValue(name);
if(value == null)
return fallback;
Type type = typeof(T);
if(value is string strVal) {
if(type == typeof(bool))
value = !string.Equals(strVal, "0", StringComparison.InvariantCultureIgnoreCase)
&& !string.Equals(strVal, "false", StringComparison.InvariantCultureIgnoreCase);
else if(type == typeof(string[]))
value = strVal.Split(' ');
}
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 IConfig ScopeTo(string 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,8 +1,8 @@
using MySqlConnector;
using SharpChat.Config;
using SharpChat.Events;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
@ -13,13 +13,13 @@ namespace SharpChat {
public static bool HasDatabase
=> !string.IsNullOrWhiteSpace(ConnectionString);
public static void ReadConfig() {
if(!File.Exists("mariadb.txt"))
return;
string[] config = File.ReadAllLines("mariadb.txt");
if(config.Length < 4)
return;
Init(config[0], config[1], config[2], config[3]);
public static void Init(IConfig config) {
Init(
config.ReadValue("host", "localhost"),
config.ReadValue("user", string.Empty),
config.ReadValue("pass", string.Empty),
config.ReadValue("db", "sharpchat")
);
}
public static void Init(string host, string username, string password, string database) {

View File

@ -1,28 +1,7 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text;
namespace SharpChat {
public static class Extensions {
public static string GetSignedHash(this string str, string key = null) {
return Encoding.UTF8.GetBytes(str).GetSignedHash(key);
}
public static string GetSignedHash(this byte[] bytes, string key = null) {
key ??= File.Exists("login_key.txt") ? File.ReadAllText("login_key.txt") : "woomy";
StringBuilder sb = new();
using(HMACSHA256 algo = new(Encoding.UTF8.GetBytes(key))) {
byte[] hash = algo.ComputeHash(bytes);
foreach(byte b in hash)
sb.AppendFormat("{0:x2}", b);
}
return sb.ToString();
}
public static string GetIdString(this byte[] buffer) {
const string id_chars = "abcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZ";
StringBuilder sb = new();

View File

@ -1,64 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace SharpChat.Flashii {
public class FlashiiAuthInfo {
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = "none";
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; }
[JsonPropertyName("colour_raw")]
public int ColourRaw { get; set; }
[JsonPropertyName("hierarchy")]
public int Rank { get; set; }
[JsonPropertyName("is_silenced")]
public DateTimeOffset SilencedUntil { get; set; }
[JsonPropertyName("perms")]
public ChatUserPermissions Permissions { get; set; }
private const string SIG_FMT = "verify#{0}#{1}#{2}";
public static async Task<FlashiiAuthInfo> VerifyAsync(HttpClient client, string method, string token, string ipAddr) {
if(client == null)
throw new ArgumentNullException(nameof(client));
method ??= string.Empty;
token ??= string.Empty;
ipAddr ??= string.Empty;
string sig = string.Format(SIG_FMT, method, token, ipAddr);
HttpRequestMessage req = new(HttpMethod.Post, FlashiiUrls.VerifyURL) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "method", method },
{ "token", token },
{ "ipaddr", ipAddr },
}),
Headers = {
{ "X-SharpChat-Signature", sig.GetSignedHash() },
},
};
using HttpResponseMessage res = await client.SendAsync(req);
return JsonSerializer.Deserialize<FlashiiAuthInfo>(
await res.Content.ReadAsByteArrayAsync()
);
}
}
}

View File

@ -1,183 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace SharpChat.Flashii {
public class FlashiiBanInfo {
[JsonPropertyName("is_ban")]
public bool IsBanned { get; set; }
[JsonPropertyName("user_id")]
public string UserId { get; set; }
[JsonPropertyName("ip_addr")]
public string RemoteAddress { get; set; }
[JsonPropertyName("is_perma")]
public bool IsPermanent { get; set; }
[JsonPropertyName("expires")]
public DateTimeOffset ExpiresAt { get; set; }
// only populated in list request
[JsonPropertyName("user_name")]
public string UserName { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
public ChatColour UserColour => ChatColour.FromMisuzu(UserColourRaw);
private const string CHECK_SIG_FMT = "check#{0}#{1}#{2}#{3}";
private const string REVOKE_SIG_FMT = "revoke#{0}#{1}#{2}";
private const string CREATE_SIG_FMT = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}";
private const string LIST_SIG_FMT = "list#{0}";
public static async Task<FlashiiBanInfo> CheckAsync(HttpClient client, string userId = null, string ipAddr = null, bool userIdIsName = false) {
if(client == null)
throw new ArgumentNullException(nameof(client));
userId ??= string.Empty;
ipAddr ??= string.Empty;
string userIdIsNameStr = userIdIsName ? "1" : "0";
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(FlashiiUrls.BansCheckURL, Uri.EscapeDataString(userId), Uri.EscapeDataString(ipAddr), Uri.EscapeDataString(now), Uri.EscapeDataString(userIdIsNameStr));
string sig = string.Format(CHECK_SIG_FMT, now, userId, ipAddr, userIdIsNameStr);
HttpRequestMessage req = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", sig.GetSignedHash() },
},
};
using HttpResponseMessage res = await client.SendAsync(req);
return JsonSerializer.Deserialize<FlashiiBanInfo>(
await res.Content.ReadAsByteArrayAsync()
);
}
public static async Task<FlashiiBanInfo[]> GetListAsync(HttpClient client) {
if(client == null)
throw new ArgumentNullException(nameof(client));
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(FlashiiUrls.BansListURL, Uri.EscapeDataString(now));
string sig = string.Format(LIST_SIG_FMT, now);
HttpRequestMessage req = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", sig.GetSignedHash() },
},
};
using HttpResponseMessage res = await client.SendAsync(req);
return JsonSerializer.Deserialize<FlashiiBanInfo[]>(
await res.Content.ReadAsByteArrayAsync()
);
}
public enum RevokeKind {
UserId,
RemoteAddress,
}
public async Task<bool> RevokeAsync(HttpClient client, RevokeKind kind) {
if(client == null)
throw new ArgumentNullException(nameof(client));
string type = kind switch {
RevokeKind.UserId => "user",
RevokeKind.RemoteAddress => "addr",
_ => throw new ArgumentException("Invalid kind specified.", nameof(kind)),
};
string target = kind switch {
RevokeKind.UserId => UserId,
RevokeKind.RemoteAddress => RemoteAddress,
_ => string.Empty,
};
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(FlashiiUrls.BansRevokeURL, Uri.EscapeDataString(type), Uri.EscapeDataString(target), Uri.EscapeDataString(now));
string sig = string.Format(REVOKE_SIG_FMT, now, type, target);
HttpRequestMessage req = new(HttpMethod.Delete, url) {
Headers = {
{ "X-SharpChat-Signature", sig.GetSignedHash() },
},
};
using HttpResponseMessage res = await client.SendAsync(req);
if(res.StatusCode == HttpStatusCode.NotFound)
return false;
res.EnsureSuccessStatusCode();
return res.StatusCode == HttpStatusCode.NoContent;
}
public static async Task CreateAsync(
HttpClient client,
string targetId,
string targetAddr,
string modId,
string modAddr,
TimeSpan duration,
string reason
) {
if(client == null)
throw new ArgumentNullException(nameof(client));
if(string.IsNullOrWhiteSpace(targetAddr))
throw new ArgumentNullException(nameof(targetAddr));
if(string.IsNullOrWhiteSpace(modAddr))
throw new ArgumentNullException(nameof(modAddr));
if(duration <= TimeSpan.Zero)
return;
modId ??= string.Empty;
targetId ??= string.Empty;
reason ??= string.Empty;
string isPerma = duration == TimeSpan.MaxValue ? "1" : "0";
string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString();
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string sig = string.Format(
CREATE_SIG_FMT,
now, targetId, targetAddr,
modId, modAddr,
durationStr, isPerma, reason
);
HttpRequestMessage req = new(HttpMethod.Post, FlashiiUrls.BansCreateURL) {
Headers = {
{ "X-SharpChat-Signature", sig.GetSignedHash() },
},
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "t", now },
{ "ui", targetId },
{ "ua", targetAddr },
{ "mi", modId },
{ "ma", modAddr },
{ "d", durationStr },
{ "p", isPerma },
{ "r", reason },
}),
};
using HttpResponseMessage res = await client.SendAsync(req);
res.EnsureSuccessStatusCode();
}
}
}

View File

@ -1,92 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace SharpChat.Flashii {
public static class FlashiiUrls {
private const string BASE_URL_FILE = "msz_url.txt";
private const string BASE_URL_FALLBACK = "https://flashii.net";
private const string BUMP = "/_sockchat/bump";
private const string VERIFY = "/_sockchat/verify";
private const string BANS_CHECK = "/_sockchat/bans/check?u={0}&a={1}&x={2}&n={3}";
private const string BANS_CREATE = "/_sockchat/bans/create";
private const string BANS_REVOKE = "/_sockchat/bans/revoke?t={0}&s={1}&x={2}";
private const string BANS_LIST = "/_sockchat/bans/list?x={0}";
public static string BumpURL { get; }
public static string VerifyURL { get; }
public static string BansCheckURL { get; }
public static string BansCreateURL { get; }
public static string BansRevokeURL { get; }
public static string BansListURL { get; }
static FlashiiUrls() {
BumpURL = GetURL(BUMP);
VerifyURL = GetURL(VERIFY);
BansCheckURL = GetURL(BANS_CHECK);
BansCreateURL = GetURL(BANS_CREATE);
BansRevokeURL = GetURL(BANS_REVOKE);
BansListURL = GetURL(BANS_LIST);
}
public static string GetBaseURL() {
if(!File.Exists(BASE_URL_FILE))
return BASE_URL_FALLBACK;
string url = File.ReadAllText(BASE_URL_FILE).Trim().Trim('/');
if(string.IsNullOrEmpty(url))
return BASE_URL_FALLBACK;
return url;
}
public static string GetURL(string path) {
return GetBaseURL() + path;
}
public static async Task BumpUsersOnlineAsync(HttpClient client, IEnumerable<(string userId, string ipAddr)> list) {
if(client == null)
throw new ArgumentNullException(nameof(client));
if(list == null)
throw new ArgumentNullException(nameof(list));
if(!list.Any())
return;
string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
StringBuilder sb = new();
sb.AppendFormat("bump#{0}", now);
Dictionary<string, string> formData = new() {
{ "t", now }
};
foreach(var (userId, ipAddr) in list) {
sb.AppendFormat("#{0}:{1}", userId, ipAddr);
formData.Add(string.Format("u[{0}]", userId), ipAddr);
}
HttpRequestMessage req = new(HttpMethod.Post, BumpURL) {
Headers = {
{ "X-SharpChat-Signature", sb.ToString().GetSignedHash() }
},
Content = new FormUrlEncodedContent(formData),
};
using HttpResponseMessage res = await client.SendAsync(req);
try {
res.EnsureSuccessStatusCode();
} catch(HttpRequestException) {
Logger.Debug(await res.Content.ReadAsStringAsync());
#if DEBUG
throw;
#endif
}
}
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Misuzu {
public class MisuzuAuthInfo {
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = "none";
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; }
[JsonPropertyName("colour_raw")]
public int ColourRaw { get; set; }
[JsonPropertyName("hierarchy")]
public int Rank { get; set; }
[JsonPropertyName("is_silenced")]
public DateTimeOffset SilencedUntil { get; set; }
[JsonPropertyName("perms")]
public ChatUserPermissions Permissions { get; set; }
}
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
using SharpChat.Flashii;
using SharpChat.Misuzu;
using System;
using System.Collections.Generic;
using System.Text;
@ -12,9 +12,9 @@ namespace SharpChat.Packet {
public class AuthFailPacket : ServerPacket {
public AuthFailReason Reason { get; private set; }
public FlashiiBanInfo BanInfo { get; private set; }
public MisuzuBanInfo BanInfo { get; private set; }
public AuthFailPacket(AuthFailReason reason, FlashiiBanInfo fbi = null) {
public AuthFailPacket(AuthFailReason reason, MisuzuBanInfo fbi = null) {
Reason = reason;
if(reason == AuthFailReason.Banned)

View File

@ -7,11 +7,18 @@ namespace SharpChat.Packet {
public ChatUser User { get; private set; }
public ChatChannel Channel { get; private set; }
public ChatUserSession Session { get; private set; }
public int MaxMessageLength { get; private set; }
public AuthSuccessPacket(ChatUser user, ChatChannel channel, ChatUserSession sess) {
public AuthSuccessPacket(
ChatUser user,
ChatChannel channel,
ChatUserSession sess,
int maxMsgLength
) {
User = user ?? throw new ArgumentNullException(nameof(user));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
Session = sess ?? throw new ArgumentNullException(nameof(channel));
MaxMessageLength = maxMsgLength;
}
public override IEnumerable<string> Pack() {
@ -23,7 +30,7 @@ namespace SharpChat.Packet {
sb.Append('\t');
sb.Append(Channel.Name);
sb.Append('\t');
sb.Append(SockChatServer.MSG_LENGTH_MAX);
sb.Append(MaxMessageLength);
return new[] { sb.ToString() };
}

View File

@ -1,4 +1,4 @@
using SharpChat.Flashii;
using SharpChat.Misuzu;
using System;
using System.Collections.Generic;
using System.Linq;
@ -6,9 +6,9 @@ using System.Text;
namespace SharpChat.Packet {
public class BanListPacket : ServerPacket {
public IEnumerable<FlashiiBanInfo> Bans { get; private set; }
public IEnumerable<MisuzuBanInfo> Bans { get; private set; }
public BanListPacket(IEnumerable<FlashiiBanInfo> bans) {
public BanListPacket(IEnumerable<MisuzuBanInfo> bans) {
Bans = bans ?? throw new ArgumentNullException(nameof(bans));
}
@ -20,7 +20,7 @@ namespace SharpChat.Packet {
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fbanlist\f");
foreach(FlashiiBanInfo ban in Bans) {
foreach(MisuzuBanInfo ban in Bans) {
string banStr = string.IsNullOrEmpty(ban.UserName) ? ban.RemoteAddress : ban.UserName;
sb.AppendFormat(@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('/unban '+ this.innerHTML);"">{0}</a>, ", banStr);
}

View File

@ -1,10 +1,14 @@
using System;
using SharpChat.Config;
using SharpChat.Misuzu;
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading;
namespace SharpChat {
public class Program {
public const ushort PORT = 6770;
public const string CONFIG = "sharpchat.cfg";
public static void Main(string[] args) {
Console.WriteLine(@" _____ __ ________ __ ");
@ -12,12 +16,14 @@ namespace SharpChat {
Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/");
Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ ");
Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ ");
Console.WriteLine(@" / _/ Sock Chat Server");
#if DEBUG
Console.WriteLine(@"============================================ DEBUG ==");
#endif
Database.ReadConfig();
/**/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);
bool hasCancelled = false;
@ -32,17 +38,105 @@ namespace SharpChat {
if(hasCancelled) return;
using HttpClient httpClient = new(new HttpClientHandler() {
UseProxy = false, // we will never and the initial resolving takes forever on linux
});
httpClient.DefaultRequestHeaders.Add("User-Agent", "SharpChat/20230206");
string configFile = CONFIG;
// If the config file doesn't exist and we're using the default path, run the converter
if(!File.Exists(configFile) && configFile == CONFIG)
ConvertConfiguration();
using IConfig config = new StreamConfig(configFile);
Database.Init(config.ScopeTo("mariadb"));
if(hasCancelled) return;
using SockChatServer scs = new(httpClient, PORT);
using HttpClient httpClient = new(new HttpClientHandler() {
UseProxy = false,
});
httpClient.DefaultRequestHeaders.Add("User-Agent", SharpInfo.ProgramName);
if(hasCancelled) return;
MisuzuClient msz = new(httpClient, config.ScopeTo("msz"));
if(hasCancelled) return;
using SockChatServer scs = new(httpClient, msz, config.ScopeTo("chat"));
scs.Listen(mre);
mre.WaitOne();
}
private static void ConvertConfiguration() {
using Stream s = new FileStream(CONFIG, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
s.SetLength(0);
s.Flush();
using StreamWriter sw = new(s, new UTF8Encoding(false));
sw.WriteLine("# and ; can be used at the start of a line for comments.");
sw.WriteLine();
sw.WriteLine("# General Configuration");
sw.WriteLine($"#chat:port {SockChatServer.DEFAULT_PORT}");
sw.WriteLine($"#chat:msgMaxLength {SockChatServer.DEFAULT_MSG_LENGTH_MAX}");
sw.WriteLine($"#chat:connMaxCount {SockChatServer.DEFAULT_MAX_CONNECTIONS}");
sw.WriteLine($"#chat:floodKickLength {SockChatServer.DEFAULT_FLOOD_KICK_LENGTH}");
sw.WriteLine();
sw.WriteLine("# Channels");
sw.WriteLine("chat:channels lounge staff");
sw.WriteLine();
sw.WriteLine("# Lounge channel settings");
sw.WriteLine("chat:channels:lounge:name Lounge");
sw.WriteLine("chat:channels:lounge:autoJoin true");
sw.WriteLine();
sw.WriteLine("# Staff channel settings");
sw.WriteLine("chat:channels:staff:name Staff");
sw.WriteLine("chat:channels:staff:minRank 5");
const string msz_secret = "login_key.txt";
const string msz_url = "msz_url.txt";
sw.WriteLine();
sw.WriteLine("# Misuzu integration settings");
if(File.Exists(msz_secret))
sw.WriteLine(string.Format("msz:secret {0}", File.ReadAllText(msz_secret).Trim()));
else
sw.WriteLine("#msz:secret woomy");
if(File.Exists(msz_url))
sw.WriteLine(string.Format("msz:url {0}/_sockchat", File.ReadAllText(msz_url).Trim()));
else
sw.WriteLine("#msz:url https://flashii.net/_sockchat");
const string mdb_config = @"mariadb.txt";
bool hasMDB = File.Exists(mdb_config);
string[] mdbCfg = File.Exists(mdb_config) ? File.ReadAllLines(mdb_config) : Array.Empty<string>();
sw.WriteLine();
sw.WriteLine("# MariaDB configuration");
if(!string.IsNullOrWhiteSpace(mdbCfg[0]))
sw.WriteLine($"mariadb:host {mdbCfg[0]}");
else
sw.WriteLine($"#mariadb:host <username>");
if(mdbCfg.Length > 1)
sw.WriteLine($"mariadb:user {mdbCfg[1]}");
else
sw.WriteLine($"#mariadb:user <username>");
if(mdbCfg.Length > 2)
sw.WriteLine($"mariadb:pass {mdbCfg[2]}");
else
sw.WriteLine($"#mariadb:pass <password>");
if(mdbCfg.Length > 3)
sw.WriteLine($"mariadb:db {mdbCfg[3]}");
else
sw.WriteLine($"#mariadb:db <database>");
sw.Flush();
}
}
}

View File

@ -10,4 +10,16 @@
<PackageReference Include="MySqlConnector" Version="2.2.5" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="git describe --tags --abbrev=0 --always &gt; version.txt" />
</Target>
<ItemGroup>
<None Remove="version.txt" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="version.txt" />
</ItemGroup>
</Project>

37
SharpChat/SharpInfo.cs Normal file
View File

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

View File

@ -1,7 +1,8 @@
using Fleck;
using SharpChat.Commands;
using SharpChat.Config;
using SharpChat.Events;
using SharpChat.Flashii;
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
@ -15,15 +16,10 @@ using System.Threading.Tasks;
namespace SharpChat {
public class SockChatServer : IDisposable {
public const int MSG_LENGTH_MAX = 5000;
#if DEBUG
public const int MAX_CONNECTIONS = 9001;
public const int FLOOD_KICK_LENGTH = 5;
#else
public const int MAX_CONNECTIONS = 5;
public const int FLOOD_KICK_LENGTH = 30;
#endif
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 bool IsDisposed { get; private set; }
@ -38,6 +34,11 @@ namespace SharpChat {
public ChatContext Context { get; }
private readonly HttpClient HttpClient;
private readonly MisuzuClient Misuzu;
private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections;
private readonly CachedValue<int> FloodKickLength;
private IReadOnlyCollection<IChatCommand> Commands { get; } = new IChatCommand[] {
new AFKCommand(),
@ -54,22 +55,36 @@ namespace SharpChat {
private ManualResetEvent Shutdown { get; set; }
private bool IsShuttingDown = false;
public SockChatServer(HttpClient httpClient, ushort port) {
Logger.Write("Starting Sock Chat server...");
public SockChatServer(HttpClient httpClient, MisuzuClient msz, IConfig config) {
Logger.Write("Initialising Sock Chat server...");
HttpClient = httpClient;
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
Context = new ChatContext();