diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index 6b3ef49ae..f4c04bf9b 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -3,6 +3,7 @@ using Server.Engines.Quests.Collector; using Server.Items; using Server.Mobiles; using Server.Spells; +using Server.Engines.AntiBot; namespace Server.Engines.Harvest { @@ -492,6 +493,17 @@ namespace Server.Engines.Harvest return false; } + if (Utility.Random(100) < 1) + { + if (!AntiBotSystem.CheckPlayer(from, () => + { + from.Target = new HarvestTarget(tool, this); + })) + { + return false; // antibot challenge sent + } + } + from.SendLocalizedMessage(500974); // What water do you want to fish in? return true; } diff --git a/Projects/UOContent/Engines/Harvest/Lumberjacking.cs b/Projects/UOContent/Engines/Harvest/Lumberjacking.cs index 9640f68d8..b850d07dd 100644 --- a/Projects/UOContent/Engines/Harvest/Lumberjacking.cs +++ b/Projects/UOContent/Engines/Harvest/Lumberjacking.cs @@ -1,6 +1,7 @@ using System; using Server.Items; using Server.Targeting; +using Server.Engines.AntiBot; namespace Server.Engines.Harvest { @@ -185,6 +186,17 @@ namespace Server.Engines.Harvest { from.RevealingAction(); } + + if (Utility.Random(100) < 1) + { + if (!AntiBotSystem.CheckPlayer(from, () => + { + from.Target = new HarvestTarget(tool, this); + })) + { + // antibot challenge sent + } + } } public static void Initialize() diff --git a/Projects/UOContent/Engines/Harvest/Mining.cs b/Projects/UOContent/Engines/Harvest/Mining.cs index dd7f5c747..33970951d 100644 --- a/Projects/UOContent/Engines/Harvest/Mining.cs +++ b/Projects/UOContent/Engines/Harvest/Mining.cs @@ -2,6 +2,7 @@ using System; using Server.Items; using Server.Mobiles; using Server.Targeting; +using Server.Engines.AntiBot; namespace Server.Engines.Harvest { @@ -444,6 +445,17 @@ namespace Server.Engines.Harvest return false; } + if (Utility.Random(100) < 1) + { + if (!AntiBotSystem.CheckPlayer(from, () => + { + from.Target = new HarvestTarget(tool, this); + })) + { + // antibot challenge sent + } + } + from.SendLocalizedMessage(503033); // Where do you wish to dig? return true; } diff --git a/Projects/UOContent/Systems/AntiBotSystem/AntiBotGump.cs b/Projects/UOContent/Systems/AntiBotSystem/AntiBotGump.cs new file mode 100644 index 000000000..559692a39 --- /dev/null +++ b/Projects/UOContent/Systems/AntiBotSystem/AntiBotGump.cs @@ -0,0 +1,77 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AntiBotGump.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using Server.Gumps; +using Server.Network; + +namespace Server.Engines.AntiBot +{ + public class AntiBotGump : Gump + { + public readonly Mobile _mobile; + public readonly int _code; + + public AntiBotGump(Mobile mobile, int code) : base(150, 150) + { + _mobile = mobile; + _code = code; + + Closable = false; + Disposable = false; + Draggable = true; + Resizable = false; + + AddPage(0); + AddBackground(0, 0, 350, 220, 9270); + + AddHtml(20, 20, 310, 25, "
Anti-Bot Verification
", false, false); + AddHtml(20, 50, 310, 40, "
Please enter the following number:
", false, false); + AddHtml(20, 55, 310, 40, $"

{code}
", false, false); + AddHtml(20, 100, 310, 50, "You have 5 minutes to input the correct number.
Incorrect numbers, cancellations, or timeouts will disconnect you from the server.", false, false); + + AddBackground(20, 160, 200, 25, 3000); + AddTextEntry(25, 165, 190, 20, 0, 0, ""); + + AddButton(230, 160, 4005, 4007, 1, GumpButtonType.Reply, 0); // Submit + AddHtml(230, 190, 40, 20, "
Submit
", false, false); + + AddButton(290, 160, 4017, 4019, 0, GumpButtonType.Reply, 0); // Cancel + AddHtml(290, 190, 40, 20, "
Cancel
", false, false); + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + var from = sender?.Mobile; + if (from == null) + { + return; + } + + bool cancelled = info.ButtonID == 0; + int enteredCode = 0; + + if (!cancelled) + { + var textEntry = info.GetTextEntry(0); + if (textEntry != null && !int.TryParse(textEntry.Trim(), out enteredCode)) + { + enteredCode = -1; + } + } + + AntiBotSystem.ProcessResponse(from, enteredCode, cancelled); + } + } +} diff --git a/Projects/UOContent/Systems/AntiBotSystem/AntiBotSystem.cs b/Projects/UOContent/Systems/AntiBotSystem/AntiBotSystem.cs new file mode 100644 index 000000000..c2224d105 --- /dev/null +++ b/Projects/UOContent/Systems/AntiBotSystem/AntiBotSystem.cs @@ -0,0 +1,233 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AntiBotSystem.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Server.Gumps; +using Server.Mobiles; + +namespace Server.Engines.AntiBot +{ + public static class AntiBotSystem + { + private class AntiBotChallenge + { + public string ChallengeId { get; set; } + public DateTime ChallengeExpiry { get; set; } + public Action SuccessCallback { get; set; } + public Timer TimeoutTimer { get; set; } + public bool UseTurnstile { get; set; } + public int FallbackCode { get; set; } + } + + private static readonly Dictionary _activeChallenges = new(); + private static readonly HttpClient _httpClient = new(); + + // enable or disable the entire anti-bot verification system + public static bool Enabled { get; set; } = true; + + // if set to false (default) = uses a number matching verification + // if set to true = uses Cloudflare's Turnstile verification + public static bool UseTurnstile { get; set; } = false; + + // Cloudflare Turnstile + // secret key from your Cloudflare account (https://dash.cloudflare.com/login) + public static string TurnstileSecretKey { get; set; } = "YOUR_SECRET_KEY"; + + // the base URL where the widget is hosted (must support HTTPS) + // view the docs here: https://developers.cloudflare.com/turnstile/ + public static string VerificationUrl { get; set; } = "https://yourwebserver.com/verify"; + + // timeout before disconnecting the user (applies to both Turnstile and number match verification) + public static TimeSpan ChallengeTimeout { get; set; } = TimeSpan.FromMinutes(5); + + public static bool CheckPlayer(Mobile from, Action onSuccess) + { + if (!Enabled || from is not PlayerMobile) + { + return true; + } + + CleanupExpiredChallenges(); + + if (_activeChallenges.ContainsKey(from)) + { + return false; + } + + var challengeId = Guid.NewGuid().ToString("N")[..8]; + var challenge = new AntiBotChallenge + { + ChallengeId = challengeId, + ChallengeExpiry = Core.Now.Add(ChallengeTimeout), + SuccessCallback = onSuccess, + UseTurnstile = UseTurnstile, + FallbackCode = Utility.RandomMinMax(1000, 9999) + }; + + challenge.TimeoutTimer = Timer.DelayCall(ChallengeTimeout, () => + { + if (_activeChallenges.ContainsKey(from)) + { + _activeChallenges.Remove(from); + from.SendMessage("Anti-Bot: Verification timed out. Disconnecting..."); + from.NetState?.Disconnect("Anti-Bot: Verification failed by timing out."); + } + }); + + _activeChallenges[from] = challenge; + + if (UseTurnstile) + { + from.CloseGump(); + from.SendGump(new AntiBotTurnstileGump(from, challengeId)); + } + else + { + from.CloseGump(); + from.SendGump(new AntiBotGump(from, challenge.FallbackCode)); + } + + return false; + } + + public static async Task VerifyTurnstileToken(string token) + { + var formData = new List> + { + new("secret", TurnstileSecretKey), + new("response", token) + }; + + var response = await _httpClient.PostAsync( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + new FormUrlEncodedContent(formData) + ); + + var jsonResponse = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(jsonResponse); + + return result?.Success == true; + } + + internal static void ProcessResponse(Mobile from, int enteredCode, bool cancelled) + { + if (!_activeChallenges.TryGetValue(from, out var challenge)) + { + return; + } + + challenge.TimeoutTimer?.Stop(); + _activeChallenges.Remove(from); + + if (cancelled) + { + from.SendMessage("Anti-Bot: Verification cancelled. Disconnecting..."); + from.NetState?.Disconnect("Anti-Bot: Verification failed by cancellation."); + return; + } + + if (enteredCode == challenge.FallbackCode) + { + from.SendMessage("Anti-Bot: Verification successful!"); + challenge.SuccessCallback?.Invoke(); + } + else + { + from.SendMessage("Anti-Bot: Incorrect number. Disconnecting..."); + from.NetState?.Disconnect("Anti-Bot: Verification failed by incorrect number."); + } + } + + internal static async void ProcessTurnstileResponse(Mobile from, string token) + { + if (!_activeChallenges.TryGetValue(from, out var challenge)) + { + return; + } + + challenge.TimeoutTimer?.Stop(); + _activeChallenges.Remove(from); + + var isValid = await VerifyTurnstileToken(token); + + if (isValid) + { + from.SendMessage("Anti-Bot: Verification successful!"); + challenge.SuccessCallback?.Invoke(); + } + else + { + from.SendMessage("Anti-Bot: Verification failed. Disconnecting..."); + from.NetState?.Disconnect("Anti-Bot: Verification failed."); + } + } + + public static void ProcessTurnstileVerification(string challengeId, string token) + { + Mobile targetMobile = null; + foreach (var kvp in _activeChallenges) + { + if (kvp.Value.ChallengeId == challengeId) + { + targetMobile = kvp.Key; + break; + } + } + + if (targetMobile != null) + { + ProcessTurnstileResponse(targetMobile, token); + } + } + + private static void CleanupExpiredChallenges() + { + var now = Core.Now; + var toRemove = new List(); + + foreach (var kvp in _activeChallenges) + { + if (kvp.Value.ChallengeExpiry < now) + { + kvp.Value.TimeoutTimer?.Stop(); + toRemove.Add(kvp.Key); + } + } + + foreach (var mobile in toRemove) + { + _activeChallenges.Remove(mobile); + } + } + + public static void CancelChallenge(Mobile from) + { + if (_activeChallenges.TryGetValue(from, out var challenge)) + { + challenge.TimeoutTimer?.Stop(); + _activeChallenges.Remove(from); + } + } + + private class TurnstileResponse + { + public bool Success { get; set; } + } + } +} \ No newline at end of file diff --git a/Projects/UOContent/Systems/AntiBotSystem/AntiBotTurnstileGump.cs b/Projects/UOContent/Systems/AntiBotSystem/AntiBotTurnstileGump.cs new file mode 100644 index 000000000..22cc4c3ab --- /dev/null +++ b/Projects/UOContent/Systems/AntiBotSystem/AntiBotTurnstileGump.cs @@ -0,0 +1,85 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AntiBotTurnstileGump.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using Server.Gumps; +using Server.Network; +using System.Diagnostics; + +namespace Server.Engines.AntiBot +{ + public class AntiBotTurnstileGump : Gump + { + private readonly Mobile _from; + private readonly string _challengeId; + + public AntiBotTurnstileGump(Mobile from, string challengeId) : base(150, 150) + { + _from = from; + _challengeId = challengeId; + + Closable = false; + Disposable = false; + Draggable = true; + Resizable = false; + + AddPage(0); + AddBackground(0, 0, 350, 220, 9270); + + AddHtml(20, 20, 310, 25, "
Anti-Bot Verification
", false, false); + AddHtml(20, 50, 310, 40, "
Please verify by web browser:
", false, false); + AddHtml(20, 75, 310, 40, $"
{AntiBotSystem.VerificationUrl}?id={challengeId}
", false, false); + AddHtml(20, 100, 310, 50, "You have 5 minutes to complete verification.
Cancelling or timing out will disconnect you from the server.", false, false); + + AddButton(30, 170, 4005, 4007, 1, GumpButtonType.Reply, 0); // Open Browser + AddHtml(60, 173, 100, 20, "
Open Browser
", false, false); + + AddButton(230, 170, 4017, 4019, 0, GumpButtonType.Reply, 0); // Cancel + AddHtml(260, 173, 60, 20, "
Cancel
", false, false); + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + var from = sender?.Mobile; + if (from == null) + { + return; + } + + switch (info.ButtonID) + { + case 1: // Open Browser + try + { + var url = $"{AntiBotSystem.VerificationUrl}?id={_challengeId}"; + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + _from.SendMessage("Browser opened. Complete verification and return to game."); + _from.SendGump(this); // Keep gump open + } + catch + { + _from.SendMessage("Could not open browser. Please visit the verification URL manually."); + _from.SendGump(this); // Keep gump open + } + break; + + case 0: // Cancel + AntiBotSystem.CancelChallenge(_from); + _from.SendMessage("Anti-Bot: Verification cancelled. Disconnecting..."); + _from.NetState?.Disconnect("Anti-Bot: Verification cancelled."); + break; + } + } + } +} \ No newline at end of file