using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Network;
namespace Server.Custom.Bridge
{
///
/// The staff write plane: moderation actions the website drives against the live shard.
/// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
///
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
///
/// Trust model (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
/// these commands are gated there behind admin/moderator roles. The shard trusts the
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
/// CoOwner authority. Its one hard floor is : a command refuses any
/// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
/// buggy sidecar can never ban, kick, or otherwise touch the Owner.
///
/// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
/// Attribution rides on a required "actor" field (the website staff user); every applied
/// action logs to the console and emits an admin.audit event the website persists.
///
public static class BridgeAdmin
{
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("admin.kick", OnKick);
BridgeBoot.RegisterHandler("admin.ban", OnBan);
BridgeBoot.RegisterHandler("admin.unban", OnUnban);
BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
}
// ---- admin.kick ----
/// Disconnects every live session of the target account. Target by serial or account.
private static void OnKick(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "kick";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
int kicked = KickAccountSessions(acct);
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Num("sessions", kicked)
.Str("reason", reason)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
BridgeLink.Emit(sb.End());
}
// ---- admin.ban ----
///
/// Bans an account (offline-capable) and disconnects any live sessions. A positive
/// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
/// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
///
private static void OnBan(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "ban";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
if (durationSec < 0)
durationSec = 0;
if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
durationSec = BridgeConfig.AdminBanMaxDurationSec;
if (durationSec > 0)
acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
else
acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
// SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
acct.SetTag("BanDealer", WebActor(actor));
acct.Banned = true;
int kicked = KickAccountSessions(acct);
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Num("durationSec", durationSec)
.Num("sessions", kicked)
.Str("reason", reason)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
BridgeLink.Emit(sb.End());
}
// ---- admin.unban ----
private static void OnUnban(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "unban";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
acct.Banned = false;
acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Str("reason", reason)
.End());
Ok(reqId, action, acct.Username);
}
// ---- admin.broadcast ----
private static void OnBroadcast(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "broadcast";
if (!Ready(reqId, action, actor))
return;
var text = BridgeJson.GetString(o, "text");
if (String.IsNullOrEmpty(text))
{
Err(reqId, action, "missing text");
return;
}
if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
// Default to the staff-broadcast green; callers may override.
int hue = BridgeJson.GetInt(o, "hue", 0x35);
World.Broadcast(hue, false, text);
Log(actor, action, null, text);
BridgeLink.Emit(AuditBegin(action, actor, null)
.Num("hue", hue)
.Str("text", text)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
BridgeLink.Emit(sb.End());
}
// ---- shared prologue / replies ----
/// Common gate: the write plane must be enabled and an actor must be present.
private static bool Ready(string reqId, string action, string actor)
{
if (!BridgeConfig.AdminWriteEnabled)
{
Err(reqId, action, "admin write plane disabled");
return false;
}
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return false;
}
return true;
}
private static void Ok(string reqId, string action, string target)
{
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
if (target != null) sb.Str("target", target);
BridgeLink.Emit(sb.End());
}
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("admin.error");
if (reqId != null) sb.Str("reqId", reqId);
if (action != null) sb.Str("action", action);
sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
///
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
/// connected dashboard so the website's moderation log stays complete regardless of which
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
/// BridgeEvents; see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
///
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
{
return BridgeJson.Begin("admin.audit")
.Str("origin", "web")
.Str("action", action)
.Str("actor", WebActor(actor))
.Str("target", target);
}
private static string WebActor(string actor)
{
return "web:" + actor;
}
/// Reads and length-clamps the optional reason string.
private static string Reason(Dictionary o)
{
var reason = BridgeJson.GetString(o, "reason");
if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
return reason;
}
private static void Log(string actor, string action, string target, string detail)
{
Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
WebActor(actor), action, target ?? "-", detail ?? "-");
}
// ---- target resolution & floor ----
///
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
/// "account" (username). Returns null if neither resolves to a real account. Public so the
/// account plane (unlink) resolves targets the same way the moderation plane does.
///
public static Account ResolveTargetAccount(Dictionary o)
{
var serialStr = BridgeJson.GetString(o, "serial");
if (serialStr != null)
{
var m = ResolveSerial(serialStr);
return m == null ? null : m.Account as Account;
}
var acctName = BridgeJson.GetString(o, "account");
return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
}
///
/// The one shard-side safety floor. Protects any account whose effective access level —
/// the account's own or the highest of its characters' — is at or above the configured
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
/// so the account plane (unlink) enforces the identical floor.
///
public static bool Protected(Account acct)
{
var lvl = acct.AccessLevel;
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m != null && m.AccessLevel > lvl)
lvl = m.AccessLevel;
}
return lvl >= BridgeConfig.AdminAccessFloor;
}
///
/// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
/// (rather than walking the account's characters) also catches a session parked at
/// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
/// mutates the instance set.
///
private static int KickAccountSessions(Account acct)
{
var doomed = new List();
foreach (var ns in NetState.Instances)
{
if (ns != null && ns.Account == acct)
doomed.Add(ns);
}
foreach (var ns in doomed)
ns.Dispose();
return doomed.Count;
}
private static Mobile ResolveSerial(string serialStr)
{
try
{
var s = serialStr.Trim();
int value;
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
value = Convert.ToInt32(s.Substring(2), 16);
else
value = Convert.ToInt32(s, 10);
return World.FindMobile(value);
}
catch
{
return null;
}
}
}
}