/* TShock, a server mod for Terraria Copyright (C) 2011-2019 Pryaxis & TShock Contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ using System; using System.Linq; using System.Collections.Generic; using System.Data; using MySql.Data.MySqlClient; using System.Collections.ObjectModel; using TShockAPI.DB.Queries; namespace TShockAPI.DB { /// /// Class that manages bans. /// public class BanManager { private IDbConnection database; private Dictionary _bans; /// /// Readonly dictionary of Bans, keyed on ban ticket number. /// public ReadOnlyDictionary Bans => new ReadOnlyDictionary(_bans); /// /// Event invoked when a ban is checked for validity /// public static event EventHandler OnBanValidate; /// /// Event invoked before a ban is added /// public static event EventHandler OnBanPreAdd; /// /// Event invoked after a ban is added /// public static event EventHandler OnBanPostAdd; /// /// Initializes a new instance of the class. /// /// A valid connection to the TShock database public BanManager(IDbConnection db) { database = db; var table = new SqlTable("PlayerBans", new SqlColumn("TicketNumber", MySqlDbType.Int32) { Primary = true, AutoIncrement = true }, new SqlColumn("Identifier", MySqlDbType.Text), new SqlColumn("Reason", MySqlDbType.Text), new SqlColumn("BanningUser", MySqlDbType.Text), new SqlColumn("Date", MySqlDbType.Int64), new SqlColumn("Expiration", MySqlDbType.Int64) ); var creator = new SqlTableCreator(db, db.GetSqlQueryBuilder()); try { creator.EnsureTableStructure(table); } catch (DllNotFoundException) { System.Console.WriteLine(GetString("Possible problem with your database - is Sqlite3.dll present?")); throw new Exception(GetString("Could not find a database library (probably Sqlite3.dll)")); } UpdateBans(); TryConvertBans(); OnBanValidate += BanValidateCheck; OnBanPreAdd += BanAddedCheck; } /// /// Updates the collection from database. /// public void UpdateBans() { _bans = RetrieveAllBans().ToDictionary(b => b.TicketNumber); } /// /// Converts bans from the old ban system to the new. /// public void TryConvertBans() { int res = database.GetSqlType() switch { SqlType.Mysql => database.QueryScalar("SELECT COUNT(table_name) FROM information_schema.tables WHERE table_schema = @0 and table_name = 'Bans'", TShock.Config.Settings.MySqlDbName), SqlType.Sqlite => database.QueryScalar("SELECT COUNT(name) FROM sqlite_master WHERE type='table' AND name = 'Bans'"), SqlType.Postgres => database.QueryScalar("SELECT COUNT(table_name) FROM information_schema.tables WHERE table_name = 'Bans'"), }; if (res != 0) { var bans = new List(); using (var reader = database.QueryReader("SELECT * FROM Bans")) { while (reader.Read()) { var ip = reader.Get("IP"); var uuid = reader.Get("UUID"); var account = reader.Get("AccountName"); var reason = reader.Get("Reason"); var banningUser = reader.Get("BanningUser"); var date = reader.Get("Date"); var expiration = reader.Get("Expiration"); if (!DateTime.TryParse(date, out DateTime start)) { start = DateTime.UtcNow; } if (!DateTime.TryParse(expiration, out DateTime end)) { end = DateTime.MaxValue; } if (!string.IsNullOrWhiteSpace(ip)) { bans.Add(new BanPreAddEventArgs { Identifier = $"{Identifier.IP}{ip}", Reason = reason, BanningUser = banningUser, BanDateTime = start, ExpirationDateTime = end }); } if (!string.IsNullOrWhiteSpace(account)) { bans.Add(new BanPreAddEventArgs { Identifier = $"{Identifier.Account}{account}", Reason = reason, BanningUser = banningUser, BanDateTime = start, ExpirationDateTime = end }); } if (!string.IsNullOrWhiteSpace(uuid)) { bans.Add(new BanPreAddEventArgs { Identifier = $"{Identifier.UUID}{uuid}", Reason = reason, BanningUser = banningUser, BanDateTime = start, ExpirationDateTime = end }); } } } foreach (var ban in bans) InsertBan(ban); database.Query("DROP TABLE Bans"); } } internal bool CheckBan(TSPlayer player) { List identifiers = new List { $"{Identifier.Name}{player.Name}", $"{Identifier.IP}{player.IP}" }; if (player.UUID != null && player.UUID.Length > 0) { identifiers.Add($"{Identifier.UUID}{player.UUID}"); } if (player.Account != null) { identifiers.Add($"{Identifier.Account}{player.Account.Name}"); } Ban ban = TShock.Bans.Bans.FirstOrDefault(b => identifiers.Contains(b.Value.Identifier) && TShock.Bans.IsValidBan(b.Value, player)).Value; if (ban != null) { if (ban.ExpirationDateTime == DateTime.MaxValue) { player.Disconnect(GetParticularString("{0} is ban number, {1} is ban reason", $"#{ban.TicketNumber} - You are banned: {ban.Reason}")); return true; } TimeSpan ts = ban.ExpirationDateTime - DateTime.UtcNow; player.Disconnect(GetParticularString("{0} is ban number, {1} is ban reason, {2} is a timestamp", $"#{ban.TicketNumber} - You are banned: {ban.Reason} ({ban.GetPrettyExpirationString()} remaining)")); return true; } return false; } /// /// Determines whether or not a ban is valid /// /// /// /// public bool IsValidBan(Ban ban, TSPlayer player) { BanEventArgs args = new BanEventArgs { Ban = ban, Player = player }; OnBanValidate?.Invoke(this, args); return args.Valid; } internal void BanValidateCheck(object sender, BanEventArgs args) { //Only perform validation if the event has not been cancelled before we got here if (args.Valid) { //We consider a ban to be valid if the start time is before now and the end time is after now args.Valid = (DateTime.UtcNow > args.Ban.BanDateTime && DateTime.UtcNow < args.Ban.ExpirationDateTime); } } internal void BanAddedCheck(object sender, BanPreAddEventArgs args) { //Only perform validation if the event has not been cancelled before we got here if (args.Valid) { //We consider a ban valid to add if no other *current* bans exist for the identifier provided. //E.g., if a previous ban has expired, a new ban is valid. //However, if a previous ban on the provided identifier is still in effect, a new ban is not valid args.Valid = !Bans.Any(b => b.Value.Identifier == args.Identifier && b.Value.ExpirationDateTime > DateTime.UtcNow); args.Message = args.Valid ? null : GetString("The ban is invalid because a current ban for this identifier already exists."); } } /// /// Adds a new ban for the given identifier. Returns a Ban object if the ban was added, else null /// /// /// /// /// /// /// public AddBanResult InsertBan(string identifier, string reason, string banningUser, DateTime fromDate, DateTime toDate) { BanPreAddEventArgs args = new BanPreAddEventArgs { Identifier = identifier, Reason = reason, BanningUser = banningUser, BanDateTime = fromDate, ExpirationDateTime = toDate }; return InsertBan(args); } /// /// Adds a new ban for the given data. Returns a Ban object if the ban was added, else null /// /// A predefined instance of /// public AddBanResult InsertBan(BanPreAddEventArgs args) { OnBanPreAdd?.Invoke(this, args); if (!args.Valid) { string message = args.Message ?? GetString("The ban was not valid for an unknown reason."); return new AddBanResult { Message = message }; } string query = "INSERT INTO PlayerBans (Identifier, Reason, BanningUser, Date, Expiration) VALUES (@0, @1, @2, @3, @4)" + database.GetSqlType() switch { SqlType.Mysql => /*lang=mysql*/"; SELECT LAST_INSERT_ID();", SqlType.Sqlite => /*lang=sqlite*/"; SELECT last_insert_rowid();", SqlType.Postgres => /*lang=postgresql*/"RETURNING \"Identifier\";", _ => null }; int ticketId = database.QueryScalar(query, args.Identifier, args.Reason, args.BanningUser, args.BanDateTime.Ticks, args.ExpirationDateTime.Ticks); if (ticketId == 0) { return new AddBanResult { Message = GetString("Inserting the ban into the database failed.") }; } Ban b = new Ban(ticketId, args.Identifier, args.Reason, args.BanningUser, args.BanDateTime, args.ExpirationDateTime); _bans.Add(ticketId, b); OnBanPostAdd?.Invoke(this, new BanEventArgs { Ban = b }); return new AddBanResult { Ban = b }; } /// /// Attempts to remove a ban. Returns true if the ban was removed or expired. False if the ban could not be removed or expired /// /// The ticket number of the ban to change /// If true, deletes the ban from the database. If false, marks the expiration time as now, rendering the ban expired. Defaults to false /// public bool RemoveBan(int ticketNumber, bool fullDelete = false) { int rowsModified; if (fullDelete) { rowsModified = database.Query("DELETE FROM PlayerBans WHERE TicketNumber=@0", ticketNumber); _bans.Remove(ticketNumber); } else { rowsModified = database.Query("UPDATE PlayerBans SET Expiration=@0 WHERE TicketNumber=@1", DateTime.UtcNow.Ticks, ticketNumber); _bans[ticketNumber].ExpirationDateTime = DateTime.UtcNow; } return rowsModified > 0; } /// /// Retrieves a single ban from a ban's ticket number /// /// /// public Ban GetBanById(int id) { if (Bans.TryGetValue(id, out Ban value)) { return value; } using var reader = database.QueryReader("SELECT * FROM PlayerBans WHERE TicketNumber=@0", id); if (reader.Read()) { var ticketNumber = reader.Get("TicketNumber"); var identifier = reader.Get("Identifier"); var reason = reader.Get("Reason"); var banningUser = reader.Get("BanningUser"); var date = reader.Get("Date"); var expiration = reader.Get("Expiration"); return new Ban(ticketNumber, identifier, reason, banningUser, date, expiration); } return null; } /// /// Retrieves an enumerable of all bans for a given identifier /// /// Identifier to search with /// Whether or not to exclude expired bans /// public IEnumerable RetrieveBansByIdentifier(string identifier, bool currentOnly = true) { string query = "SELECT * FROM PlayerBans WHERE Identifier=@0"; if (currentOnly) { query += $" AND Expiration > {DateTime.UtcNow.Ticks}"; } using var reader = database.QueryReader(query, identifier); while (reader.Read()) { var ticketNumber = reader.Get("TicketNumber"); var ident = reader.Get("Identifier"); var reason = reader.Get("Reason"); var banningUser = reader.Get("BanningUser"); var date = reader.Get("Date"); var expiration = reader.Get("Expiration"); yield return new Ban(ticketNumber, ident, reason, banningUser, date, expiration); } } /// /// Retrieves an enumerable of bans for a given set of identifiers /// /// Whether or not to exclude expired bans /// /// public IEnumerable GetBansByIdentifiers(bool currentOnly = true, params string[] identifiers) { //Generate a sequence of '@0, @1, @2, ... etc' var parameters = string.Join(", ", Enumerable.Range(0, identifiers.Length).Select(p => $"@{p}")); string query = $"SELECT * FROM PlayerBans WHERE Identifier IN ({parameters})"; if (currentOnly) { query += $" AND Expiration > {DateTime.UtcNow.Ticks}"; } using var reader = database.QueryReader(query, identifiers); while (reader.Read()) { var ticketNumber = reader.Get("TicketNumber"); var identifier = reader.Get("Identifier"); var reason = reader.Get("Reason"); var banningUser = reader.Get("BanningUser"); var date = reader.Get("Date"); var expiration = reader.Get("Expiration"); yield return new Ban(ticketNumber, identifier, reason, banningUser, date, expiration); } } /// /// Retrieves a list of bans from the database, sorted by their addition date from newest to oldest /// public IEnumerable RetrieveAllBans() => RetrieveAllBansSorted(BanSortMethod.AddedNewestToOldest); /// /// Retrieves an enumerable of s from the database, sorted using the provided sort method /// /// /// public IEnumerable RetrieveAllBansSorted(BanSortMethod sortMethod) { List banlist = new List(); try { using var reader = database.QueryReader($"SELECT * FROM PlayerBans ORDER BY {SortToOrderByMap[sortMethod]}"); while (reader.Read()) { var ticketNumber = reader.Get("TicketNumber"); var identifier = reader.Get("Identifier"); var reason = reader.Get("Reason"); var banningUser = reader.Get("BanningUser"); var date = reader.Get("Date"); var expiration = reader.Get("Expiration"); var ban = new Ban(ticketNumber, identifier, reason, banningUser, date, expiration); banlist.Add(ban); } } catch (Exception ex) { TShock.Log.Error(ex.ToString()); Console.WriteLine(ex.StackTrace); } return banlist; } /// /// Removes all bans from the database /// /// true, if bans were cleared, false otherwise. public bool ClearBans() { try { return database.Query("DELETE FROM PlayerBans") != 0; } catch (Exception ex) { TShock.Log.Error(ex.ToString()); } return false; } private readonly Dictionary SortToOrderByMap = new() { { BanSortMethod.AddedNewestToOldest, "Date DESC" }, { BanSortMethod.AddedOldestToNewest, "Date ASC" }, { BanSortMethod.ExpirationSoonestToLatest, "Expiration ASC" }, { BanSortMethod.ExpirationLatestToSoonest, "Expiration DESC" } }; } /// /// Enum containing sort options for ban retrieval /// public enum BanSortMethod { /// /// Bans will be sorted on expiration date, from soonest to latest /// ExpirationSoonestToLatest, /// /// Bans will be sorted on expiration date, from latest to soonest /// ExpirationLatestToSoonest, /// /// Bans will be sorted by the date they were added, from newest to oldest /// AddedNewestToOldest, /// /// Bans will be sorted by the date they were added, from oldest to newest /// AddedOldestToNewest, /// /// Bans will be sorted by their ticket number /// TicketNumber } /// /// Result of an attempt to add a ban /// public class AddBanResult { /// /// Message generated from the attempt /// public string Message { get; set; } /// /// Ban object generated from the attempt, or null if the attempt failed /// public Ban Ban { get; set; } } /// /// Event args used for completed bans /// public class BanEventArgs : EventArgs { /// /// Complete ban object /// public Ban Ban { get; set; } /// /// Player ban is being applied to /// public TSPlayer Player { get; set; } /// /// Whether or not the operation should be considered to be valid /// public bool Valid { get; set; } = true; } /// /// Event args used for ban data prior to a ban being formalized /// public class BanPreAddEventArgs : EventArgs { /// /// An identifiable piece of information to ban /// public string Identifier { get; set; } /// /// Gets or sets the ban reason. /// /// The ban reason. public string Reason { get; set; } /// /// Gets or sets the name of the user who added this ban entry. /// /// The banning user. public string BanningUser { get; set; } /// /// DateTime from which the ban will take effect /// public DateTime BanDateTime { get; set; } /// /// DateTime at which the ban will end /// public DateTime ExpirationDateTime { get; set; } /// /// Whether or not the operation should be considered to be valid /// public bool Valid { get; set; } = true; /// /// Optional message to explain why the event was invalidated, if it was /// public string Message { get; set; } } /// /// Describes an identifier used by the ban system /// public class Identifier { /// /// Identifiers currently registered /// public static List Available = new List(); /// /// The prefix of the identifier. E.g, 'ip:' /// public string Prefix { get; } /// /// Short description of the identifier and its basic usage /// public string Description { get; set; } /// /// IP identifier /// public static Identifier IP = Register("ip:", GetString($"An identifier for an IP Address in octet format. e.g., '{"127.0.0.1".Color(Utils.RedHighlight)}'.")); /// /// UUID identifier /// public static Identifier UUID = Register("uuid:", GetString("An identifier for a UUID.")); /// /// Player name identifier /// public static Identifier Name = Register("name:", GetString("An identifier for a character name.")); /// /// User account identifier /// public static Identifier Account = Register("acc:", GetString("An identifier for a TShock User Account name.")); private Identifier(string prefix, string description) { Prefix = prefix; Description = description; } /// /// Returns the identifier's prefix /// /// public override string ToString() { return Prefix; } /// /// Registers a new identifier with the given prefix and description /// /// /// public static Identifier Register(string prefix, string description) { var ident = new Identifier(prefix, description); Available.Add(ident); return ident; } } /// /// Model class that represents a ban entry in the TShock database. /// public class Ban { /// /// A unique ID assigned to this ban /// public int TicketNumber { get; set; } /// /// An identifiable piece of information to ban /// public string Identifier { get; set; } /// /// Gets or sets the ban reason. /// /// The ban reason. public string Reason { get; set; } /// /// Gets or sets the name of the user who added this ban entry. /// /// The banning user. public string BanningUser { get; set; } /// /// DateTime from which the ban will take effect /// public DateTime BanDateTime { get; set; } /// /// DateTime at which the ban will end /// public DateTime ExpirationDateTime { get; set; } /// /// Returns a string in the format dd:mm:hh:ss indicating the time until the ban expires. /// If the ban is not set to expire (ExpirationDateTime == DateTime.MaxValue), returns the string 'Never' /// /// public string GetPrettyExpirationString() { if (ExpirationDateTime == DateTime.MaxValue) { return "Never"; } TimeSpan ts = (ExpirationDateTime - DateTime.UtcNow).Duration(); // Use duration to avoid pesky negatives for expired bans return $"{ts.Days:00}:{ts.Hours:00}:{ts.Minutes:00}:{ts.Seconds:00}"; } /// /// Returns a string in the format dd:mm:hh:ss indicating the time elapsed since the ban was added. /// /// public string GetPrettyTimeSinceBanString() { TimeSpan ts = (DateTime.UtcNow - BanDateTime).Duration(); return $"{ts.Days:00}:{ts.Hours:00}:{ts.Minutes:00}:{ts.Seconds:00}"; } /// /// Initializes a new instance of the class. /// /// Unique ID assigned to the ban /// Identifier to apply the ban to /// Reason for the ban /// Account name that executed the ban /// System ticks at which the ban began /// System ticks at which the ban will end public Ban(int ticketNumber, string identifier, string reason, string banningUser, long start, long end) : this(ticketNumber, identifier, reason, banningUser, new DateTime(start), new DateTime(end)) { } /// /// Initializes a new instance of the class. /// /// Unique ID assigned to the ban /// Identifier to apply the ban to /// Reason for the ban /// Account name that executed the ban /// DateTime at which the ban will start /// DateTime at which the ban will end public Ban(int ticketNumber, string identifier, string reason, string banningUser, DateTime start, DateTime end) { TicketNumber = ticketNumber; Identifier = identifier; Reason = reason; BanningUser = banningUser; BanDateTime = start; ExpirationDateTime = end; } } }