using System; using System.Collections.Generic; using Server.Accounting; using Server.Commands; namespace Server.Custom.Bridge { /// /// Ties a game account to a website account. /// /// Flow: /// 1. In game, the player runs [link. The shard mints a short, one-time, expiring code, /// holds it in memory keyed to their account, and emits link.request to the sidecar. /// 2. The player enters that code on the website. The website tells the sidecar, which /// sends link.confirm inbound. /// 3. The shard validates the code, writes Account tag "WebsiteUserId", drops the code, /// and replies link.ok. The tag persists to accounts.xml across restarts. /// /// The code table and the account write both live on the Core thread. The websiteUserId in /// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md ยง2); if the /// sidecar ever moves off-host, gate it behind a shared secret. /// public static class BridgeAccountLink { private const string Tag = "WebsiteUserId"; // Unambiguous alphabet: no O/0, I/1, so a player reading a code aloud can't get it wrong. private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private const int CodeLength = 6; private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); private sealed class Pending { public string Account; public DateTime Expires; } // code -> pending link. Core-thread only. private static readonly Dictionary _codes = new Dictionary(StringComparer.OrdinalIgnoreCase); // account -> last [link time, to rate-limit code spam. private static readonly Dictionary _lastRequest = new Dictionary(StringComparer.OrdinalIgnoreCase); public static void Initialize() { if (!BridgeConfig.Enabled) return; CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand); CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand); BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm); // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever. Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), PurgeExpired); } /// Reads the linked website id for an account, or null. Used to enrich events. public static string WebIdFor(Account acct) { if (acct == null) return null; return acct.GetTag(Tag); } // ---- [link ---- [Usage("link")] [Description("Links this game account to your website account via a one-time code.")] private static void OnLinkCommand(CommandEventArgs e) { RequestLink(e.Mobile); } /// /// Mints a one-time code for the mobile's account and emits link.request. This is the /// body of the [link command, exposed so it can be driven in tests without a client. /// public static void RequestLink(Mobile m) { if (m == null) return; var acct = m.Account as Account; if (acct == null) { m.SendMessage("Bridge: no account on this character."); return; } var existing = acct.GetTag(Tag); if (existing != null) { m.SendMessage("Your account is already linked to website user {0}.", existing); return; } DateTime last; if (_lastRequest.TryGetValue(acct.Username, out last) && DateTime.UtcNow - last < RequestCooldown) { m.SendMessage("Please wait a moment before requesting another link code."); return; } // One outstanding code per account: drop any prior code so only the newest works. DropCodesFor(acct.Username); var code = MintCode(); _codes[code] = new Pending { Account = acct.Username, Expires = DateTime.UtcNow + CodeTtl }; _lastRequest[acct.Username] = DateTime.UtcNow; BridgeLink.Emit(BridgeJson.Begin("link.request") .Str("code", code) .Str("account", acct.Username) .Str("char", m.Name) .Num("ttlSec", (long)CodeTtl.TotalSeconds) .End()); var url = BridgeConfig.LinkUrl; m.SendMessage(0x35, "Link code: {0}", code); m.SendMessage("Enter it at {0} within {1} minutes to link your account.", url, (int)CodeTtl.TotalMinutes); } // ---- [unlink ---- [Usage("unlink")] [Description("Unlinks this game account from your website account.")] private static void OnUnlinkCommand(CommandEventArgs e) { Unlink(e.Mobile); } /// /// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so /// the website can reconcile a player-initiated unlink. Player-scoped (own account only), /// so it needs no access floor. After unlinking, [link works again. /// public static void Unlink(Mobile m) { if (m == null) return; var acct = m.Account as Account; if (acct == null) { m.SendMessage("Bridge: no account on this character."); return; } var existing = acct.GetTag(Tag); if (existing == null) { m.SendMessage("Your account is not linked to a website account."); return; } acct.RemoveTag(Tag); DropCodesFor(acct.Username); // drop any pending codes so nothing dangles BridgeLink.Emit(BridgeJson.Begin("account.unlinked") .Str("origin", "in-game") .Str("account", acct.Username) .Str("websiteUserId", existing) .Str("char", m.Name) .End()); m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing); } // ---- inbound link.confirm ---- private static void OnLinkConfirm(Dictionary o) { var code = BridgeJson.GetString(o, "code"); var webId = BridgeJson.GetString(o, "websiteUserId"); if (code == null || webId == null) { Reply("link.error", null, null, "malformed link.confirm"); return; } Pending pending; if (!_codes.TryGetValue(code, out pending)) { Reply("link.error", code, null, "unknown or expired code"); return; } _codes.Remove(code); if (DateTime.UtcNow > pending.Expires) { Reply("link.error", code, pending.Account, "code expired"); return; } var acct = Accounting.Accounts.GetAccount(pending.Account) as Account; if (acct == null) { Reply("link.error", code, pending.Account, "account no longer exists"); return; } // Persisted to accounts.xml on the next world save. acct.SetTag(Tag, webId); DropCodesFor(pending.Account); Reply("link.ok", code, pending.Account, null, webId); NotifyOnline(acct, webId); } // ---- helpers ---- private static void Reply(string kind, string code, string account, string reason, string webId = null) { var sb = BridgeJson.Begin(kind); if (code != null) sb.Str("code", code); if (account != null) sb.Str("account", account); if (webId != null) sb.Str("websiteUserId", webId); if (reason != null) sb.Str("reason", reason); BridgeLink.Emit(sb.End()); } private static void NotifyOnline(Account acct, string webId) { for (int i = 0; i < acct.Length; i++) { var m = acct[i]; if (m != null && m.NetState != null) m.SendMessage(0x40, "Your account is now linked to website user {0}.", webId); } } private static string MintCode() { // Avoid a collision with an outstanding code, though at 32^6 it is astronomically rare. for (int attempt = 0; attempt < 8; attempt++) { var chars = new char[CodeLength]; for (int i = 0; i < CodeLength; i++) chars[i] = Alphabet[Utility.Random(Alphabet.Length)]; var code = new string(chars); if (!_codes.ContainsKey(code)) return code; } // Fall back to a guaranteed-unique code. return "L" + DateTime.UtcNow.Ticks.ToString("X").Substring(0, CodeLength - 1); } private static void DropCodesFor(string account) { var doomed = new List(); foreach (var kv in _codes) { if (String.Equals(kv.Value.Account, account, StringComparison.OrdinalIgnoreCase)) doomed.Add(kv.Key); } foreach (var c in doomed) _codes.Remove(c); } private static void PurgeExpired() { try { var now = DateTime.UtcNow; var doomed = new List(); foreach (var kv in _codes) { if (now > kv.Value.Expires) doomed.Add(kv.Key); } foreach (var c in doomed) _codes.Remove(c); } catch (Exception ex) { Console.WriteLine("[Bridge] link purge threw: {0}", ex.Message); } } } }