Some support and improvements

This commit is contained in:
FangkuaiYa committed 2026-09-12 02:33:43 +08:00
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>
+80 -1
View File
@@ -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() {
+1 -1
View File
@@ -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>
+6
View File
@@ -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;
}