using System.Collections.Immutable; using System.Linq; using System.Net; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Shared.Administration.Logs; using Content.Shared.Database; using Content.Shared.Humanoid; using Content.Shared.Humanoid.Markings; using Content.Shared.Preferences; using Microsoft.EntityFrameworkCore; using Robust.Shared.Enums; using Robust.Shared.Network; using Robust.Shared.Utility; namespace Content.Server.Database { public abstract class ServerDbBase { #region Preferences public async Task GetPlayerPreferencesAsync(NetUserId userId) { await using var db = await GetDb(); var prefs = await db.DbContext .Preference .Include(p => p.Profiles).ThenInclude(h => h.Jobs) .Include(p => p.Profiles).ThenInclude(h => h.Antags) .Include(p => p.Profiles).ThenInclude(h => h.Traits) .AsSingleQuery() .SingleOrDefaultAsync(p => p.UserId == userId.UserId); if (prefs is null) return null; var maxSlot = prefs.Profiles.Max(p => p.Slot) + 1; var profiles = new Dictionary(maxSlot); foreach (var profile in prefs.Profiles) { profiles[profile.Slot] = ConvertProfiles(profile); } return new PlayerPreferences(profiles, prefs.SelectedCharacterSlot, Color.FromHex(prefs.AdminOOCColor)); } public async Task SaveSelectedCharacterIndexAsync(NetUserId userId, int index) { await using var db = await GetDb(); await SetSelectedCharacterSlotAsync(userId, index, db.DbContext); await db.DbContext.SaveChangesAsync(); } public async Task SaveCharacterSlotAsync(NetUserId userId, ICharacterProfile? profile, int slot) { await using var db = await GetDb(); if (profile is null) { await DeleteCharacterSlot(db.DbContext, userId, slot); await db.DbContext.SaveChangesAsync(); return; } if (profile is not HumanoidCharacterProfile humanoid) { // TODO: Handle other ICharacterProfile implementations properly throw new NotImplementedException(); } var oldProfile = db.DbContext.Profile .Include(p => p.Preference) .Where(p => p.Preference.UserId == userId.UserId) .Include(p => p.Jobs) .Include(p => p.Antags) .Include(p => p.Traits) .AsSplitQuery() .SingleOrDefault(h => h.Slot == slot); var newProfile = ConvertProfiles(humanoid, slot, oldProfile); if (oldProfile == null) { var prefs = await db.DbContext .Preference .Include(p => p.Profiles) .SingleAsync(p => p.UserId == userId.UserId); prefs.Profiles.Add(newProfile); } await db.DbContext.SaveChangesAsync(); } private static async Task DeleteCharacterSlot(ServerDbContext db, NetUserId userId, int slot) { var profile = await db.Profile.Include(p => p.Preference) .Where(p => p.Preference.UserId == userId.UserId && p.Slot == slot) .SingleOrDefaultAsync(); if (profile == null) { return; } db.Profile.Remove(profile); } public async Task InitPrefsAsync(NetUserId userId, ICharacterProfile defaultProfile) { await using var db = await GetDb(); var profile = ConvertProfiles((HumanoidCharacterProfile) defaultProfile, 0); var prefs = new Preference { UserId = userId.UserId, SelectedCharacterSlot = 0, AdminOOCColor = Color.Red.ToHex() }; prefs.Profiles.Add(profile); db.DbContext.Preference.Add(prefs); await db.DbContext.SaveChangesAsync(); return new PlayerPreferences(new[] {new KeyValuePair(0, defaultProfile)}, 0, Color.FromHex(prefs.AdminOOCColor)); } public async Task DeleteSlotAndSetSelectedIndex(NetUserId userId, int deleteSlot, int newSlot) { await using var db = await GetDb(); await DeleteCharacterSlot(db.DbContext, userId, deleteSlot); await SetSelectedCharacterSlotAsync(userId, newSlot, db.DbContext); await db.DbContext.SaveChangesAsync(); } public async Task SaveAdminOOCColorAsync(NetUserId userId, Color color) { await using var db = await GetDb(); var prefs = await db.DbContext .Preference .Include(p => p.Profiles) .SingleAsync(p => p.UserId == userId.UserId); prefs.AdminOOCColor = color.ToHex(); await db.DbContext.SaveChangesAsync(); } private static async Task SetSelectedCharacterSlotAsync(NetUserId userId, int newSlot, ServerDbContext db) { var prefs = await db.Preference.SingleAsync(p => p.UserId == userId.UserId); prefs.SelectedCharacterSlot = newSlot; } private static HumanoidCharacterProfile ConvertProfiles(Profile profile) { var jobs = profile.Jobs.ToDictionary(j => j.JobName, j => (JobPriority) j.Priority); var antags = profile.Antags.Select(a => a.AntagName); var traits = profile.Traits.Select(t => t.TraitName); var sex = Sex.Male; if (Enum.TryParse(profile.Sex, true, out var sexVal)) sex = sexVal; var clothing = ClothingPreference.Jumpsuit; if (Enum.TryParse(profile.Clothing, true, out var clothingVal)) clothing = clothingVal; var backpack = BackpackPreference.Backpack; if (Enum.TryParse(profile.Backpack, true, out var backpackVal)) backpack = backpackVal; var gender = sex == Sex.Male ? Gender.Male : Gender.Female; if (Enum.TryParse(profile.Gender, true, out var genderVal)) gender = genderVal; // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract var markingsRaw = profile.Markings?.Deserialize>(); List markings = new(); if (markingsRaw != null) { foreach (var marking in markingsRaw) { var parsed = Marking.ParseFromDbString(marking); if (parsed is null) continue; markings.Add(parsed); } } return new HumanoidCharacterProfile( profile.CharacterName, profile.FlavorText, profile.Species, profile.Age, sex, gender, new HumanoidCharacterAppearance ( profile.HairName, Color.FromHex(profile.HairColor), profile.FacialHairName, Color.FromHex(profile.FacialHairColor), Color.FromHex(profile.EyeColor), Color.FromHex(profile.SkinColor), markings ), clothing, backpack, jobs, (PreferenceUnavailableMode) profile.PreferenceUnavailable, antags.ToList(), traits.ToList() ); } private static Profile ConvertProfiles(HumanoidCharacterProfile humanoid, int slot, Profile? profile = null) { profile ??= new Profile(); var appearance = (HumanoidCharacterAppearance) humanoid.CharacterAppearance; List markingStrings = new(); foreach (var marking in appearance.Markings) { markingStrings.Add(marking.ToString()); } var markings = JsonSerializer.SerializeToDocument(markingStrings); profile.CharacterName = humanoid.Name; profile.FlavorText = humanoid.FlavorText; profile.Species = humanoid.Species; profile.Age = humanoid.Age; profile.Sex = humanoid.Sex.ToString(); profile.Gender = humanoid.Gender.ToString(); profile.HairName = appearance.HairStyleId; profile.HairColor = appearance.HairColor.ToHex(); profile.FacialHairName = appearance.FacialHairStyleId; profile.FacialHairColor = appearance.FacialHairColor.ToHex(); profile.EyeColor = appearance.EyeColor.ToHex(); profile.SkinColor = appearance.SkinColor.ToHex(); profile.Clothing = humanoid.Clothing.ToString(); profile.Backpack = humanoid.Backpack.ToString(); profile.Markings = markings; profile.Slot = slot; profile.PreferenceUnavailable = (DbPreferenceUnavailableMode) humanoid.PreferenceUnavailable; profile.Jobs.Clear(); profile.Jobs.AddRange( humanoid.JobPriorities .Where(j => j.Value != JobPriority.Never) .Select(j => new Job {JobName = j.Key, Priority = (DbJobPriority) j.Value}) ); profile.Antags.Clear(); profile.Antags.AddRange( humanoid.AntagPreferences .Select(a => new Antag {AntagName = a}) ); profile.Traits.Clear(); profile.Traits.AddRange( humanoid.TraitPreferences .Select(t => new Trait {TraitName = t}) ); return profile; } #endregion #region User Ids public async Task GetAssignedUserIdAsync(string name) { await using var db = await GetDb(); var assigned = await db.DbContext.AssignedUserId.SingleOrDefaultAsync(p => p.UserName == name); return assigned?.UserId is { } g ? new NetUserId(g) : default(NetUserId?); } public async Task AssignUserIdAsync(string name, NetUserId netUserId) { await using var db = await GetDb(); db.DbContext.AssignedUserId.Add(new AssignedUserId { UserId = netUserId.UserId, UserName = name }); await db.DbContext.SaveChangesAsync(); } #endregion #region Bans /* * BAN STUFF */ /// /// Looks up a ban by id. /// This will return a pardoned ban as well. /// /// The ban id to look for. /// The ban with the given id or null if none exist. public abstract Task GetServerBanAsync(int id); /// /// Looks up an user's most recent received un-pardoned ban. /// This will NOT return a pardoned ban. /// One of or need to not be null. /// /// The ip address of the user. /// The id of the user. /// The HWId of the user. /// The user's latest received un-pardoned ban, or null if none exist. public abstract Task GetServerBanAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId); /// /// Looks up an user's ban history. /// This will return pardoned bans as well. /// One of or need to not be null. /// /// The ip address of the user. /// The id of the user. /// The HWId of the user. /// Include pardoned and expired bans. /// The user's ban history. public abstract Task> GetServerBansAsync( IPAddress? address, NetUserId? userId, ImmutableArray? hwId, bool includeUnbanned); public abstract Task AddServerBanAsync(ServerBanDef serverBan); public abstract Task AddServerUnbanAsync(ServerUnbanDef serverUnban); public async Task EditServerBan(int id, string reason, NoteSeverity severity, DateTime? expiration, Guid editedBy, DateTime editedAt) { await using var db = await GetDb(); var ban = await db.DbContext.Ban.SingleOrDefaultAsync(b => b.Id == id); if (ban is null) return; ban.Severity = severity; ban.Reason = reason; ban.ExpirationTime = expiration; ban.LastEditedById = editedBy; ban.LastEditedAt = editedAt; await db.DbContext.SaveChangesAsync(); } protected static async Task GetBanExemptionCore(DbGuard db, NetUserId? userId) { if (userId == null) return null; var exemption = await db.DbContext.BanExemption .SingleOrDefaultAsync(e => e.UserId == userId.Value.UserId); return exemption?.Flags; } public async Task UpdateBanExemption(NetUserId userId, ServerBanExemptFlags flags) { await using var db = await GetDb(); if (flags == 0) { // Delete whatever is there. await db.DbContext.BanExemption.Where(u => u.UserId == userId.UserId).ExecuteDeleteAsync(); return; } var exemption = await db.DbContext.BanExemption.SingleOrDefaultAsync(u => u.UserId == userId.UserId); if (exemption == null) { exemption = new ServerBanExemption { UserId = userId }; db.DbContext.BanExemption.Add(exemption); } exemption.Flags = flags; await db.DbContext.SaveChangesAsync(); } public async Task GetBanExemption(NetUserId userId) { await using var db = await GetDb(); var flags = await GetBanExemptionCore(db, userId); return flags ?? ServerBanExemptFlags.None; } #endregion #region Role Bans /* * ROLE BANS */ /// /// Looks up a role ban by id. /// This will return a pardoned role ban as well. /// /// The role ban id to look for. /// The role ban with the given id or null if none exist. public abstract Task GetServerRoleBanAsync(int id); /// /// Looks up an user's role ban history. /// This will return pardoned role bans based on the bool. /// Requires one of , , or to not be null. /// /// The IP address of the user. /// The NetUserId of the user. /// The Hardware Id of the user. /// Whether expired and pardoned bans are included. /// The user's role ban history. public abstract Task> GetServerRoleBansAsync(IPAddress? address, NetUserId? userId, ImmutableArray? hwId, bool includeUnbanned); public abstract Task AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan); public abstract Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban); public async Task EditServerRoleBan(int id, string reason, NoteSeverity severity, DateTime? expiration, Guid editedBy, DateTime editedAt) { await using var db = await GetDb(); var ban = await db.DbContext.RoleBan.SingleOrDefaultAsync(b => b.Id == id); if (ban is null) return; ban.Severity = severity; ban.Reason = reason; ban.ExpirationTime = expiration; ban.LastEditedById = editedBy; ban.LastEditedAt = editedAt; await db.DbContext.SaveChangesAsync(); } #endregion #region Playtime public async Task> GetPlayTimes(Guid player) { await using var db = await GetDb(); return await db.DbContext.PlayTime .Where(p => p.PlayerId == player) .ToListAsync(); } public async Task UpdatePlayTimes(IReadOnlyCollection updates) { await using var db = await GetDb(); // Ideally I would just be able to send a bunch of UPSERT commands, but EFCore is a pile of garbage. // So... In the interest of not making this take forever at high update counts... // Bulk-load play time objects for all players involved. // This allows us to semi-efficiently load all entities we need in a single DB query. // Then we can update & insert without further round-trips to the DB. var players = updates.Select(u => u.User.UserId).Distinct().ToArray(); var dbTimes = (await db.DbContext.PlayTime .Where(p => players.Contains(p.PlayerId)) .ToArrayAsync()) .GroupBy(p => p.PlayerId) .ToDictionary(g => g.Key, g => g.ToDictionary(p => p.Tracker, p => p)); foreach (var (user, tracker, time) in updates) { if (dbTimes.TryGetValue(user.UserId, out var userTimes) && userTimes.TryGetValue(tracker, out var ent)) { // Already have a tracker in the database, update it. ent.TimeSpent = time; continue; } // No tracker, make a new one. var playTime = new PlayTime { Tracker = tracker, PlayerId = user.UserId, TimeSpent = time }; db.DbContext.PlayTime.Add(playTime); } await db.DbContext.SaveChangesAsync(); } #endregion #region Player Records /* * PLAYER RECORDS */ public async Task UpdatePlayerRecord( NetUserId userId, string userName, IPAddress address, ImmutableArray hwId) { await using var db = await GetDb(); var record = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == userId.UserId); if (record == null) { db.DbContext.Player.Add(record = new Player { FirstSeenTime = DateTime.UtcNow, UserId = userId.UserId, }); } record.LastSeenTime = DateTime.UtcNow; record.LastSeenAddress = address; record.LastSeenUserName = userName; record.LastSeenHWId = hwId.ToArray(); await db.DbContext.SaveChangesAsync(); } public async Task GetPlayerRecordByUserName(string userName, CancellationToken cancel) { await using var db = await GetDb(); // Sort by descending last seen time. // So if, due to account renames, we have two people with the same username in the DB, // the most recent one is picked. var record = await db.DbContext.Player .OrderByDescending(p => p.LastSeenTime) .FirstOrDefaultAsync(p => p.LastSeenUserName == userName, cancel); return record == null ? null : MakePlayerRecord(record); } public async Task GetPlayerRecordByUserId(NetUserId userId, CancellationToken cancel) { await using var db = await GetDb(); var record = await db.DbContext.Player .SingleOrDefaultAsync(p => p.UserId == userId.UserId, cancel); return record == null ? null : MakePlayerRecord(record); } protected abstract PlayerRecord MakePlayerRecord(Player player); #endregion #region Connection Logs /* * CONNECTION LOG */ public abstract Task AddConnectionLogAsync( NetUserId userId, string userName, IPAddress address, ImmutableArray hwId, ConnectionDenyReason? denied); public async Task AddServerBanHitsAsync(int connection, IEnumerable bans) { await using var db = await GetDb(); foreach (var ban in bans) { db.DbContext.ServerBanHit.Add(new ServerBanHit { ConnectionId = connection, BanId = ban.Id!.Value }); } await db.DbContext.SaveChangesAsync(); } #endregion #region Admin Ranks /* * ADMIN RANKS */ public async Task GetAdminDataForAsync(NetUserId userId, CancellationToken cancel) { await using var db = await GetDb(); return await db.DbContext.Admin .Include(p => p.Flags) .Include(p => p.AdminRank) .ThenInclude(p => p!.Flags) .AsSplitQuery() // tests fail because of a random warning if you dont have this! .SingleOrDefaultAsync(p => p.UserId == userId.UserId, cancel); } public abstract Task<((Admin, string? lastUserName)[] admins, AdminRank[])> GetAllAdminAndRanksAsync(CancellationToken cancel); public async Task GetAdminRankDataForAsync(int id, CancellationToken cancel = default) { await using var db = await GetDb(); return await db.DbContext.AdminRank .Include(r => r.Flags) .SingleOrDefaultAsync(r => r.Id == id, cancel); } public async Task RemoveAdminAsync(NetUserId userId, CancellationToken cancel) { await using var db = await GetDb(); var admin = await db.DbContext.Admin.SingleAsync(a => a.UserId == userId.UserId, cancel); db.DbContext.Admin.Remove(admin); await db.DbContext.SaveChangesAsync(cancel); } public async Task AddAdminAsync(Admin admin, CancellationToken cancel) { await using var db = await GetDb(); db.DbContext.Admin.Add(admin); await db.DbContext.SaveChangesAsync(cancel); } public async Task UpdateAdminAsync(Admin admin, CancellationToken cancel) { await using var db = await GetDb(); var existing = await db.DbContext.Admin.Include(a => a.Flags).SingleAsync(a => a.UserId == admin.UserId, cancel); existing.Flags = admin.Flags; existing.Title = admin.Title; existing.AdminRankId = admin.AdminRankId; await db.DbContext.SaveChangesAsync(cancel); } public async Task RemoveAdminRankAsync(int rankId, CancellationToken cancel) { await using var db = await GetDb(); var admin = await db.DbContext.AdminRank.SingleAsync(a => a.Id == rankId, cancel); db.DbContext.AdminRank.Remove(admin); await db.DbContext.SaveChangesAsync(cancel); } public async Task AddAdminRankAsync(AdminRank rank, CancellationToken cancel) { await using var db = await GetDb(); db.DbContext.AdminRank.Add(rank); await db.DbContext.SaveChangesAsync(cancel); } public virtual async Task AddNewRound(Server server, params Guid[] playerIds) { await using var db = await GetDb(); var players = await db.DbContext.Player .Where(player => playerIds.Contains(player.UserId)) .ToListAsync(); var round = new Round { Players = players, ServerId = server.Id }; db.DbContext.Round.Add(round); await db.DbContext.SaveChangesAsync(); return round.Id; } public async Task GetRound(int id) { await using var db = await GetDb(); var round = await db.DbContext.Round .Include(round => round.Players) .SingleAsync(round => round.Id == id); return round; } public async Task AddRoundPlayers(int id, Guid[] playerIds) { await using var db = await GetDb(); // ReSharper disable once SuggestVarOrType_Elsewhere Dictionary players = await db.DbContext.Player .Where(player => playerIds.Contains(player.UserId)) .ToDictionaryAsync(player => player.UserId, player => player.Id); foreach (var player in playerIds) { await db.DbContext.Database.ExecuteSqlAsync($""" INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}) ON CONFLICT DO NOTHING """); } await db.DbContext.SaveChangesAsync(); } public async Task UpdateAdminRankAsync(AdminRank rank, CancellationToken cancel) { await using var db = await GetDb(); var existing = await db.DbContext.AdminRank .Include(r => r.Flags) .SingleAsync(a => a.Id == rank.Id, cancel); existing.Flags = rank.Flags; existing.Name = rank.Name; await db.DbContext.SaveChangesAsync(cancel); } #endregion #region Admin Logs public async Task<(Server, bool existed)> AddOrGetServer(string serverName) { await using var db = await GetDb(); var server = await db.DbContext.Server .Where(server => server.Name.Equals(serverName)) .SingleOrDefaultAsync(); if (server != default) return (server, true); server = new Server { Name = serverName }; db.DbContext.Server.Add(server); await db.DbContext.SaveChangesAsync(); return (server, false); } public async Task AddAdminLogs(List logs) { DebugTools.Assert(logs.All(x => x.RoundId > 0), "Adding logs with invalid round ids."); await using var db = await GetDb(); db.DbContext.AdminLog.AddRange(logs); await db.DbContext.SaveChangesAsync(); } private static IQueryable GetAdminLogsQuery(ServerDbContext db, LogFilter? filter = null) { IQueryable query = db.AdminLog; if (filter == null) { return query.OrderBy(log => log.Date); } if (filter.Round != null) { query = query.Where(log => log.RoundId == filter.Round); } if (filter.Search != null) { query = query.Where(log => log.Message.Contains(filter.Search)); } if (filter.Types != null) { query = query.Where(log => filter.Types.Contains(log.Type)); } if (filter.Impacts != null) { query = query.Where(log => filter.Impacts.Contains(log.Impact)); } if (filter.Before != null) { query = query.Where(log => log.Date < filter.Before); } if (filter.After != null) { query = query.Where(log => log.Date > filter.After); } if (filter.IncludePlayers) { if (filter.AnyPlayers != null) { query = query.Where(log => log.Players.Any(p => filter.AnyPlayers.Contains(p.PlayerUserId)) || log.Players.Count == 0 && filter.IncludeNonPlayers); } if (filter.AllPlayers != null) { query = query.Where(log => log.Players.All(p => filter.AllPlayers.Contains(p.PlayerUserId)) || log.Players.Count == 0 && filter.IncludeNonPlayers); } } else { query = query.Where(log => log.Players.Count == 0); } if (filter.LastLogId != null) { query = filter.DateOrder switch { DateOrder.Ascending => query.Where(log => log.Id > filter.LastLogId), DateOrder.Descending => query.Where(log => log.Id < filter.LastLogId), _ => throw new ArgumentOutOfRangeException(nameof(filter), $"Unknown {nameof(DateOrder)} value {filter.DateOrder}") }; } query = filter.DateOrder switch { DateOrder.Ascending => query.OrderBy(log => log.Date), DateOrder.Descending => query.OrderByDescending(log => log.Date), _ => throw new ArgumentOutOfRangeException(nameof(filter), $"Unknown {nameof(DateOrder)} value {filter.DateOrder}") }; const int hardLogLimit = 500_000; if (filter.Limit != null) { query = query.Take(Math.Min(filter.Limit.Value, hardLogLimit)); } else { query = query.Take(hardLogLimit); } return query; } public async IAsyncEnumerable GetAdminLogMessages(LogFilter? filter = null) { await using var db = await GetDb(); var query = GetAdminLogsQuery(db.DbContext, filter); await foreach (var log in query.Select(log => log.Message).AsAsyncEnumerable()) { yield return log; } } public async IAsyncEnumerable GetAdminLogs(LogFilter? filter = null) { await using var db = await GetDb(); var query = GetAdminLogsQuery(db.DbContext, filter); query = query.Include(log => log.Players); await foreach (var log in query.AsAsyncEnumerable()) { var players = new Guid[log.Players.Count]; for (var i = 0; i < log.Players.Count; i++) { players[i] = log.Players[i].PlayerUserId; } yield return new SharedAdminLog(log.Id, log.Type, log.Impact, log.Date, log.Message, players); } } public async IAsyncEnumerable GetAdminLogsJson(LogFilter? filter = null) { await using var db = await GetDb(); var query = GetAdminLogsQuery(db.DbContext, filter); await foreach (var json in query.Select(log => log.Json).AsAsyncEnumerable()) { yield return json; } } public async Task CountAdminLogs(int round) { await using var db = await GetDb(); return await db.DbContext.AdminLog.CountAsync(log => log.RoundId == round); } #endregion #region Whitelist public async Task GetWhitelistStatusAsync(NetUserId player) { await using var db = await GetDb(); return await db.DbContext.Whitelist.AnyAsync(w => w.UserId == player); } public async Task AddToWhitelistAsync(NetUserId player) { await using var db = await GetDb(); db.DbContext.Whitelist.Add(new Whitelist { UserId = player }); await db.DbContext.SaveChangesAsync(); } public async Task RemoveFromWhitelistAsync(NetUserId player) { await using var db = await GetDb(); var entry = await db.DbContext.Whitelist.SingleAsync(w => w.UserId == player); db.DbContext.Whitelist.Remove(entry); await db.DbContext.SaveChangesAsync(); } public async Task GetLastReadRules(NetUserId player) { await using var db = await GetDb(); return await db.DbContext.Player .Where(dbPlayer => dbPlayer.UserId == player) .Select(dbPlayer => dbPlayer.LastReadRules) .SingleOrDefaultAsync(); } public async Task SetLastReadRules(NetUserId player, DateTime date) { await using var db = await GetDb(); var dbPlayer = await db.DbContext.Player.Where(dbPlayer => dbPlayer.UserId == player).SingleOrDefaultAsync(); if (dbPlayer == null) { return; } dbPlayer.LastReadRules = date; await db.DbContext.SaveChangesAsync(); } #endregion #region Uploaded Resources Logs public async Task AddUploadedResourceLogAsync(NetUserId user, DateTime date, string path, byte[] data) { await using var db = await GetDb(); db.DbContext.UploadedResourceLog.Add(new UploadedResourceLog() { UserId = user, Date = date, Path = path, Data = data }); await db.DbContext.SaveChangesAsync(); } public async Task PurgeUploadedResourceLogAsync(int days) { await using var db = await GetDb(); var date = DateTime.Now.Subtract(TimeSpan.FromDays(days)); await foreach (var log in db.DbContext.UploadedResourceLog .Where(l => date > l.Date) .AsAsyncEnumerable()) { db.DbContext.UploadedResourceLog.Remove(log); } await db.DbContext.SaveChangesAsync(); } #endregion #region Admin Notes public virtual async Task AddAdminNote(AdminNote note) { await using var db = await GetDb(); db.DbContext.AdminNotes.Add(note); await db.DbContext.SaveChangesAsync(); return note.Id; } public virtual async Task AddAdminWatchlist(AdminWatchlist watchlist) { await using var db = await GetDb(); db.DbContext.AdminWatchlists.Add(watchlist); await db.DbContext.SaveChangesAsync(); return watchlist.Id; } public virtual async Task AddAdminMessage(AdminMessage message) { await using var db = await GetDb(); db.DbContext.AdminMessages.Add(message); await db.DbContext.SaveChangesAsync(); return message.Id; } public async Task GetAdminNote(int id) { await using var db = await GetDb(); return await db.DbContext.AdminNotes .Where(note => note.Id == id) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.LastEditedBy) .Include(note => note.DeletedBy) .Include(note => note.Player) .SingleOrDefaultAsync(); } public async Task GetAdminWatchlist(int id) { await using var db = await GetDb(); return await db.DbContext.AdminWatchlists .Where(note => note.Id == id) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.LastEditedBy) .Include(note => note.DeletedBy) .Include(note => note.Player) .SingleOrDefaultAsync(); } public async Task GetAdminMessage(int id) { await using var db = await GetDb(); return await db.DbContext.AdminMessages .Where(note => note.Id == id) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.LastEditedBy) .Include(note => note.DeletedBy) .Include(note => note.Player) .SingleOrDefaultAsync(); } public async Task GetServerBanAsNoteAsync(int id) { await using var db = await GetDb(); var ban = await db.DbContext.Ban .Include(ban => ban.Unban) .Include(ban => ban.Round) .ThenInclude(r => r!.Server) .Include(ban => ban.CreatedBy) .Include(ban => ban.LastEditedBy) .Include(ban => ban.Unban) .SingleOrDefaultAsync(b => b.Id == id); if (ban is null) return null; var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == ban.PlayerUserId); return new ServerBanNote(ban.Id, ban.RoundId, ban.Round, ban.PlayerUserId, player, ban.PlaytimeAtNote, ban.Reason, ban.Severity, ban.CreatedBy, ban.BanTime, ban.LastEditedBy, ban.LastEditedAt, ban.ExpirationTime, ban.Hidden, ban.Unban?.UnbanningAdmin == null ? null : await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == ban.Unban.UnbanningAdmin.Value), ban.Unban?.UnbanTime); } public async Task GetServerRoleBanAsNoteAsync(int id) { await using var db = await GetDb(); var ban = await db.DbContext.RoleBan .Include(b => b.Unban) .SingleOrDefaultAsync(b => b.Id == id); if (ban is null) return null; var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == ban.PlayerUserId); var unbanningAdmin = ban.Unban is null ? null : await db.DbContext.Player.SingleOrDefaultAsync(b => b.UserId == ban.Unban.UnbanningAdmin); return new ServerRoleBanNote(ban.Id, ban.RoundId, ban.Round, ban.PlayerUserId, player, ban.PlaytimeAtNote, ban.Reason, ban.Severity, ban.CreatedBy, ban.BanTime, ban.LastEditedBy, ban.LastEditedAt, ban.ExpirationTime, ban.Hidden, new [] { ban.RoleId.Replace(BanManager.JobPrefix, null) }, unbanningAdmin, ban.Unban?.UnbanTime); } public async Task> GetAllAdminRemarks(Guid player) { await using var db = await GetDb(); List notes = new(); notes.AddRange( await (from note in db.DbContext.AdminNotes where note.PlayerUserId == player && !note.Deleted && (note.ExpirationTime == null || DateTime.UtcNow < note.ExpirationTime) select note) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.LastEditedBy) .Include(note => note.Player) .ToListAsync()); notes.AddRange(await GetActiveWatchlistsImpl(db, player)); notes.AddRange(await GetMessagesImpl(db, player)); notes.AddRange(await GetServerBansAsNotesForUser(db, player)); notes.AddRange(await GetGroupedServerRoleBansAsNotesForUser(db, player)); return notes; } public async Task EditAdminNote(int id, string message, NoteSeverity severity, bool secret, Guid editedBy, DateTime editedAt, DateTime? expiryTime) { await using var db = await GetDb(); var note = await db.DbContext.AdminNotes.Where(note => note.Id == id).SingleAsync(); note.Message = message; note.Severity = severity; note.Secret = secret; note.LastEditedById = editedBy; note.LastEditedAt = editedAt; note.ExpirationTime = expiryTime; await db.DbContext.SaveChangesAsync(); } public async Task EditAdminWatchlist(int id, string message, Guid editedBy, DateTime editedAt, DateTime? expiryTime) { await using var db = await GetDb(); var note = await db.DbContext.AdminWatchlists.Where(note => note.Id == id).SingleAsync(); note.Message = message; note.LastEditedById = editedBy; note.LastEditedAt = editedAt; note.ExpirationTime = expiryTime; await db.DbContext.SaveChangesAsync(); } public async Task EditAdminMessage(int id, string message, Guid editedBy, DateTime editedAt, DateTime? expiryTime) { await using var db = await GetDb(); var note = await db.DbContext.AdminMessages.Where(note => note.Id == id).SingleAsync(); note.Message = message; note.LastEditedById = editedBy; note.LastEditedAt = editedAt; note.ExpirationTime = expiryTime; await db.DbContext.SaveChangesAsync(); } public async Task DeleteAdminNote(int id, Guid deletedBy, DateTime deletedAt) { await using var db = await GetDb(); var note = await db.DbContext.AdminNotes.Where(note => note.Id == id).SingleAsync(); note.Deleted = true; note.DeletedById = deletedBy; note.DeletedAt = deletedAt; await db.DbContext.SaveChangesAsync(); } public async Task DeleteAdminWatchlist(int id, Guid deletedBy, DateTime deletedAt) { await using var db = await GetDb(); var watchlist = await db.DbContext.AdminWatchlists.Where(note => note.Id == id).SingleAsync(); watchlist.Deleted = true; watchlist.DeletedById = deletedBy; watchlist.DeletedAt = deletedAt; await db.DbContext.SaveChangesAsync(); } public async Task DeleteAdminMessage(int id, Guid deletedBy, DateTime deletedAt) { await using var db = await GetDb(); var message = await db.DbContext.AdminMessages.Where(note => note.Id == id).SingleAsync(); message.Deleted = true; message.DeletedById = deletedBy; message.DeletedAt = deletedAt; await db.DbContext.SaveChangesAsync(); } public async Task HideServerBanFromNotes(int id, Guid deletedBy, DateTime deletedAt) { await using var db = await GetDb(); var ban = await db.DbContext.Ban.Where(ban => ban.Id == id).SingleAsync(); ban.Hidden = true; ban.LastEditedById = deletedBy; ban.LastEditedAt = deletedAt; await db.DbContext.SaveChangesAsync(); } public async Task HideServerRoleBanFromNotes(int id, Guid deletedBy, DateTime deletedAt) { await using var db = await GetDb(); var roleBan = await db.DbContext.RoleBan.Where(roleBan => roleBan.Id == id).SingleAsync(); roleBan.Hidden = true; roleBan.LastEditedById = deletedBy; roleBan.LastEditedAt = deletedAt; await db.DbContext.SaveChangesAsync(); } public async Task> GetVisibleAdminRemarks(Guid player) { await using var db = await GetDb(); List notesCol = new(); notesCol.AddRange( await (from note in db.DbContext.AdminNotes where note.PlayerUserId == player && !note.Secret && !note.Deleted && (note.ExpirationTime == null || DateTime.UtcNow < note.ExpirationTime) select note) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.Player) .ToListAsync()); notesCol.AddRange(await GetMessagesImpl(db, player)); return notesCol; } public async Task> GetActiveWatchlists(Guid player) { await using var db = await GetDb(); return await GetActiveWatchlistsImpl(db, player); } protected async Task> GetActiveWatchlistsImpl(DbGuard db, Guid player) { return await (from watchlist in db.DbContext.AdminWatchlists where watchlist.PlayerUserId == player && !watchlist.Deleted && (watchlist.ExpirationTime == null || DateTime.UtcNow < watchlist.ExpirationTime) select watchlist) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.LastEditedBy) .Include(note => note.Player) .ToListAsync(); } public async Task> GetMessages(Guid player) { await using var db = await GetDb(); return await GetMessagesImpl(db, player); } protected async Task> GetMessagesImpl(DbGuard db, Guid player) { return await (from message in db.DbContext.AdminMessages where message.PlayerUserId == player && !message.Deleted && (message.ExpirationTime == null || DateTime.UtcNow < message.ExpirationTime) select message) .Include(note => note.Round) .ThenInclude(r => r!.Server) .Include(note => note.CreatedBy) .Include(note => note.LastEditedBy) .Include(note => note.Player) .ToListAsync(); } public async Task MarkMessageAsSeen(int id) { await using var db = await GetDb(); var message = await db.DbContext.AdminMessages.SingleAsync(m => m.Id == id); message.Seen = true; await db.DbContext.SaveChangesAsync(); } // These two are here because they get converted into notes later protected async Task> GetServerBansAsNotesForUser(DbGuard db, Guid user) { // You can't group queries, as player will not always exist. When it doesn't, the // whole query returns nothing var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == user); var bans = await db.DbContext.Ban .Where(ban => ban.PlayerUserId == user && !ban.Hidden) .Include(ban => ban.Unban) .Include(ban => ban.Round) .ThenInclude(r => r!.Server) .Include(ban => ban.CreatedBy) .Include(ban => ban.LastEditedBy) .Include(ban => ban.Unban) .ToArrayAsync(); var banNotes = new List(); foreach (var ban in bans) { var banNote = new ServerBanNote(ban.Id, ban.RoundId, ban.Round, ban.PlayerUserId, player, ban.PlaytimeAtNote, ban.Reason, ban.Severity, ban.CreatedBy, ban.BanTime, ban.LastEditedBy, ban.LastEditedAt, ban.ExpirationTime, ban.Hidden, ban.Unban?.UnbanningAdmin == null ? null : await db.DbContext.Player.SingleOrDefaultAsync( p => p.UserId == ban.Unban.UnbanningAdmin.Value), ban.Unban?.UnbanTime); banNotes.Add(banNote); } return banNotes; } protected async Task> GetGroupedServerRoleBansAsNotesForUser(DbGuard db, Guid user) { // Server side query var bansQuery = await db.DbContext.RoleBan .Where(ban => ban.PlayerUserId == user && !ban.Hidden) .Include(ban => ban.Unban) .Include(ban => ban.Round) .ThenInclude(r => r!.Server) .Include(ban => ban.CreatedBy) .Include(ban => ban.LastEditedBy) .Include(ban => ban.Unban) .ToArrayAsync(); // Client side query, as EF can't do groups yet var bansEnumerable = bansQuery .GroupBy(ban => new { ban.BanTime, CreatedBy = (Player?)ban.CreatedBy, ban.Reason, Unbanned = ban.Unban == null }) .Select(banGroup => banGroup) .ToArray(); List bans = new(); var player = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == user); foreach (var banGroup in bansEnumerable) { var firstBan = banGroup.First(); Player? unbanningAdmin = null; if (firstBan.Unban?.UnbanningAdmin is not null) unbanningAdmin = await db.DbContext.Player.SingleOrDefaultAsync(p => p.UserId == firstBan.Unban.UnbanningAdmin.Value); bans.Add(new ServerRoleBanNote(firstBan.Id, firstBan.RoundId, firstBan.Round, firstBan.PlayerUserId, player, firstBan.PlaytimeAtNote, firstBan.Reason, firstBan.Severity, firstBan.CreatedBy, firstBan.BanTime, firstBan.LastEditedBy, firstBan.LastEditedAt, firstBan.ExpirationTime, firstBan.Hidden, banGroup.Select(ban => ban.RoleId.Replace(BanManager.JobPrefix, null)).ToArray(), unbanningAdmin, firstBan.Unban?.UnbanTime)); } return bans; } #endregion protected abstract Task GetDb(); protected abstract class DbGuard : IAsyncDisposable { public abstract ServerDbContext DbContext { get; } public abstract ValueTask DisposeAsync(); } } }