Files
tbd-station-14/Content.Server/Administration/Notes/AdminNotesManager.cs
Pieter-Jan Briers 2e6eaa45c5 Fix admin notes and database time nonsense. (#25280)
God bloody christ. There's like three layers of shit here.

So firstly, apparently we were still using Npgsql.EnableLegacyTimestampBehavior. This means that time values (which are stored UTC in the database) were converted to local time when read out. This meant they were passed around as kind Local to clients (instead of UTC in the case of SQLite). That's easy enough to fix just turn off the flag and fix the couple spots we're passing a local DateTime ez.

Oh but it turns out there's a DIFFERENT problem with SQLite: See SQLite we definitely store the DateTimes as UTC, but when Microsoft.Data.Sqlite reads them it reads them as Kind Unspecified instead of Utc.

Why are these so bad? Because the admin notes system passes DateTime instances from EF Core straight to the rest of the game code. And that means it's a PAIN IN THE ASS to run the necessary conversions to fix the DateTime instances. GOD DAMNIT now I have to make a whole new set of "Record" entities so we avoid leaking the EF Core model entities. WAAAAAAA.

Fixes #19897
2024-02-20 10:13:31 +01:00

344 lines
12 KiB
C#

using System.Text;
using System.Threading.Tasks;
using Content.Server.Administration.Managers;
using Content.Server.Database;
using Content.Server.EUI;
using Content.Server.GameTicking;
using Content.Shared.Administration;
using Content.Shared.Administration.Notes;
using Content.Shared.CCVar;
using Content.Shared.Database;
using Content.Shared.Players.PlayTimeTracking;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Player;
namespace Content.Server.Administration.Notes;
public sealed class AdminNotesManager : IAdminNotesManager, IPostInjectInit
{
[Dependency] private readonly IAdminManager _admins = default!;
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly EuiManager _euis = default!;
[Dependency] private readonly IEntitySystemManager _systems = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
public const string SawmillId = "admin.notes";
public event Action<SharedAdminNote>? NoteAdded;
public event Action<SharedAdminNote>? NoteModified;
public event Action<SharedAdminNote>? NoteDeleted;
private ISawmill _sawmill = default!;
public bool CanCreate(ICommonSession admin)
{
return CanEdit(admin);
}
public bool CanDelete(ICommonSession admin)
{
return CanEdit(admin);
}
public bool CanEdit(ICommonSession admin)
{
return _admins.HasAdminFlag(admin, AdminFlags.EditNotes);
}
public bool CanView(ICommonSession admin)
{
return _admins.HasAdminFlag(admin, AdminFlags.ViewNotes);
}
public async Task OpenEui(ICommonSession admin, Guid notedPlayer)
{
var ui = new AdminNotesEui();
_euis.OpenEui(ui, admin);
await ui.ChangeNotedPlayer(notedPlayer);
}
public async Task OpenUserNotesEui(ICommonSession player)
{
var ui = new UserNotesEui();
_euis.OpenEui(ui, player);
await ui.UpdateNotes();
}
public async Task AddAdminRemark(ICommonSession createdBy, Guid player, NoteType type, string message, NoteSeverity? severity, bool secret, DateTime? expiryTime)
{
message = message.Trim();
// There's a foreign key constraint in place here. If there's no player record, it will fail.
// Not like there's much use in adding notes on accounts that have never connected.
// You can still ban them just fine, which is why we should allow admins to view their bans with the notes panel
if (await _db.GetPlayerRecordByUserId((NetUserId) player) is null)
return;
var sb = new StringBuilder($"{createdBy.Name} added a");
if (secret && type == NoteType.Note)
{
sb.Append(" secret");
}
sb.Append($" {type} with message {message}");
switch (type)
{
case NoteType.Note:
sb.Append($" with {severity} severity");
break;
case NoteType.Message:
severity = null;
secret = false;
break;
case NoteType.Watchlist:
severity = null;
secret = true;
break;
case NoteType.ServerBan:
case NoteType.RoleBan:
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type");
}
if (expiryTime is not null)
{
sb.Append($" which expires on {expiryTime.Value.ToUniversalTime(): yyyy-MM-dd HH:mm:ss} UTC");
}
_sawmill.Info(sb.ToString());
_systems.TryGetEntitySystem(out GameTicker? ticker);
int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId;
var serverName = _config.GetCVar(CCVars.AdminLogsServerName); // This could probably be done another way, but this is fine. For displaying only.
var createdAt = DateTime.UtcNow;
var playtime = (await _db.GetPlayTimes(player)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero;
int noteId;
bool? seen = null;
switch (type)
{
case NoteType.Note:
if (severity is null)
throw new ArgumentException("Severity cannot be null for a note", nameof(severity));
noteId = await _db.AddAdminNote(roundId, player, playtime, message, severity.Value, secret, createdBy.UserId, createdAt, expiryTime);
break;
case NoteType.Watchlist:
secret = true;
noteId = await _db.AddAdminWatchlist(roundId, player, playtime, message, createdBy.UserId, createdAt, expiryTime);
break;
case NoteType.Message:
noteId = await _db.AddAdminMessage(roundId, player, playtime, message, createdBy.UserId, createdAt, expiryTime);
seen = false;
break;
case NoteType.ServerBan: // Add bans using the ban panel, not note edit
case NoteType.RoleBan:
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type");
}
var note = new SharedAdminNote(
noteId,
(NetUserId) player,
roundId,
serverName,
playtime,
type,
message,
severity,
secret,
createdBy.Name,
createdBy.Name,
createdAt,
createdAt,
expiryTime,
null,
null,
null,
seen
);
NoteAdded?.Invoke(note);
}
private async Task<SharedAdminNote?> GetAdminRemark(int id, NoteType type)
{
return type switch
{
NoteType.Note => (await _db.GetAdminNote(id))?.ToShared(),
NoteType.Watchlist => (await _db.GetAdminWatchlist(id))?.ToShared(),
NoteType.Message => (await _db.GetAdminMessage(id))?.ToShared(),
NoteType.ServerBan => (await _db.GetServerBanAsNoteAsync(id))?.ToShared(),
NoteType.RoleBan => (await _db.GetServerRoleBanAsNoteAsync(id))?.ToShared(),
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type")
};
}
public async Task DeleteAdminRemark(int noteId, NoteType type, ICommonSession deletedBy)
{
var note = await GetAdminRemark(noteId, type);
if (note == null)
{
_sawmill.Warning($"Player {deletedBy.Name} has tried to delete non-existent {type} {noteId}");
return;
}
var deletedAt = DateTime.UtcNow;
switch (type)
{
case NoteType.Note:
await _db.DeleteAdminNote(noteId, deletedBy.UserId, deletedAt);
break;
case NoteType.Watchlist:
await _db.DeleteAdminWatchlist(noteId, deletedBy.UserId, deletedAt);
break;
case NoteType.Message:
await _db.DeleteAdminMessage(noteId, deletedBy.UserId, deletedAt);
break;
case NoteType.ServerBan:
await _db.HideServerBanFromNotes(noteId, deletedBy.UserId, deletedAt);
break;
case NoteType.RoleBan:
await _db.HideServerRoleBanFromNotes(noteId, deletedBy.UserId, deletedAt);
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type");
}
_sawmill.Info($"{deletedBy.Name} has deleted {type} {noteId}");
NoteDeleted?.Invoke(note);
}
public async Task ModifyAdminRemark(int noteId, NoteType type, ICommonSession editedBy, string message, NoteSeverity? severity, bool secret, DateTime? expiryTime)
{
message = message.Trim();
var note = await GetAdminRemark(noteId, type);
// If the note doesn't exist or is the same, we skip updating it
if (note == null ||
note.Message == message &&
note.NoteSeverity == severity &&
note.Secret == secret &&
note.ExpiryTime == expiryTime)
{
return;
}
var sb = new StringBuilder($"{editedBy.Name} has modified {type} {noteId}");
if (note.Message != message)
{
sb.Append($", modified message from {note.Message} to {message}");
}
if (note.Secret != secret)
{
sb.Append($", made it {(secret ? "secret" : "visible")}");
}
if (note.NoteSeverity != severity)
{
sb.Append($", updated the severity from {note.NoteSeverity} to {severity}");
}
if (note.ExpiryTime != expiryTime)
{
sb.Append(", updated the expiry time from ");
if (note.ExpiryTime is null)
sb.Append("never");
else
sb.Append($"{note.ExpiryTime.Value.ToUniversalTime(): yyyy-MM-dd HH:mm:ss} UTC");
sb.Append(" to ");
if (expiryTime is null)
sb.Append("never");
else
sb.Append($"{expiryTime.Value.ToUniversalTime(): yyyy-MM-dd HH:mm:ss} UTC");
}
_sawmill.Info(sb.ToString());
var editedAt = DateTime.UtcNow;
switch (type)
{
case NoteType.Note:
if (severity is null)
throw new ArgumentException("Severity cannot be null for a note", nameof(severity));
await _db.EditAdminNote(noteId, message, severity.Value, secret, editedBy.UserId, editedAt, expiryTime);
break;
case NoteType.Watchlist:
await _db.EditAdminWatchlist(noteId, message, editedBy.UserId, editedAt, expiryTime);
break;
case NoteType.Message:
await _db.EditAdminMessage(noteId, message, editedBy.UserId, editedAt, expiryTime);
break;
case NoteType.ServerBan:
if (severity is null)
throw new ArgumentException("Severity cannot be null for a ban", nameof(severity));
await _db.EditServerBan(noteId, message, severity.Value, expiryTime, editedBy.UserId, editedAt);
break;
case NoteType.RoleBan:
if (severity is null)
throw new ArgumentException("Severity cannot be null for a role ban", nameof(severity));
await _db.EditServerRoleBan(noteId, message, severity.Value, expiryTime, editedBy.UserId, editedAt);
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown note type");
}
var newNote = note with
{
Message = message,
NoteSeverity = severity,
Secret = secret,
LastEditedAt = editedAt,
EditedByName = editedBy.Name,
ExpiryTime = expiryTime
};
NoteModified?.Invoke(newNote);
}
public async Task<List<IAdminRemarksRecord>> GetAllAdminRemarks(Guid player)
{
return await _db.GetAllAdminRemarks(player);
}
public async Task<List<IAdminRemarksRecord>> GetVisibleRemarks(Guid player)
{
if (_config.GetCVar(CCVars.SeeOwnNotes))
{
return await _db.GetVisibleAdminNotes(player);
}
_sawmill.Warning($"Someone tried to call GetVisibleNotes for {player} when see_own_notes was false");
return new List<IAdminRemarksRecord>();
}
public async Task<List<AdminWatchlistRecord>> GetActiveWatchlists(Guid player)
{
return await _db.GetActiveWatchlists(player);
}
public async Task<List<AdminMessageRecord>> GetNewMessages(Guid player)
{
return await _db.GetMessages(player);
}
public async Task MarkMessageAsSeen(int id)
{
await _db.MarkMessageAsSeen(id);
}
public void PostInject()
{
_sawmill = _logManager.GetSawmill(SawmillId);
}
}