Files
tbd-station-14/Content.Server/Database/ServerDbBase.cs
Pieter-Jan Briers 4f3db43696 Integrate Modern HWID into content
This should be the primary changes for the future-proof "Modern HWID" system implemented into Robust and the auth server.

HWIDs in the database have been given an additional column representing their version, legacy or modern. This is implemented via an EF Core owned entity. By manually setting the column name of the main value column, we can keep DB compatibility and the migration is just adding some type columns.

This new HWID type has to be plumbed through everywhere, resulting in some breaking changes for the DB layer and such.

New bans and player records are placed with the new modern HWID. Old bans are still checked against legacy HWIDs.

Modern HWIDs are presented with a "V2-" prefix to admins, to allow distinguishing them. This is also integrated into the parsing logic for placing new bans.

There's also some code cleanup to reduce copy pasting around the place from my changes.

Requires latest engine to support ImmutableArray<byte> in NetSerializer.
2024-11-12 01:51:54 +01:00

1759 lines
65 KiB
C#

using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Runtime.CompilerServices;
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 Content.Shared.Preferences.Loadouts;
using Content.Shared.Roles;
using Content.Shared.Traits;
using Microsoft.EntityFrameworkCore;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Server.Database
{
public abstract class ServerDbBase
{
private readonly ISawmill _opsLog;
public event Action<DatabaseNotification>? OnNotificationReceived;
/// <param name="opsLog">Sawmill to trace log database operations to.</param>
public ServerDbBase(ISawmill opsLog)
{
_opsLog = opsLog;
}
#region Preferences
public async Task<PlayerPreferences?> GetPlayerPreferencesAsync(
NetUserId userId,
CancellationToken cancel = default)
{
await using var db = await GetDb(cancel);
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)
.Include(p => p.Profiles)
.ThenInclude(h => h.Loadouts)
.ThenInclude(l => l.Groups)
.ThenInclude(group => group.Loadouts)
.AsSplitQuery()
.SingleOrDefaultAsync(p => p.UserId == userId.UserId, cancel);
if (prefs is null)
return null;
var maxSlot = prefs.Profiles.Max(p => p.Slot) + 1;
var profiles = new Dictionary<int, ICharacterProfile>(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)
.Include(p => p.Loadouts)
.ThenInclude(l => l.Groups)
.ThenInclude(group => group.Loadouts)
.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<PlayerPreferences> 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<int, ICharacterProfile>(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 => new ProtoId<JobPrototype>(j.JobName), j => (JobPriority) j.Priority);
var antags = profile.Antags.Select(a => new ProtoId<AntagPrototype>(a.AntagName));
var traits = profile.Traits.Select(t => new ProtoId<TraitPrototype>(t.TraitName));
var sex = Sex.Male;
if (Enum.TryParse<Sex>(profile.Sex, true, out var sexVal))
sex = sexVal;
var spawnPriority = (SpawnPriorityPreference) profile.SpawnPriority;
var gender = sex == Sex.Male ? Gender.Male : Gender.Female;
if (Enum.TryParse<Gender>(profile.Gender, true, out var genderVal))
gender = genderVal;
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
var markingsRaw = profile.Markings?.Deserialize<List<string>>();
List<Marking> markings = new();
if (markingsRaw != null)
{
foreach (var marking in markingsRaw)
{
var parsed = Marking.ParseFromDbString(marking);
if (parsed is null) continue;
markings.Add(parsed);
}
}
var loadouts = new Dictionary<string, RoleLoadout>();
foreach (var role in profile.Loadouts)
{
var loadout = new RoleLoadout(role.RoleName)
{
};
foreach (var group in role.Groups)
{
var groupLoadouts = loadout.SelectedLoadouts.GetOrNew(group.GroupName);
foreach (var profLoadout in group.Loadouts)
{
groupLoadouts.Add(new Loadout()
{
Prototype = profLoadout.LoadoutName,
});
}
}
loadouts[role.RoleName] = loadout;
}
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
),
spawnPriority,
jobs,
(PreferenceUnavailableMode) profile.PreferenceUnavailable,
antags.ToHashSet(),
traits.ToHashSet(),
loadouts
);
}
private static Profile ConvertProfiles(HumanoidCharacterProfile humanoid, int slot, Profile? profile = null)
{
profile ??= new Profile();
var appearance = (HumanoidCharacterAppearance) humanoid.CharacterAppearance;
List<string> 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.SpawnPriority = (int) humanoid.SpawnPriority;
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})
);
profile.Loadouts.Clear();
foreach (var (role, loadouts) in humanoid.Loadouts)
{
var dz = new ProfileRoleLoadout()
{
RoleName = role,
};
foreach (var (group, groupLoadouts) in loadouts.SelectedLoadouts)
{
var profileGroup = new ProfileLoadoutGroup()
{
GroupName = group,
};
foreach (var loadout in groupLoadouts)
{
profileGroup.Loadouts.Add(new ProfileLoadout()
{
LoadoutName = loadout.Prototype,
});
}
dz.Groups.Add(profileGroup);
}
profile.Loadouts.Add(dz);
}
return profile;
}
#endregion
#region User Ids
public async Task<NetUserId?> 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
*/
/// <summary>
/// Looks up a ban by id.
/// This will return a pardoned ban as well.
/// </summary>
/// <param name="id">The ban id to look for.</param>
/// <returns>The ban with the given id or null if none exist.</returns>
public abstract Task<ServerBanDef?> GetServerBanAsync(int id);
/// <summary>
/// Looks up an user's most recent received un-pardoned ban.
/// This will NOT return a pardoned ban.
/// One of <see cref="address"/> or <see cref="userId"/> need to not be null.
/// </summary>
/// <param name="address">The ip address of the user.</param>
/// <param name="userId">The id of the user.</param>
/// <param name="hwId">The legacy HWId of the user.</param>
/// <param name="modernHWIds">The modern HWIDs of the user.</param>
/// <returns>The user's latest received un-pardoned ban, or null if none exist.</returns>
public abstract Task<ServerBanDef?> GetServerBanAsync(
IPAddress? address,
NetUserId? userId,
ImmutableArray<byte>? hwId,
ImmutableArray<ImmutableArray<byte>>? modernHWIds);
/// <summary>
/// Looks up an user's ban history.
/// This will return pardoned bans as well.
/// One of <see cref="address"/> or <see cref="userId"/> need to not be null.
/// </summary>
/// <param name="address">The ip address of the user.</param>
/// <param name="userId">The id of the user.</param>
/// <param name="hwId">The legacy HWId of the user.</param>
/// <param name="modernHWIds">The modern HWIDs of the user.</param>
/// <param name="includeUnbanned">Include pardoned and expired bans.</param>
/// <returns>The user's ban history.</returns>
public abstract Task<List<ServerBanDef>> GetServerBansAsync(
IPAddress? address,
NetUserId? userId,
ImmutableArray<byte>? hwId,
ImmutableArray<ImmutableArray<byte>>? modernHWIds,
bool includeUnbanned);
public abstract Task AddServerBanAsync(ServerBanDef serverBan);
public abstract Task AddServerUnbanAsync(ServerUnbanDef serverUnban);
public async Task EditServerBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset 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?.UtcDateTime;
ban.LastEditedById = editedBy;
ban.LastEditedAt = editedAt.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
protected static async Task<ServerBanExemptFlags?> GetBanExemptionCore(
DbGuard db,
NetUserId? userId,
CancellationToken cancel = default)
{
if (userId == null)
return null;
var exemption = await db.DbContext.BanExemption
.SingleOrDefaultAsync(e => e.UserId == userId.Value.UserId, cancellationToken: cancel);
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<ServerBanExemptFlags> GetBanExemption(NetUserId userId, CancellationToken cancel)
{
await using var db = await GetDb(cancel);
var flags = await GetBanExemptionCore(db, userId, cancel);
return flags ?? ServerBanExemptFlags.None;
}
#endregion
#region Role Bans
/*
* ROLE BANS
*/
/// <summary>
/// Looks up a role ban by id.
/// This will return a pardoned role ban as well.
/// </summary>
/// <param name="id">The role ban id to look for.</param>
/// <returns>The role ban with the given id or null if none exist.</returns>
public abstract Task<ServerRoleBanDef?> GetServerRoleBanAsync(int id);
/// <summary>
/// Looks up an user's role ban history.
/// This will return pardoned role bans based on the <see cref="includeUnbanned"/> bool.
/// Requires one of <see cref="address"/>, <see cref="userId"/>, or <see cref="hwId"/> to not be null.
/// </summary>
/// <param name="address">The IP address of the user.</param>
/// <param name="userId">The NetUserId of the user.</param>
/// <param name="hwId">The Hardware Id of the user.</param>
/// <param name="modernHWIds">The modern HWIDs of the user.</param>
/// <param name="includeUnbanned">Whether expired and pardoned bans are included.</param>
/// <returns>The user's role ban history.</returns>
public abstract Task<List<ServerRoleBanDef>> GetServerRoleBansAsync(IPAddress? address,
NetUserId? userId,
ImmutableArray<byte>? hwId,
ImmutableArray<ImmutableArray<byte>>? modernHWIds,
bool includeUnbanned);
public abstract Task<ServerRoleBanDef> AddServerRoleBanAsync(ServerRoleBanDef serverRoleBan);
public abstract Task AddServerRoleUnbanAsync(ServerRoleUnbanDef serverRoleUnban);
public async Task EditServerRoleBan(int id, string reason, NoteSeverity severity, DateTimeOffset? expiration, Guid editedBy, DateTimeOffset 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?.UtcDateTime;
ban.LastEditedById = editedBy;
ban.LastEditedAt = editedAt.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
#endregion
#region Playtime
public async Task<List<PlayTime>> GetPlayTimes(Guid player, CancellationToken cancel)
{
await using var db = await GetDb(cancel);
return await db.DbContext.PlayTime
.Where(p => p.PlayerId == player)
.ToListAsync(cancel);
}
public async Task UpdatePlayTimes(IReadOnlyCollection<PlayTimeUpdate> 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,
ImmutableTypedHwid? 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;
await db.DbContext.SaveChangesAsync();
}
public async Task<PlayerRecord?> 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<PlayerRecord?> 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 async Task<bool> PlayerRecordExists(DbGuard db, NetUserId userId)
{
return await db.DbContext.Player.AnyAsync(p => p.UserId == userId);
}
[return: NotNullIfNotNull(nameof(player))]
protected PlayerRecord? MakePlayerRecord(Player? player)
{
if (player == null)
return null;
return new PlayerRecord(
new NetUserId(player.UserId),
new DateTimeOffset(NormalizeDatabaseTime(player.FirstSeenTime)),
player.LastSeenUserName,
new DateTimeOffset(NormalizeDatabaseTime(player.LastSeenTime)),
player.LastSeenAddress,
player.LastSeenHWId);
}
#endregion
#region Connection Logs
/*
* CONNECTION LOG
*/
public abstract Task<int> AddConnectionLogAsync(NetUserId userId,
string userName,
IPAddress address,
ImmutableTypedHwid? hwId,
float trust,
ConnectionDenyReason? denied,
int serverId);
public async Task AddServerBanHitsAsync(int connection, IEnumerable<ServerBanDef> 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<Admin?> GetAdminDataForAsync(NetUserId userId, CancellationToken cancel)
{
await using var db = await GetDb(cancel);
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<AdminRank?> GetAdminRankDataForAsync(int id, CancellationToken cancel = default)
{
await using var db = await GetDb(cancel);
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(cancel);
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(cancel);
db.DbContext.Admin.Add(admin);
await db.DbContext.SaveChangesAsync(cancel);
}
public async Task UpdateAdminAsync(Admin admin, CancellationToken cancel)
{
await using var db = await GetDb(cancel);
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(cancel);
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(cancel);
db.DbContext.AdminRank.Add(rank);
await db.DbContext.SaveChangesAsync(cancel);
}
public async Task<int> 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
{
StartDate = DateTime.UtcNow,
Players = players,
ServerId = server.Id
};
db.DbContext.Round.Add(round);
await db.DbContext.SaveChangesAsync();
return round.Id;
}
public async Task<Round> 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<Guid, int> 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();
}
[return: NotNullIfNotNull(nameof(round))]
protected RoundRecord? MakeRoundRecord(Round? round)
{
if (round == null)
return null;
return new RoundRecord(
round.Id,
NormalizeDatabaseTime(round.StartDate),
MakeServerRecord(round.Server));
}
public async Task UpdateAdminRankAsync(AdminRank rank, CancellationToken cancel)
{
await using var db = await GetDb(cancel);
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);
}
[return: NotNullIfNotNull(nameof(server))]
protected ServerRecord? MakeServerRecord(Server? server)
{
if (server == null)
return null;
return new ServerRecord(server.Id, server.Name);
}
public async Task AddAdminLogs(List<AdminLog> logs)
{
const int maxRetryAttempts = 5;
var initialRetryDelay = TimeSpan.FromSeconds(5);
DebugTools.Assert(logs.All(x => x.RoundId > 0), "Adding logs with invalid round ids.");
var attempt = 0;
var retryDelay = initialRetryDelay;
while (attempt < maxRetryAttempts)
{
try
{
await using var db = await GetDb();
db.DbContext.AdminLog.AddRange(logs);
await db.DbContext.SaveChangesAsync();
_opsLog.Debug($"Successfully saved {logs.Count} admin logs.");
break;
}
catch (Exception ex)
{
attempt += 1;
_opsLog.Error($"Attempt {attempt} failed to save logs: {ex}");
if (attempt >= maxRetryAttempts)
{
_opsLog.Error($"Max retry attempts reached. Failed to save {logs.Count} admin logs.");
return;
}
_opsLog.Warning($"Retrying in {retryDelay.TotalSeconds} seconds...");
await Task.Delay(retryDelay);
retryDelay *= 2;
}
}
}
protected abstract IQueryable<AdminLog> StartAdminLogsQuery(ServerDbContext db, LogFilter? filter = null);
private IQueryable<AdminLog> GetAdminLogsQuery(ServerDbContext db, LogFilter? filter = null)
{
// Save me from SQLite
var query = StartAdminLogsQuery(db, filter);
if (filter == null)
{
return query.OrderBy(log => log.Date);
}
if (filter.Round != null)
{
query = query.Where(log => log.RoundId == filter.Round);
}
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<string> 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<SharedAdminLog> 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<JsonDocument> 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<int> 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<bool> 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<DateTimeOffset?> GetLastReadRules(NetUserId player)
{
await using var db = await GetDb();
return NormalizeDatabaseTime(await db.DbContext.Player
.Where(dbPlayer => dbPlayer.UserId == player)
.Select(dbPlayer => dbPlayer.LastReadRules)
.SingleOrDefaultAsync());
}
public async Task SetLastReadRules(NetUserId player, DateTimeOffset 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.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task<bool> GetBlacklistStatusAsync(NetUserId player)
{
await using var db = await GetDb();
return await db.DbContext.Blacklist.AnyAsync(w => w.UserId == player);
}
public async Task AddToBlacklistAsync(NetUserId player)
{
await using var db = await GetDb();
db.DbContext.Blacklist.Add(new Blacklist() { UserId = player });
await db.DbContext.SaveChangesAsync();
}
public async Task RemoveFromBlacklistAsync(NetUserId player)
{
await using var db = await GetDb();
var entry = await db.DbContext.Blacklist.SingleAsync(w => w.UserId == player);
db.DbContext.Blacklist.Remove(entry);
await db.DbContext.SaveChangesAsync();
}
#endregion
#region Uploaded Resources Logs
public async Task AddUploadedResourceLogAsync(NetUserId user, DateTimeOffset date, string path, byte[] data)
{
await using var db = await GetDb();
db.DbContext.UploadedResourceLog.Add(new UploadedResourceLog() { UserId = user, Date = date.UtcDateTime, Path = path, Data = data });
await db.DbContext.SaveChangesAsync();
}
public async Task PurgeUploadedResourceLogAsync(int days)
{
await using var db = await GetDb();
var date = DateTime.UtcNow.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<int> 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<int> 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<int> AddAdminMessage(AdminMessage message)
{
await using var db = await GetDb();
db.DbContext.AdminMessages.Add(message);
await db.DbContext.SaveChangesAsync();
return message.Id;
}
public async Task<AdminNoteRecord?> GetAdminNote(int id)
{
await using var db = await GetDb();
var entity = 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();
return entity == null ? null : MakeAdminNoteRecord(entity);
}
private AdminNoteRecord MakeAdminNoteRecord(AdminNote entity)
{
return new AdminNoteRecord(
entity.Id,
MakeRoundRecord(entity.Round),
MakePlayerRecord(entity.Player),
entity.PlaytimeAtNote,
entity.Message,
entity.Severity,
MakePlayerRecord(entity.CreatedBy),
NormalizeDatabaseTime(entity.CreatedAt),
MakePlayerRecord(entity.LastEditedBy),
NormalizeDatabaseTime(entity.LastEditedAt),
NormalizeDatabaseTime(entity.ExpirationTime),
entity.Deleted,
MakePlayerRecord(entity.DeletedBy),
NormalizeDatabaseTime(entity.DeletedAt),
entity.Secret);
}
public async Task<AdminWatchlistRecord?> GetAdminWatchlist(int id)
{
await using var db = await GetDb();
var entity = 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();
return entity == null ? null : MakeAdminWatchlistRecord(entity);
}
public async Task<AdminMessageRecord?> GetAdminMessage(int id)
{
await using var db = await GetDb();
var entity = 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();
return entity == null ? null : MakeAdminMessageRecord(entity);
}
private AdminMessageRecord MakeAdminMessageRecord(AdminMessage entity)
{
return new AdminMessageRecord(
entity.Id,
MakeRoundRecord(entity.Round),
MakePlayerRecord(entity.Player),
entity.PlaytimeAtNote,
entity.Message,
MakePlayerRecord(entity.CreatedBy),
NormalizeDatabaseTime(entity.CreatedAt),
MakePlayerRecord(entity.LastEditedBy),
NormalizeDatabaseTime(entity.LastEditedAt),
NormalizeDatabaseTime(entity.ExpirationTime),
entity.Deleted,
MakePlayerRecord(entity.DeletedBy),
NormalizeDatabaseTime(entity.DeletedAt),
entity.Seen,
entity.Dismissed);
}
public async Task<ServerBanNoteRecord?> 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 ServerBanNoteRecord(
ban.Id,
MakeRoundRecord(ban.Round),
MakePlayerRecord(player),
ban.PlaytimeAtNote,
ban.Reason,
ban.Severity,
MakePlayerRecord(ban.CreatedBy),
ban.BanTime,
MakePlayerRecord(ban.LastEditedBy),
ban.LastEditedAt,
ban.ExpirationTime,
ban.Hidden,
MakePlayerRecord(ban.Unban?.UnbanningAdmin == null
? null
: await db.DbContext.Player.SingleOrDefaultAsync(p =>
p.UserId == ban.Unban.UnbanningAdmin.Value)),
ban.Unban?.UnbanTime);
}
public async Task<ServerRoleBanNoteRecord?> GetServerRoleBanAsNoteAsync(int id)
{
await using var db = await GetDb();
var ban = await db.DbContext.RoleBan
.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);
var unbanningAdmin =
ban.Unban is null
? null
: await db.DbContext.Player.SingleOrDefaultAsync(b => b.UserId == ban.Unban.UnbanningAdmin);
return new ServerRoleBanNoteRecord(
ban.Id,
MakeRoundRecord(ban.Round),
MakePlayerRecord(player),
ban.PlaytimeAtNote,
ban.Reason,
ban.Severity,
MakePlayerRecord(ban.CreatedBy),
ban.BanTime,
MakePlayerRecord(ban.LastEditedBy),
ban.LastEditedAt,
ban.ExpirationTime,
ban.Hidden,
new [] { ban.RoleId.Replace(BanManager.JobPrefix, null) },
MakePlayerRecord(unbanningAdmin),
ban.Unban?.UnbanTime);
}
public async Task<List<IAdminRemarksRecord>> GetAllAdminRemarks(Guid player)
{
await using var db = await GetDb();
List<IAdminRemarksRecord> 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()).Select(MakeAdminNoteRecord));
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, DateTimeOffset editedAt, DateTimeOffset? 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.UtcDateTime;
note.ExpirationTime = expiryTime?.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task EditAdminWatchlist(int id, string message, Guid editedBy, DateTimeOffset editedAt, DateTimeOffset? 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.UtcDateTime;
note.ExpirationTime = expiryTime?.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task EditAdminMessage(int id, string message, Guid editedBy, DateTimeOffset editedAt, DateTimeOffset? 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.UtcDateTime;
note.ExpirationTime = expiryTime?.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task DeleteAdminNote(int id, Guid deletedBy, DateTimeOffset 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.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task DeleteAdminWatchlist(int id, Guid deletedBy, DateTimeOffset 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.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task DeleteAdminMessage(int id, Guid deletedBy, DateTimeOffset 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.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task HideServerBanFromNotes(int id, Guid deletedBy, DateTimeOffset 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.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task HideServerRoleBanFromNotes(int id, Guid deletedBy, DateTimeOffset 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.UtcDateTime;
await db.DbContext.SaveChangesAsync();
}
public async Task<List<IAdminRemarksRecord>> GetVisibleAdminRemarks(Guid player)
{
await using var db = await GetDb();
List<IAdminRemarksRecord> 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()).Select(MakeAdminNoteRecord));
notesCol.AddRange(await GetMessagesImpl(db, player));
notesCol.AddRange(await GetServerBansAsNotesForUser(db, player));
notesCol.AddRange(await GetGroupedServerRoleBansAsNotesForUser(db, player));
return notesCol;
}
public async Task<List<AdminWatchlistRecord>> GetActiveWatchlists(Guid player)
{
await using var db = await GetDb();
return await GetActiveWatchlistsImpl(db, player);
}
protected async Task<List<AdminWatchlistRecord>> GetActiveWatchlistsImpl(DbGuard db, Guid player)
{
var entities = 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();
return entities.Select(MakeAdminWatchlistRecord).ToList();
}
private AdminWatchlistRecord MakeAdminWatchlistRecord(AdminWatchlist entity)
{
return new AdminWatchlistRecord(entity.Id, MakeRoundRecord(entity.Round), MakePlayerRecord(entity.Player), entity.PlaytimeAtNote, entity.Message, MakePlayerRecord(entity.CreatedBy), NormalizeDatabaseTime(entity.CreatedAt), MakePlayerRecord(entity.LastEditedBy), NormalizeDatabaseTime(entity.LastEditedAt), NormalizeDatabaseTime(entity.ExpirationTime), entity.Deleted, MakePlayerRecord(entity.DeletedBy), NormalizeDatabaseTime(entity.DeletedAt));
}
public async Task<List<AdminMessageRecord>> GetMessages(Guid player)
{
await using var db = await GetDb();
return await GetMessagesImpl(db, player);
}
protected async Task<List<AdminMessageRecord>> GetMessagesImpl(DbGuard db, Guid player)
{
var entities = 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();
return entities.Select(MakeAdminMessageRecord).ToList();
}
public async Task MarkMessageAsSeen(int id, bool dismissedToo)
{
await using var db = await GetDb();
var message = await db.DbContext.AdminMessages.SingleAsync(m => m.Id == id);
message.Seen = true;
if (dismissedToo)
message.Dismissed = true;
await db.DbContext.SaveChangesAsync();
}
// These two are here because they get converted into notes later
protected async Task<List<ServerBanNoteRecord>> 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<ServerBanNoteRecord>();
foreach (var ban in bans)
{
var banNote = new ServerBanNoteRecord(
ban.Id,
MakeRoundRecord(ban.Round),
MakePlayerRecord(player),
ban.PlaytimeAtNote,
ban.Reason,
ban.Severity,
MakePlayerRecord(ban.CreatedBy),
NormalizeDatabaseTime(ban.BanTime),
MakePlayerRecord(ban.LastEditedBy),
NormalizeDatabaseTime(ban.LastEditedAt),
NormalizeDatabaseTime(ban.ExpirationTime),
ban.Hidden,
MakePlayerRecord(ban.Unban?.UnbanningAdmin == null
? null
: await db.DbContext.Player.SingleOrDefaultAsync(
p => p.UserId == ban.Unban.UnbanningAdmin.Value)),
NormalizeDatabaseTime(ban.Unban?.UnbanTime));
banNotes.Add(banNote);
}
return banNotes;
}
protected async Task<List<ServerRoleBanNoteRecord>> 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<ServerRoleBanNoteRecord> 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 ServerRoleBanNoteRecord(
firstBan.Id,
MakeRoundRecord(firstBan.Round),
MakePlayerRecord(player),
firstBan.PlaytimeAtNote,
firstBan.Reason,
firstBan.Severity,
MakePlayerRecord(firstBan.CreatedBy),
NormalizeDatabaseTime(firstBan.BanTime),
MakePlayerRecord(firstBan.LastEditedBy),
NormalizeDatabaseTime(firstBan.LastEditedAt),
NormalizeDatabaseTime(firstBan.ExpirationTime),
firstBan.Hidden,
banGroup.Select(ban => ban.RoleId.Replace(BanManager.JobPrefix, null)).ToArray(),
MakePlayerRecord(unbanningAdmin),
NormalizeDatabaseTime(firstBan.Unban?.UnbanTime)));
}
return bans;
}
#endregion
#region Job Whitelists
public async Task<bool> AddJobWhitelist(Guid player, ProtoId<JobPrototype> job)
{
await using var db = await GetDb();
var exists = await db.DbContext.RoleWhitelists
.Where(w => w.PlayerUserId == player)
.Where(w => w.RoleId == job.Id)
.AnyAsync();
if (exists)
return false;
var whitelist = new RoleWhitelist
{
PlayerUserId = player,
RoleId = job
};
db.DbContext.RoleWhitelists.Add(whitelist);
await db.DbContext.SaveChangesAsync();
return true;
}
public async Task<List<string>> GetJobWhitelists(Guid player, CancellationToken cancel)
{
await using var db = await GetDb(cancel);
return await db.DbContext.RoleWhitelists
.Where(w => w.PlayerUserId == player)
.Select(w => w.RoleId)
.ToListAsync(cancellationToken: cancel);
}
public async Task<bool> IsJobWhitelisted(Guid player, ProtoId<JobPrototype> job)
{
await using var db = await GetDb();
return await db.DbContext.RoleWhitelists
.Where(w => w.PlayerUserId == player)
.Where(w => w.RoleId == job.Id)
.AnyAsync();
}
public async Task<bool> RemoveJobWhitelist(Guid player, ProtoId<JobPrototype> job)
{
await using var db = await GetDb();
var entry = await db.DbContext.RoleWhitelists
.Where(w => w.PlayerUserId == player)
.Where(w => w.RoleId == job.Id)
.SingleOrDefaultAsync();
if (entry == null)
return false;
db.DbContext.RoleWhitelists.Remove(entry);
await db.DbContext.SaveChangesAsync();
return true;
}
#endregion
// SQLite returns DateTime as Kind=Unspecified, Npgsql actually knows for sure it's Kind=Utc.
// Normalize DateTimes here so they're always Utc. Thanks.
protected abstract DateTime NormalizeDatabaseTime(DateTime time);
[return: NotNullIfNotNull(nameof(time))]
protected DateTime? NormalizeDatabaseTime(DateTime? time)
{
return time != null ? NormalizeDatabaseTime(time.Value) : time;
}
public async Task<bool> HasPendingModelChanges()
{
await using var db = await GetDb();
return db.DbContext.Database.HasPendingModelChanges();
}
protected abstract Task<DbGuard> GetDb(
CancellationToken cancel = default,
[CallerMemberName] string? name = null);
protected void LogDbOp(string? name)
{
_opsLog.Verbose($"Running DB operation: {name ?? "unknown"}");
}
protected abstract class DbGuard : IAsyncDisposable
{
public abstract ServerDbContext DbContext { get; }
public abstract ValueTask DisposeAsync();
}
protected void NotificationReceived(DatabaseNotification notification)
{
OnNotificationReceived?.Invoke(notification);
}
public virtual void Shutdown()
{
}
}
}