diff --git a/CentCom.Common/Abstract/IRestBan.cs b/CentCom.Common/Abstract/IRestBan.cs index d0435eb..619f2a4 100644 --- a/CentCom.Common/Abstract/IRestBan.cs +++ b/CentCom.Common/Abstract/IRestBan.cs @@ -53,4 +53,9 @@ public interface IRestBan /// The list of job bans, if present /// public IReadOnlyList JobBans { get; } + + /// + /// The optional Round ID of the ban, if present + /// + public int? RoundId { get; } } \ No newline at end of file diff --git a/CentCom.Common/Models/Rest/RestBan.cs b/CentCom.Common/Models/Rest/RestBan.cs index 60864f9..ca8034c 100644 --- a/CentCom.Common/Models/Rest/RestBan.cs +++ b/CentCom.Common/Models/Rest/RestBan.cs @@ -14,5 +14,6 @@ public record RestBan string Reason, DateTimeOffset? Expires, ICKey UnbannedBy, - IReadOnlyList JobBans + IReadOnlyList JobBans, + int? RoundId ) : IRestBan; \ No newline at end of file diff --git a/CentCom.Exporter/CentCom.Exporter.csproj b/CentCom.Exporter/CentCom.Exporter.csproj index 3811cbb..8431b9d 100644 --- a/CentCom.Exporter/CentCom.Exporter.csproj +++ b/CentCom.Exporter/CentCom.Exporter.csproj @@ -7,13 +7,13 @@ - - - + + + - + diff --git a/CentCom.Exporter/Configuration/BanProviderKind.cs b/CentCom.Exporter/Configuration/BanProviderKind.cs index b968937..d26b826 100644 --- a/CentCom.Exporter/Configuration/BanProviderKind.cs +++ b/CentCom.Exporter/Configuration/BanProviderKind.cs @@ -8,5 +8,9 @@ public enum BanProviderKind /// /// Specifies using a /// - Tgstation + Tgstation, + /// + /// Specifies using a + /// + ParadiseSS13, } \ No newline at end of file diff --git a/CentCom.Exporter/Data/ExportedBan.cs b/CentCom.Exporter/Data/Ban/ExportedBan.cs similarity index 90% rename from CentCom.Exporter/Data/ExportedBan.cs rename to CentCom.Exporter/Data/Ban/ExportedBan.cs index 3aaa230..50d2ce9 100644 --- a/CentCom.Exporter/Data/ExportedBan.cs +++ b/CentCom.Exporter/Data/Ban/ExportedBan.cs @@ -3,7 +3,7 @@ using CentCom.Common.Models.Byond; using CentCom.Common.Models.Rest; -namespace CentCom.Exporter.Data; +namespace CentCom.Exporter.Data.Ban; /// /// Standard exported ban from a IBanProvider @@ -11,8 +11,7 @@ namespace CentCom.Exporter.Data; /// /// This class may require modification should another IBanProvider implementation necessitate it /// -public class ExportedBan -{ +public class ExportedBan { /// /// The database ID of the ban /// @@ -66,6 +65,11 @@ public class ExportedBan /// public CKey UnbannedBy { get; set; } + /// + /// The Round ID of the ban, if any + /// + public int? RoundId { get; set; } + public static implicit operator RestBan(ExportedBan ban) => new RestBan ( ban.Id, @@ -76,6 +80,7 @@ public class ExportedBan ban.Reason, ban.Expiration, ban.UnbannedBy, - ban.BanType == BanType.Job ? new[] { new RestJobBan(ban.Role) } : null + ban.BanType == BanType.Job ? new[] { new RestJobBan(ban.Role) } : null, + ban.RoundId ); } \ No newline at end of file diff --git a/CentCom.Exporter/Data/Ban/ParadiseExportedBan.cs b/CentCom.Exporter/Data/Ban/ParadiseExportedBan.cs new file mode 100644 index 0000000..6575d7c --- /dev/null +++ b/CentCom.Exporter/Data/Ban/ParadiseExportedBan.cs @@ -0,0 +1,29 @@ +using System; +using CentCom.Common.Models; +using CentCom.Common.Models.Byond; +using CentCom.Common.Models.Rest; + +namespace CentCom.Exporter.Data.Ban; +public class ParadiseExportedBan : ExportedBan { + public new BanType BanType => ((InternalBanType.Equals("PERMABAN") || InternalBanType.Equals("TEMPBAN")) ? BanType.Server : BanType.Job); + + /// + /// The internal type for this ban. + /// + public string InternalBanType { get; set; } + + // If you dont override this, stuff breaks + public static implicit operator RestBan(ParadiseExportedBan ban) => new RestBan + ( + ban.Id, + ban.BanType, + ban.CKey, + ban.BannedAt, + ban.BannedBy, + ban.Reason, + ban.Expiration, + ban.UnbannedBy, + ban.BanType == BanType.Job ? new[] { new RestJobBan(ban.Role) } : null, + ban.RoundId + ); +} diff --git a/CentCom.Exporter/Data/BanClusterer.cs b/CentCom.Exporter/Data/BanClusterer.cs deleted file mode 100644 index deb40e2..0000000 --- a/CentCom.Exporter/Data/BanClusterer.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using CentCom.Common.Abstract; -using CentCom.Common.Models; -using CentCom.Common.Models.Rest; - -namespace CentCom.Exporter.Data; - -/// -/// Provides utilities for clustering bans such that job bans are correctly grouped into one ban -/// -public static class BanClusterer -{ - /// - /// Clusters a collection of IRestBans to ensure that all job bans are grouped appropriately - /// - /// The bans to cluster - /// The collection of bans with all job bans clustered appropriately - /// - /// Job bans are considered to be in a group if the ckey, banning ckey, reason, - /// banned timestamp, expiration timestamp, and unbanned ckey are the same. - /// - public static IEnumerable ClusterBans(IEnumerable bans) - { - var clusteredBans = bans.Where(x => x.BanType == BanType.Server).ToList(); - clusteredBans.AddRange(bans.Where(x => x.BanType == BanType.Job).GroupBy(x => - new { x.CKey, x.BannedBy, x.Reason, x.BannedOn, x.Expires, x.UnbannedBy }).Select(group => - { - var ban = group.Last(); - return new RestBan(ban.Id, ban.BanType, ban.CKey, ban.BannedOn, ban.BannedBy, ban.Reason, ban.Expires, - ban.UnbannedBy, - group.SelectMany(j => j.JobBans) - .Select(j => j.Job) - .Distinct() - .Select(j => (IRestJobBan)new RestJobBan(j)).ToList()); - })); - return clusteredBans.OrderByDescending(x => x.Id); - } -} \ No newline at end of file diff --git a/CentCom.Exporter/Data/Clustering/IBanClusterer.cs b/CentCom.Exporter/Data/Clustering/IBanClusterer.cs new file mode 100644 index 0000000..1d71785 --- /dev/null +++ b/CentCom.Exporter/Data/Clustering/IBanClusterer.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Linq; +using CentCom.Common.Abstract; +using CentCom.Common.Models; +using CentCom.Common.Models.Rest; + +namespace CentCom.Exporter.Data.Clustering; + +/// +/// Provides utilities for clustering bans such that job bans are correctly grouped into one ban +/// +public interface IBanClusterer { + /// + /// Clusters a collection of IRestBans to ensure that all job bans are grouped appropriately + /// + /// The bans to cluster + /// The collection of bans with all job bans clustered appropriately + public IEnumerable ClusterBans(IEnumerable bans); +} diff --git a/CentCom.Exporter/Data/Clustering/ParadiseBanClusterer.cs b/CentCom.Exporter/Data/Clustering/ParadiseBanClusterer.cs new file mode 100644 index 0000000..8a31d5b --- /dev/null +++ b/CentCom.Exporter/Data/Clustering/ParadiseBanClusterer.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; +using CentCom.Common.Abstract; +using CentCom.Common.Models; +using CentCom.Common.Models.Rest; + +namespace CentCom.Exporter.Data.Clustering; + +public class ParadiseBanClusterer : IBanClusterer { + /// + /// + /// Job bans are considered to be in a group if the ckey, banning ckey, reason, + /// expiration timestamp, and unbanned ckey are the same. + /// + public IEnumerable ClusterBans(IEnumerable bans) { + var clusteredBans = bans.Where(x => x.BanType == BanType.Server).ToList(); + clusteredBans.AddRange(bans.Where(x => x.BanType == BanType.Job).GroupBy(x => + new { x.CKey, x.BannedBy, x.Reason, x.UnbannedBy, x.RoundId }).Select(group => { + var ban = group.Last(); + return new RestBan(ban.Id, ban.BanType, ban.CKey, ban.BannedOn, ban.BannedBy, ban.Reason, ban.Expires, + ban.UnbannedBy, + group.SelectMany(j => j.JobBans) + .Select(j => j.Job) + .Distinct() + .Select(j => (IRestJobBan)new RestJobBan(j)).ToList(), + ban.RoundId); + })); + return clusteredBans.OrderByDescending(x => x.Id); + } +} diff --git a/CentCom.Exporter/Data/Clustering/TgBanClusterer.cs b/CentCom.Exporter/Data/Clustering/TgBanClusterer.cs new file mode 100644 index 0000000..e51a912 --- /dev/null +++ b/CentCom.Exporter/Data/Clustering/TgBanClusterer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using CentCom.Common.Abstract; +using CentCom.Common.Models; +using CentCom.Common.Models.Rest; + +namespace CentCom.Exporter.Data.Clustering; + +public class TgBanClusterer : IBanClusterer { + /// + /// + /// Job bans are considered to be in a group if the ckey, banning ckey, reason, + /// banned timestamp, expiration timestamp, and unbanned ckey are the same. + /// + public IEnumerable ClusterBans(IEnumerable bans) { + var clusteredBans = bans.Where(x => x.BanType == BanType.Server).ToList(); + clusteredBans.AddRange(bans.Where(x => x.BanType == BanType.Job).GroupBy(x => + new { x.CKey, x.BannedBy, x.Reason, x.BannedOn, x.Expires, x.UnbannedBy }).Select(group => { + var ban = group.Last(); + return new RestBan(ban.Id, ban.BanType, ban.CKey, ban.BannedOn, ban.BannedBy, ban.Reason, ban.Expires, + ban.UnbannedBy, + group.SelectMany(j => j.JobBans) + .Select(j => j.Job) + .Distinct() + .Select(j => (IRestJobBan)new RestJobBan(j)).ToList(), null); + })); + return clusteredBans.OrderByDescending(x => x.Id); + } +} \ No newline at end of file diff --git a/CentCom.Exporter/Data/Providers/ParadiseBanProvider.cs b/CentCom.Exporter/Data/Providers/ParadiseBanProvider.cs new file mode 100644 index 0000000..0d204e9 --- /dev/null +++ b/CentCom.Exporter/Data/Providers/ParadiseBanProvider.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CentCom.Common.Abstract; +using CentCom.Common.Models; +using CentCom.Common.Models.Rest; +using CentCom.Exporter.Configuration; +using CentCom.Exporter.Data.Ban; +using CentCom.Exporter.Data.Clustering; +using Dapper; +using Microsoft.Extensions.Configuration; +using MySqlConnector; + +namespace CentCom.Exporter.Data.Providers; + +/// +/// Provides a generic implementation of a ban provider for all Paradise-derived ban databases. +/// +public class ParadiseBanProvider : IBanProvider { + private readonly string _connStr; + private readonly List _rawData; + private readonly ParadiseBanClusterer _clusterer; + + public ParadiseBanProvider(IConfiguration config) { + _connStr = config.GetConnectionString("provider"); + _rawData = new List(); + _clusterer = new ParadiseBanClusterer(); + } + + /// + public async Task> GetBansAsync(int? cursor, BanProviderOptions options) { + var result = new List(); + while (result.Count < options.Limit) { + var batchSize = await FetchMore(_rawData.Count > 0 ? _rawData[^1].Id : cursor, options); + + // Check for state not changing any further, indicates end of data + if (batchSize == 0) { + result = FilterRaw(true, options).ToList(); + break; + } + + // Determine if we have reached the end of the dataset + var belowLimit = batchSize < options.Limit; + + // Update result set + result = FilterRaw(belowLimit, options).ToList(); + } + + // Take up to the limit of retrieved records + return result.Take(Math.Min(result.Count, options.Limit)); + } + + /// + /// Retrieves bans from the database starting at a given cursor (ban ID) and moving in a descending ban ID order + /// + /// The optional ban ID to start at, NOT inclusive + /// The options to apply to this query + /// A collection of raw bans up to the limit for this page + private async Task FetchMore(int? cursor, BanProviderOptions options) { + const string query = @" + SELECT + b.id, + b.bantime AS BannedAt, + b.job AS Role, + b.expiration_time AS Expiration, + b.reason, + b.ckey, + b.a_ckey AS BannedBy, + b.unbanned_datetime AS UnbannedAt, + b.unbanned_ckey AS UnbannedBy, + b.ban_round_id AS RoundId, + b.bantype AS InternalBanType + FROM + ban b + WHERE + b.ckey IS NOT NULL + AND (@cursor IS NULL OR b.id < @cursor) + AND (@afterDate IS NULL OR b.bantime > @afterDate) + AND (@afterId IS NULL OR b.id > @afterId) + AND (@includeJobBans OR b.bantype IN ('PERMABAN', 'TEMPBAN')) + AND (@includeServerBans OR b.bantype IN ('JOB_PERMABAN', 'JOB_TEMPBAN')) + ORDER BY b.id DESC + LIMIT @limit"; + + await using var conn = GetConnection(); + var rawBans = await conn.QueryAsync(query, new { + cursor, + options.Limit, + options.AfterDate, + options.AfterId, + includeJobBans = options.JobBans != BanInclusionOption.None, + includeServerBans = options.ServerBans != BanInclusionOption.None + }); + + // Specify timestamps UTC where appropriate + if (!options.UseLocalTimezone) { + foreach (var ban in rawBans) { + ban.BannedAt = new DateTimeOffset(ban.BannedAt.Ticks, options.UtcOffset ?? TimeSpan.Zero); + if (ban.Expiration.HasValue) + ban.Expiration = new DateTimeOffset(ban.Expiration.Value.Ticks, + options.UtcOffset ?? TimeSpan.Zero); + if (ban.Unbanned.HasValue) + ban.Unbanned = new DateTimeOffset(ban.Unbanned.Value.Ticks, options.UtcOffset ?? TimeSpan.Zero); + } + } + + _rawData.AddRange(rawBans.Select(x => (RestBan)x)); + return rawBans?.Count() ?? 0; + } + + /// + /// Filters the collection of raw bans to be properly clustered and account for any possible 'hanging' job bans + /// at the end of the dataset + /// + /// If the last set of raw bans retrieved was below the configured limit for each page + /// The options for the ban source + /// + private IEnumerable FilterRaw(bool belowLimit, BanProviderOptions options) { + // Filter based on allowed ban types + var rawBans = _rawData.Where(x => JobBanFilter(x, options) || ServerBanFilter(x, options)); + + // Cluster bans to coalesce job bans as appropriate + var clustered = _clusterer.ClusterBans(rawBans); + + // Always remove the last ban if it is a jobban and we aren't at the limit of data, as it could be a partial result. + if (clustered.Any() && clustered.First().BanType == BanType.Job && !belowLimit) { + clustered = clustered.SkipLast(1); + } + + return clustered; + } + + /// + /// Determines if a provided ban should be filtered by the provided job ban options + /// + /// The ban to check + /// The options provided for the check + /// True if the ban should pass the filter, false if it failed + /// An invalid BanInclusionOption was provided + private static bool JobBanFilter(IRestBan ban, BanProviderOptions options) + => ban.BanType switch { + BanType.Server => false, + BanType.Job => options.JobBans switch { + BanInclusionOption.None => false, + BanInclusionOption.Temporary => ban.Expires.HasValue, + BanInclusionOption.Permanent => !ban.Expires.HasValue, + BanInclusionOption.All => true, + _ => throw new ArgumentOutOfRangeException() + }, + _ => throw new ArgumentOutOfRangeException() + }; + + /// + /// Determines if a provided ban should be filtered by the provided server ban options + /// + /// The ban to check + /// The options provided for the check + /// True if the ban should pass the filter, false if it failed + /// An invalid BanInclusionOption was provided + private static bool ServerBanFilter(IRestBan ban, BanProviderOptions options) + => ban.BanType switch { + BanType.Job => false, + BanType.Server => options.ServerBans switch { + BanInclusionOption.None => false, + BanInclusionOption.Temporary => ban.Expires.HasValue, + BanInclusionOption.Permanent => !ban.Expires.HasValue, + BanInclusionOption.All => true, + _ => throw new ArgumentOutOfRangeException() + }, + _ => throw new ArgumentOutOfRangeException() + }; + + /// + /// Creates a new database connection + /// + /// The created database connection + private MySqlConnection GetConnection() => new MySqlConnection(_connStr); +} \ No newline at end of file diff --git a/CentCom.Exporter/Data/Providers/TgBanProvider.cs b/CentCom.Exporter/Data/Providers/TgBanProvider.cs index 94e9d76..b390838 100644 --- a/CentCom.Exporter/Data/Providers/TgBanProvider.cs +++ b/CentCom.Exporter/Data/Providers/TgBanProvider.cs @@ -6,6 +6,8 @@ using CentCom.Common.Models; using CentCom.Common.Models.Rest; using CentCom.Exporter.Configuration; +using CentCom.Exporter.Data.Ban; +using CentCom.Exporter.Data.Clustering; using Dapper; using Microsoft.Extensions.Configuration; using MySqlConnector; @@ -19,11 +21,13 @@ public class TgBanProvider : IBanProvider { private readonly string _connStr; private readonly List _rawData; + private readonly TgBanClusterer _clusterer; public TgBanProvider(IConfiguration config) { _connStr = config.GetConnectionString("provider"); _rawData = new List(); + _clusterer = new TgBanClusterer(); } /// @@ -125,7 +129,7 @@ private IEnumerable FilterRaw(bool belowLimit, BanProviderOptions opti var rawBans = _rawData.Where(x => JobBanFilter(x, options) || ServerBanFilter(x, options)); // Cluster bans to coalesce job bans as appropriate - var clustered = BanClusterer.ClusterBans(rawBans); + var clustered = _clusterer.ClusterBans(rawBans); // Always remove the last ban if it is a jobban and we aren't at the limit of data, as it could be a partial result. if (clustered.Any() && clustered.First().BanType == BanType.Job && !belowLimit) diff --git a/CentCom.Exporter/README.md b/CentCom.Exporter/README.md index 4b9fe95..d0cf329 100644 --- a/CentCom.Exporter/README.md +++ b/CentCom.Exporter/README.md @@ -18,6 +18,7 @@ This application currently supports the following codebases, and their downstrea ban database formats: - [/tg/station](Data/Providers/TgBanProvider.cs) +- [ParadiseSS13](Data/Providers/ParadiseBanProvider.cs) Don't see a codebase that applies to you? Feel free to provide your own implementation of [IBanProvider](Data/Providers/IBanProvider.cs) in a PR, or request that bobbahbrown#0001 provide one. diff --git a/CentCom.Exporter/Startup.cs b/CentCom.Exporter/Startup.cs index b18c129..0c8a4d0 100644 --- a/CentCom.Exporter/Startup.cs +++ b/CentCom.Exporter/Startup.cs @@ -42,6 +42,9 @@ public void ConfigureServices(IServiceCollection services) case BanProviderKind.Tgstation: services.AddTransient(); break; + case BanProviderKind.ParadiseSS13: + services.AddTransient(); + break; default: throw new ArgumentOutOfRangeException(); } diff --git a/CentCom.Test/RestBanTests.cs b/CentCom.Test/RestBanTests.cs index d5943c4..e5f93f4 100644 --- a/CentCom.Test/RestBanTests.cs +++ b/CentCom.Test/RestBanTests.cs @@ -25,7 +25,8 @@ public void CanCreateBan() "Test ban please ignore", null, null, - new[] { new RestJobBan("Janitor") }); + new[] { new RestJobBan("Janitor") }, + null); Assert.NotNull(ban); } @@ -41,7 +42,8 @@ public void CanSerializeBan() "Test ban please ignore", null, null, - new[] { new RestJobBan("Janitor") }); + new[] { new RestJobBan("Janitor") }, + null); var options = GetOptions(); var serialized = JsonSerializer.Serialize(ban, options);