mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-20 01:46:07 +00:00
Some support and improvements
This commit is contained in:
1 parent
f72a8aa59f
commit
34e6751d33
10 files changed
+171
-11
No files matched your search
Binary file not shown.
@@ -68,6 +68,23 @@ public sealed class PlayerLogStore : JsonDataStore<List<PlayerLogEntry>>
|
||||
public List<PlayerLogEntry> GetByClient(int clientId) =>
|
||||
_entries.Where(e => e.ClientId == clientId).ToList();
|
||||
|
||||
public int GetCount() => _count;
|
||||
|
||||
public int GetCountByClient(int clientId) =>
|
||||
_entries.Count(e => e.ClientId == clientId);
|
||||
|
||||
public List<PlayerLogEntry> GetPage(int page, int pageSize)
|
||||
{
|
||||
var skip = (page - 1) * pageSize;
|
||||
return _entries.Skip(skip).Take(pageSize).ToList();
|
||||
}
|
||||
|
||||
public List<PlayerLogEntry> GetPageByClient(int clientId, int page, int pageSize)
|
||||
{
|
||||
var skip = (page - 1) * pageSize;
|
||||
return _entries.Where(e => e.ClientId == clientId).Skip(skip).Take(pageSize).ToList();
|
||||
}
|
||||
|
||||
public List<int> GetLoggedClientIds() =>
|
||||
_entries.Where(e => e.ClientId.HasValue).Select(e => e.ClientId!.Value).Distinct().ToList();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Empostor.Api.Innersloth.Generator\Empostor.Api.Innersloth.Generator.csproj" />
|
||||
<ProjectReference Include="..\Empostor.Api\Empostor.Api.csproj" />
|
||||
<ProjectReference Include="..\Empostor.Plugins.PlayerLog\Empostor.Plugins.PlayerLog.csproj" />
|
||||
<ProjectReference Include="..\NextFast.Hazel-New\Next.Hazel.Abstractions\Next.Hazel.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\NextFast.Hazel-New\Next.Hazel\Next.Hazel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -48,6 +48,7 @@ namespace Empostor.Server.Http
|
||||
private readonly IpGeolocationService _ipGeo;
|
||||
private readonly AdminExtensionRegistry _extensions;
|
||||
private readonly AdminThemeRegistry _themes;
|
||||
private readonly PlayerLogStore? _playerLogs;
|
||||
private readonly string _passwordHash;
|
||||
private static readonly ConcurrentDictionary<string, (int Count, DateTime FirstAttempt)> _loginFailures = new();
|
||||
|
||||
@@ -63,7 +64,8 @@ namespace Empostor.Server.Http
|
||||
IOptions<HttpServerConfig> httpServerConfig,
|
||||
IpGeolocationService ipGeo,
|
||||
AdminExtensionRegistry extensions,
|
||||
AdminThemeRegistry themes)
|
||||
AdminThemeRegistry themes,
|
||||
PlayerLogStore? playerLogs = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_gameManager = gameManager;
|
||||
@@ -77,6 +79,7 @@ namespace Empostor.Server.Http
|
||||
_ipGeo = ipGeo;
|
||||
_extensions = extensions;
|
||||
_themes = themes;
|
||||
_playerLogs = playerLogs;
|
||||
_passwordHash = ComputeHash(_config.Password);
|
||||
|
||||
if (string.IsNullOrEmpty(_config.Password)
|
||||
@@ -681,6 +684,82 @@ namespace Empostor.Server.Http
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/admin/player/logs/clients")]
|
||||
public IActionResult GetPlayerLogClients()
|
||||
{
|
||||
if (!IsAuthenticated()) return Unauthorized();
|
||||
if (_playerLogs == null) return Ok(Array.Empty<object>());
|
||||
|
||||
var ids = _playerLogs.GetLoggedClientIds();
|
||||
var result = ids.Select(id => new
|
||||
{
|
||||
id,
|
||||
name = _playerLogs.GetLatestName(id) ?? $"Player#{id}",
|
||||
friendCode = _playerLogs.GetLatestFriendCode(id) ?? "—",
|
||||
}).ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("/api/admin/player/logs")]
|
||||
public IActionResult GetPlayerLogs([FromQuery] int? clientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 100)
|
||||
{
|
||||
if (!IsAuthenticated()) return Unauthorized();
|
||||
if (_playerLogs == null) return Ok(new { page = 1, pageSize = 0, total = 0, totalPages = 0, entries = Array.Empty<object>() });
|
||||
|
||||
pageSize = Math.Clamp(pageSize, 1, 500);
|
||||
var total = clientId.HasValue ? _playerLogs.GetCountByClient(clientId.Value) : _playerLogs.GetCount();
|
||||
var totalPages = (int)Math.Ceiling((double)total / pageSize);
|
||||
var entries = clientId.HasValue
|
||||
? _playerLogs.GetPageByClient(clientId.Value, page, pageSize)
|
||||
: _playerLogs.GetPage(page, pageSize);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
entries = entries.Select(e => new
|
||||
{
|
||||
time = e.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
type = e.Type,
|
||||
playerName = e.PlayerName ?? "—",
|
||||
friendCode = e.FriendCode ?? "—",
|
||||
gameCode = e.GameCode ?? "—",
|
||||
detail = e.Detail ?? "",
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/admin/player/logs/export")]
|
||||
public IActionResult ExportPlayerLogs([FromQuery] int? clientId)
|
||||
{
|
||||
if (!IsAuthenticated()) return Unauthorized();
|
||||
if (_playerLogs == null) return File(Array.Empty<byte>(), "application/json", "player_logs.json");
|
||||
|
||||
var bytes = clientId.HasValue ? _playerLogs.ExportJson(clientId.Value) : _playerLogs.ExportJson();
|
||||
return File(bytes, "application/json", "player_logs.json");
|
||||
}
|
||||
|
||||
[HttpPost("/api/admin/player/logs/clear")]
|
||||
public IActionResult ClearPlayerLogs([FromQuery] string? olderThan)
|
||||
{
|
||||
if (!IsAuthenticated()) return Unauthorized();
|
||||
if (_playerLogs == null) return Ok(new { cleared = true });
|
||||
|
||||
DateTime? cutoff = olderThan?.ToLowerInvariant() switch
|
||||
{
|
||||
"1h" => DateTime.UtcNow.AddHours(-1),
|
||||
"24h" => DateTime.UtcNow.AddDays(-1),
|
||||
"7d" => DateTime.UtcNow.AddDays(-7),
|
||||
"30d" => DateTime.UtcNow.AddDays(-30),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
_playerLogs.Clear(cutoff);
|
||||
return Ok(new { cleared = true });
|
||||
}
|
||||
|
||||
private IGame? FindGame(string code)
|
||||
{
|
||||
try { return _gameManager.Find(new GameCode(code.ToUpperInvariant())); }
|
||||
|
||||
@@ -1150,6 +1150,8 @@ internal static class AdminTemplateDefaults
|
||||
<tr><td colspan="5" class="empty">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="pl-pager" style="display:flex;align-items:center;justify-content:center;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||||
</div>
|
||||
</div>
|
||||
<div id="p-hp" class="pnl">
|
||||
<div class="form">
|
||||
@@ -1373,25 +1375,27 @@ internal static class AdminTemplateDefaults
|
||||
</div>${!data.upToDate ? '<p style="font-size:12px;color:var(--y);margin-top:10px">' + _('updates.update_hint', 'A new version is available. Update manually.') + '</p>' : ''}`;
|
||||
}
|
||||
|
||||
async function fPlayerLogs() {
|
||||
let plPage = 1;
|
||||
const plPageSize = 100;
|
||||
|
||||
async function fPlayerLogs(page) {
|
||||
if (page !== undefined) plPage = page;
|
||||
const sel = document.getElementById('pl-client');
|
||||
const curVal = sel.value;
|
||||
const typeSel = document.getElementById('pl-type');
|
||||
const curType = typeSel.value;
|
||||
const tb = document.getElementById('pl-t');
|
||||
|
||||
// populate client list if empty
|
||||
if (!sel.hasAttribute('data-loaded')) {
|
||||
try {
|
||||
const { data: cls } = await api('GET', '/api/admin/player/logs/clients');
|
||||
sel.innerHTML = '<option value="" data-i18n="player_logs.select_client">' + _('player_logs.select_client', 'Select a player...') + '</option>';
|
||||
cls.forEach(c => { sel.innerHTML += `<option value="${c.clientId}">#${c.clientId} ${e(c.name)} (${e(c.friendCode)})</option>`; });
|
||||
cls.forEach(c => { sel.innerHTML += `<option value="${c.id}">#${c.id} ${e(c.name)} (${e(c.friendCode)})</option>`; });
|
||||
sel.value = curVal;
|
||||
sel.setAttribute('data-loaded', '1');
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// populate type filter
|
||||
if (!typeSel.hasAttribute('data-loaded')) {
|
||||
const types = ['Chat','Report','Murder','Exile','Vote','Task','Vent','Meeting','Connect','Game','Join','Leave'];
|
||||
types.forEach(t => { typeSel.innerHTML += `<option value="${t}">${t}</option>`; });
|
||||
@@ -1399,22 +1403,48 @@ internal static class AdminTemplateDefaults
|
||||
typeSel.setAttribute('data-loaded', '1');
|
||||
}
|
||||
|
||||
const url = sel.value ? `/api/admin/player/logs?clientId=${sel.value}` : '/api/admin/player/logs';
|
||||
const { data: logs } = await api('GET', url);
|
||||
let items = logs;
|
||||
let url = sel.value
|
||||
? `/api/admin/player/logs?clientId=${sel.value}&page=${plPage}&pageSize=${plPageSize}`
|
||||
: `/api/admin/player/logs?page=${plPage}&pageSize=${plPageSize}`;
|
||||
const { data } = await api('GET', url);
|
||||
let items = data.entries || [];
|
||||
if (curType) items = items.filter(l => l.type === curType);
|
||||
|
||||
if (!items.length) {
|
||||
tb.innerHTML = '<tr><td colspan="5" class="empty">' + _('player_logs.no_logs', 'No logs.') + '</td></tr>';
|
||||
document.getElementById('pl-pager').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const typeColor = { Chat: 'var(--a)', Report: 'var(--r)', Murder: 'var(--r)', Exile: 'var(--o)', Vote: 'var(--y)', Task: 'var(--g)', Vent: 'var(--p)', Meeting: 'var(--m)', Connect: 'var(--g)', Game: 'var(--m)', Join: 'var(--g)', Leave: 'var(--o)' };
|
||||
tb.innerHTML = items.map(l => `<tr>
|
||||
<td style="font-size:11px;color:var(--m);white-space:nowrap">${e(l.time)}</td>
|
||||
<td><span style="color:${typeColor[l.type] ?? 'var(--t)'};font-weight:600;font-size:12px">${e(l.type)}</span></td>
|
||||
<td>${l.clientId ? '<b>' + e(l.playerName) + '</b><br><span class="fc">' + e(l.friendCode) + '</span>' : '—'}</td>
|
||||
<td><b>${e(l.playerName)}</b><br><span class="fc">${e(l.friendCode)}</span></td>
|
||||
<td>${l.gameCode && l.gameCode !== '—' ? '<span class="code" style="font-size:11px">' + e(l.gameCode) + '</span>' : '—'}</td>
|
||||
<td style="font-size:12px">${e(l.detail)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
renderPlPager(data.totalPages, data.total);
|
||||
}
|
||||
|
||||
function renderPlPager(totalPages, total) {
|
||||
const pg = document.getElementById('pl-pager');
|
||||
if (totalPages <= 1) { pg.innerHTML = ''; return; }
|
||||
let h = `<span style="font-size:12px;color:var(--m)">${total} 条</span>`;
|
||||
if (plPage > 1) h += `<button class="bsm" onclick="fPlayerLogs(${plPage - 1})">‹</button>`;
|
||||
const range = 5;
|
||||
let start = Math.max(1, plPage - range);
|
||||
let end = Math.min(totalPages, plPage + range);
|
||||
if (start > 1) h += `<button class="bsm" onclick="fPlayerLogs(1)">1</button>`;
|
||||
if (start > 2) h += `<span style="color:var(--m)">…</span>`;
|
||||
for (let i = start; i <= end; i++) {
|
||||
h += `<button class="bsm" style="${i === plPage ? 'background:var(--p);color:#fff' : ''}" onclick="fPlayerLogs(${i})">${i}</button>`;
|
||||
}
|
||||
if (end < totalPages - 1) h += `<span style="color:var(--m)">…</span>`;
|
||||
if (end < totalPages) h += `<button class="bsm" onclick="fPlayerLogs(${totalPages})">${totalPages}</button>`;
|
||||
if (plPage < totalPages) h += `<button class="bsm" onclick="fPlayerLogs(${plPage + 1})">›</button>`;
|
||||
pg.innerHTML = h;
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
|
||||
@@ -238,7 +238,7 @@ namespace Empostor.Server.Net
|
||||
// Malformed / unauthenticated UDP handshake. Dispose the
|
||||
// underlying connection so an attacker's socket is closed and
|
||||
// removed from the listener — never leave it half-initialised.
|
||||
_logger.LogDebug(ex, "Rejecting invalid UDP handshake on port {Port}", port);
|
||||
_logger.LogWarning(ex, "Rejecting invalid UDP handshake on port {Port}", port);
|
||||
connection?.DisposeInnerConnection();
|
||||
e.Connection.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>false</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<ProjectGuid>1b0390af-a4f3-4fe4-b093-708b0135c0b3</ProjectGuid>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -474,6 +474,12 @@
|
||||
"empostor.api.innersloth.generator": {
|
||||
"type": "Project"
|
||||
},
|
||||
"empostor.plugins.playerlog": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Empostor.Api": "[2.0.0-dev, )"
|
||||
}
|
||||
},
|
||||
"NextFast.Hazel": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
|
||||
@@ -179,7 +179,11 @@ public class UdpConnectionListener : NetworkConnectionListener
|
||||
}
|
||||
|
||||
// Check for malformed connection attempts
|
||||
if (data.Buffer[0] != (byte)UdpSendOption.Hello) return;
|
||||
if (data.Buffer[0] != (byte)UdpSendOption.Hello)
|
||||
{
|
||||
Logger.Debug("Rejected non-Hello packet from {0} (first byte: 0x{1:X2})", data.RemoteEndPoint, data.Buffer[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rateLimit.
|
||||
if (!_connectionRateLimit.IsAllowed(data.RemoteEndPoint.Address))
|
||||
|
||||
@@ -61,12 +61,14 @@ public sealed class UdpPacketFilter : IDisposable
|
||||
// 1. Size check (cheapest, no state).
|
||||
if (length < MinPacketLength || length > MaxPacketLength)
|
||||
{
|
||||
Logger.Debug("UdpPacketFilter dropped packet from {Ip}: length {Length} outside [{Min}, {Max}]", ip, length, MinPacketLength, MaxPacketLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Send-option byte check (layer-7, no state).
|
||||
if (!_validOption[buffer[0]])
|
||||
{
|
||||
Logger.Debug("UdpPacketFilter dropped packet from {Ip}: invalid first byte 0x{Byte:X2}", ip, buffer[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user