Иконка ресурса

FREE NitroBoostLinker : 6.0.1 6.0.1

Нет прав для скачивания
C#:
// NitroBoostLinker.cs
// GPL-3.0 License — (c) 2026 Gabriel Dungan, DunganSoft Technologies (github.com/gjdunga)
//
// Grants the NitroBoost Oxide permission when a linked Discord user is actively
// boosting the guild (premium_since) or has a configured Booster role.
//
// Hard-fails if Image Library, Rust Kits, Custom Auto Kits, or Discord credentials
// are missing. Includes /nitrodiag for live health reporting.
//
// SECURITY MODEL:
//   - Bot token is stored in oxide/config/NitroBoostLinker.json (plaintext).
//     Restrict file-system and RCON/console access on the host.
//   - /nitrodiscordbotlink passes the token as a console argument; Oxide logs all
//     console commands verbatim. Rotate the token if log access is not restricted.
//   - DiscordApiBase is validated to begin with "https://discord.com" at load time
//     to prevent SSRF via a poisoned config file.
//   - HTTP response bodies are NOT written to logs even in debug mode; only the
//     HTTP status code is logged to avoid leaking Discord user PII.
//   - Verification codes are generated with RandomNumberGenerator over an
//     alphabet of length 32 (divides 256 evenly — zero modulo bias).
//   - /nitrodiscordbotlink snapshots the previous credentials and restores them
//     on validation failure so an admin typo cannot leave the live plugin running
//     with credentials that were never written to disk.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;

namespace Oxide.Plugins
{
    [Info("Nitro Boost Linker", "gjdunga", "6.0.1")]
    [Description("Grants NitroBoost permission when a linked Discord user is boosting or has a Booster role. Hard-fails on missing prerequisites. Includes /nitrodiag and verbose logging.")]
    public class NitroBoostLinker : CovalencePlugin
    {
        // ─────────────────────────────────────────────────────────────────────────
        // CONSTANTS
        // ─────────────────────────────────────────────────────────────────────────

        // Derived from the [Info] version so it can never drift from the real
        // plugin version again (shown in /nitrodiag and /nitrolink help).
        private string DisplayVersion => Version.ToString();
        private const string DisplayAuthor  = "Gabriel Dungan / DunganSoft Technologies — GPL-3.0 License";

        private const string UrlImageLibrary = "https://umod.org/plugins/image-library";
        private const string UrlRustKits     = "https://umod.org/plugins/rust-kits";
        private const string UrlCustomKits   = "https://umod.org/plugins/custom-auto-kits";

        /// <summary>Oxide data file for confirmed Steam-to-Discord links.</summary>
        private const string LinksFile   = "NitroBoostLinker_Links";
        /// <summary>Oxide data file for pending (unverified) link attempts.</summary>
        private const string PendingFile = "NitroBoostLinker_Pending";
        /// <summary>Oxide log file for plugin-specific diagnostic messages.</summary>
        private const string LogFile     = "NitroBoostLinker";

        /// <summary>
        /// Characters used in generated verification codes.
        /// Excludes visually ambiguous characters: 0, O, 1, I.
        /// Length 32 divides 256 evenly — zero modulo bias when used with byte % length.
        /// </summary>
        private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

        /// <summary>
        /// The only origin prefix accepted for DiscordApiBase.
        /// Validated at load time to prevent SSRF via a poisoned config file.
        /// </summary>
        private const string DiscordApiOrigin = "https://discord.com";

        /// <summary>Maximum allowed value for VerificationCodeLength to prevent allocation abuse.</summary>
        private const int MaxCodeLength = 32;

        // ─────────────────────────────────────────────────────────────────────────
        // PLUGIN REFERENCES (null when dependency is not loaded)
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>Image Library — indirect dependency required by Rust Kits.</summary>
        [PluginReference] private Plugin ImageLibrary;
        /// <summary>Rust Kits — provides the kit grant system.</summary>
        [PluginReference] private Plugin Kits;
        /// <summary>Custom Auto Kits — auto-grants kits based on Oxide permissions.</summary>
        [PluginReference] private Plugin CustomAutoKits;

        // ─────────────────────────────────────────────────────────────────────────
        // HARD-DISABLE STATE
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>
        /// When true, all player-facing commands are blocked and the reason is logged
        /// and relayed to any admin who attempts to use the plugin.
        /// Set true by default until OnServerInitialized clears prerequisites.
        /// </summary>
        private bool   _hardDisabled       = true;
        private string _hardDisabledReason = "Not initialized";

        /// <summary>
        /// Tracks every bark timer created by StartBarkTimer.
        /// SECURITY: These are destroyed before new timers are created in
        /// ReevaluateHardFailPrereqs, preventing timer accumulation when
        /// OnPluginLoaded / OnPluginUnloaded fire repeatedly on plugin-reload-heavy servers.
        /// </summary>
        private readonly List<Timer> _barkTimers = new List<Timer>();

        // ─────────────────────────────────────────────────────────────────────────
        // CONFIGURATION
        // ─────────────────────────────────────────────────────────────────────────

        private PluginConfig _config;

        private class PluginConfig
        {
            // Discord / guild
            /// <summary>Discord bot token. Keep this secret; do not commit to version control.</summary>
            [JsonProperty("DiscordBotToken")]   public string DiscordBotToken   = string.Empty;
            /// <summary>Numerical ID of the Discord guild to check membership against.</summary>
            [JsonProperty("DiscordGuildId")]    public ulong  DiscordGuildId    = 0;

            // Oxide permission / group
            /// <summary>Oxide permission name granted to qualifying boosters.</summary>
            [JsonProperty("OxidePermissionName")]    public string OxidePermissionName    = "NitroBoost";
            /// <summary>Whether to also add the player to an Oxide group.</summary>
            [JsonProperty("AlsoCreateOxideGroup")]   public bool   AlsoCreateOxideGroup   = true;
            /// <summary>Name of the Oxide group to assign (requires AlsoCreateOxideGroup true).</summary>
            [JsonProperty("OxideGroupName")]         public string OxideGroupName         = "NitroBoost";
            /// <summary>
            /// When true, the plugin only adds players to the group if it already exists.
            /// When false, the group is created automatically at startup.
            /// </summary>
            [JsonProperty("GrantGroupIfExistsOnly")] public bool   GrantGroupIfExistsOnly = true;

            // Boost / role detection
            /// <summary>Treat a non-null premium_since on the guild member as an active boost.</summary>
            [JsonProperty("TreatPremiumSinceAsBoost")] public bool   TreatPremiumSinceAsBoost = true;
            /// <summary>Also check whether the member has a designated Booster role.</summary>
            [JsonProperty("UseBoosterRoleCheck")]      public bool   UseBoosterRoleCheck      = true;
            /// <summary>Discord role ID of the Booster role. Takes precedence over BoosterRoleName.</summary>
            [JsonProperty("BoosterRoleId")]            public ulong  BoosterRoleId            = 0;
            /// <summary>Discord role name to resolve at runtime when BoosterRoleId is 0.</summary>
            [JsonProperty("BoosterRoleName")]          public string BoosterRoleName          = string.Empty;

            // Verification flow
            /// <summary>Length of generated one-time verification codes (1 to 32).</summary>
            [JsonProperty("VerificationCodeLength")]     public int VerificationCodeLength     = 6;
            /// <summary>Seconds before a pending verification code expires.</summary>
            [JsonProperty("VerificationCodeTTLSeconds")] public int VerificationCodeTTLSeconds = 600;

            // Scheduling
            /// <summary>How often in minutes all linked players are re-checked for boost / role status.</summary>
            [JsonProperty("RevalidationIntervalMinutes")] public int RevalidationIntervalMinutes = 60;
            /// <summary>Maximum commands per player per 60-second window. Console / RCON is exempt.</summary>
            [JsonProperty("RateLimitPerPlayerPerMinute")] public int RateLimitPerPlayerPerMinute = 6;

            // Discord API
            /// <summary>
            /// Discord REST API base URL. Must start with "https://discord.com".
            /// SECURITY: The plugin validates this prefix at load time to prevent SSRF.
            /// Do not change unless Discord updates their API version.
            /// </summary>
            [JsonProperty("DiscordApiBase")]     public string DiscordApiBase     = "https://discord.com/api/v10";
            /// <summary>HTTP timeout in seconds for all Discord API calls.</summary>
            [JsonProperty("HttpTimeoutSeconds")] public int    HttpTimeoutSeconds = 15;

            // DM template
            /// <summary>
            /// Template for the verification DM sent to the Discord user.
            /// {CODE} is replaced with the generated one-time code.
            /// </summary>
            [JsonProperty("SendDMTemplate")]
            public string SendDMTemplate =
                "Your Rust verification code is: **{CODE}**\n" +
                "Return to the server and run: `/nitroverify {CODE}`";

            // Diagnostics
            /// <summary>
            /// When true, HTTP error status codes are written to the log file.
            /// Response bodies are NEVER logged regardless of this setting to protect Discord user PII.
            /// </summary>
            [JsonProperty("DebugLogging")] public bool DebugLogging = false;

            // Dependency name aliases
            // These arrays let the plugin find dependencies even when plugin authors
            // vary the display name. All entries are probed via plugins.Find().
            [JsonProperty("ImageLibraryPluginNames")]
            public string[] ImageLibraryPluginNames = { "ImageLibrary", "Image Library", "image-library" };
            [JsonProperty("RustKitsPluginNames")]
            public string[] RustKitsPluginNames = { "Kits", "Rust Kits", "rust-kits" };
            [JsonProperty("CustomAutoKitsPluginNames")]
            public string[] CustomAutoKitsPluginNames = { "Custom Auto Kits", "CustomAutoKits", "custom-auto-kits" };
        }

        protected override void LoadDefaultConfig()
        {
            LogWarning("Generating default configuration file...");
            _config = new PluginConfig();
            SaveConfigTyped();
        }

        private void LoadConfigTyped()
        {
            try
            {
                _config = Config.ReadObject<PluginConfig>();
                if (_config == null) throw new Exception("Config deserialized as null.");
                NormalizeConfig();
            }
            catch (Exception e)
            {
                LogWarning($"Config invalid ({e.Message}); regenerating defaults.");
                LoadDefaultConfig();
            }
        }

        /// <summary>
        /// Clamps or corrects config values that could cause runtime problems if set to
        /// extreme values in the JSON file.
        ///
        /// SECURITY: DiscordApiBase is validated to begin with the Discord origin to prevent
        /// SSRF. A server operator with write access to the config file cannot redirect the
        /// plugin's HTTP calls to internal network addresses.
        /// </summary>
        private void NormalizeConfig()
        {
            if (string.IsNullOrWhiteSpace(_config.OxidePermissionName))
                _config.OxidePermissionName = "NitroBoost";

            if (string.IsNullOrWhiteSpace(_config.OxideGroupName))
                _config.OxideGroupName = "NitroBoost";

            _config.VerificationCodeLength = Math.Max(4, Math.Min(MaxCodeLength, _config.VerificationCodeLength));
            _config.VerificationCodeTTLSeconds   = Math.Max(60, _config.VerificationCodeTTLSeconds);
            _config.RevalidationIntervalMinutes  = Math.Max(5, _config.RevalidationIntervalMinutes);
            _config.RateLimitPerPlayerPerMinute  = Math.Max(1, _config.RateLimitPerPlayerPerMinute);
            _config.HttpTimeoutSeconds           = Math.Max(5, Math.Min(60, _config.HttpTimeoutSeconds));

            if (string.IsNullOrWhiteSpace(_config.DiscordApiBase))
                _config.DiscordApiBase = "https://discord.com/api/v10";

            if (!_config.DiscordApiBase.StartsWith(DiscordApiOrigin, StringComparison.OrdinalIgnoreCase))
            {
                LogWarning($"DiscordApiBase '{_config.DiscordApiBase}' does not start with '{DiscordApiOrigin}'. " +
                           "Resetting to default to prevent SSRF.");
                _config.DiscordApiBase = "https://discord.com/api/v10";
            }
        }

        private void SaveConfigTyped() => Config.WriteObject(_config, true);

        // ─────────────────────────────────────────────────────────────────────────
        // LOCALIZATION
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Keys for all player-facing messages. All values default to English.
        /// Translators: create oxide/lang/{locale}/NitroBoostLinker.json with these keys.
        /// </summary>
        private static class Msg
        {
            public const string HelpText            = "HelpText";
            public const string HardDisabledNotice  = "HardDisabledNotice";
            public const string UsageNitroLink      = "UsageNitroLink";
            public const string InvalidDiscordId    = "InvalidDiscordId";
            public const string AlreadyLinked       = "AlreadyLinked";
            public const string DmFailed            = "DmFailed";
            public const string DmSent              = "DmSent";
            public const string UsageNitroVerify    = "UsageNitroVerify";
            public const string InvalidCode         = "InvalidCode";
            public const string CodeOwnerMismatch   = "CodeOwnerMismatch";
            public const string CodeExpired         = "CodeExpired";
            public const string ApiError            = "ApiError";
            public const string StatusLinked        = "StatusLinked";
            public const string StatusNotLinked     = "StatusNotLinked";
            public const string AdminOnly           = "AdminOnly";
            public const string PlayerNotFound      = "PlayerNotFound";
            public const string ResyncSuccess       = "ResyncSuccess";
            public const string ResyncFailed        = "ResyncFailed";
            public const string ResyncQueued        = "ResyncQueued";
            public const string UsageBotLink        = "UsageBotLink";
            public const string BadGuildId          = "BadGuildId";
            public const string BotLinkFailed       = "BotLinkFailed";
            public const string BotLinkSaved        = "BotLinkSaved";
            public const string BotLinkStillOff     = "BotLinkStillOff";
            public const string BotLinkEnabled      = "BotLinkEnabled";
            public const string RateLimit           = "RateLimit";
            public const string QualifiedBoost      = "QualifiedBoost";
            public const string QualifiedRole       = "QualifiedRole";
            public const string NotQualified        = "NotQualified";
            public const string TokenWarning        = "TokenWarning";
        }

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                [Msg.HelpText] =
                    "--- Nitro Boost Linker v{version} ---\n" +
                    "Author: {author}\n\n" +
                    "Required plugins:\n" +
                    "  Image Library    {urlImageLib}\n" +
                    "  Rust Kits        {urlKits}\n" +
                    "  Custom Auto Kits {urlCustomKits}\n\n" +
                    "Steps:\n" +
                    "  1) Discord: Settings > Advanced > Developer Mode ON\n" +
                    "     Right-click your avatar > Copy User ID\n" +
                    "  2) /nitrolink <DiscordUserID>\n" +
                    "  3) Check DM from bot for your code\n" +
                    "  4) /nitroverify <CODE>\n\n" +
                    "VIP granted when:\n" +
                    "  - You are actively boosting the guild (premium_since), OR\n" +
                    "  - You have the configured Booster role\n\n" +
                    "Check status: /nitrostatus",

                [Msg.HardDisabledNotice]  = "[DISABLED] {reason}",
                [Msg.UsageNitroLink]      = "Usage: /nitrolink <DiscordUserID>  |  /nitrolink help",
                [Msg.InvalidDiscordId]    = "Invalid Discord User ID — must be a non-zero number.",
                [Msg.AlreadyLinked]       = "Already linked to Discord ID {discordId}. Ask an admin to /nitroresync if your boost status changed.",
                [Msg.DmFailed]            = "Could not DM that Discord user. Verify the ID and ensure the user allows DMs from server members and bots.",
                [Msg.DmSent]             = "Verification code sent via DM to {discordId}. Run /nitroverify <CODE> within {minutes} minute(s).",
                [Msg.UsageNitroVerify]    = "Usage: /nitroverify <CODE>",
                [Msg.InvalidCode]         = "Invalid or expired code. Run /nitrolink <DiscordUserID> again.",
                [Msg.CodeOwnerMismatch]   = "This code does not belong to your account.",
                [Msg.CodeExpired]         = "Code expired. Run /nitrolink <DiscordUserID> again.",
                [Msg.ApiError]            = "Could not verify guild status right now. Try again in a moment.",
                [Msg.StatusLinked]        =
                    "Discord ID   : {discordId}\n" +
                    "Boost/Role   : {boosting}\n" +
                    "Premium Since: {premiumSince}\n" +
                    "Last Checked : {lastChecked}",
                [Msg.StatusNotLinked]     = "Not linked. Run /nitrolink help to get started.",
                [Msg.AdminOnly]           = "Admin or console only.",
                [Msg.PlayerNotFound]      = "Player not found.",
                [Msg.ResyncSuccess]       = "Resynced {name}.",
                [Msg.ResyncFailed]        = "Resync failed for {name} (no link record or API error).",
                [Msg.ResyncQueued]        = "Queued resync for {count} linked player(s).",
                [Msg.UsageBotLink]        = "Usage: /nitrodiscordbotlink <BotToken> <GuildId> [BoosterRoleId|RoleName]",
                [Msg.BadGuildId]          = "GuildId must be a non-zero integer.",
                [Msg.BotLinkFailed]       = "Discord validation failed. Verify the bot token is correct, the bot is in the guild, and the guild ID is right.",
                [Msg.BotLinkSaved]        = "Discord credentials saved and validated. GuildId={guildId}.",
                [Msg.BotLinkStillOff]     = "Still disabled: {reason}",
                [Msg.BotLinkEnabled]      = "Plugin is now ENABLED.",
                [Msg.RateLimit]           = "You are sending commands too quickly. Try again in a moment.",
                [Msg.QualifiedBoost]      = "Linked! Nitro boost detected — {perm} permission granted.",
                [Msg.QualifiedRole]       = "Linked! Booster role detected — {perm} permission granted.",
                [Msg.NotQualified]        = "Linked, but no active Nitro boost or Booster role found. The permission will be granted automatically on the next re-check.",
                [Msg.TokenWarning]        = "[NitroBoostLinker] WARNING: Bot token set via command — visible in server console log. Rotate this token if console access is not restricted to trusted admins.",
            }, this, "en");
        }

        /// <summary>Returns a localized message string for the given player.</summary>
        private string GetMsg(string key, string playerId, params object[] args)
        {
            string msg = lang.GetMessage(key, this, playerId);
            try   { return args.Length > 0 ? string.Format(msg, args) : msg; }
            catch { return msg; }
        }

        // ─────────────────────────────────────────────────────────────────────────
        // DATA MODELS
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>Persisted record for a confirmed Steam-to-Discord link.</summary>
        private class LinkRecord
        {
            [JsonProperty("SteamId")]              public string    SteamId;
            [JsonProperty("DiscordUserId")]         public ulong     DiscordUserId;
            [JsonProperty("LinkedAtUtc")]           public DateTime  LinkedAtUtc;
            [JsonProperty("LastVerifiedUtc")]       public DateTime  LastVerifiedUtc;
            [JsonProperty("IsBoosting")]            public bool      IsBoosting;
            [JsonProperty("LastKnownPremiumSince")] public DateTime? LastKnownPremiumSince;

            /// <summary>Returns true if the record has the minimum required fields populated.</summary>
            public bool IsValid() => !string.IsNullOrEmpty(SteamId) && DiscordUserId != 0;
        }

        /// <summary>Transient record for a pending (unverified) link attempt.</summary>
        private class PendingRecord
        {
            [JsonProperty("SteamId")]       public string   SteamId;
            [JsonProperty("DiscordUserId")] public ulong    DiscordUserId;
            [JsonProperty("Code")]          public string   Code;
            [JsonProperty("ExpiresUtc")]    public DateTime ExpiresUtc;
            [JsonProperty("CreatedUtc")]    public DateTime CreatedUtc;

            /// <summary>Returns true if the record has the minimum required fields populated.</summary>
            public bool IsValid() =>
                !string.IsNullOrEmpty(SteamId)
                && DiscordUserId != 0
                && !string.IsNullOrEmpty(Code);
        }

        // Confirmed links: SteamId -> LinkRecord
        private Dictionary<string, LinkRecord>    _linked         = new Dictionary<string, LinkRecord>();
        // Pending by SteamId — one active attempt per player; new attempt replaces old
        private Dictionary<string, PendingRecord> _pendingBySteam = new Dictionary<string, PendingRecord>();
        // Pending by code — for O(1) lookup during /nitroverify
        private Dictionary<string, PendingRecord> _pendingByCode  = new Dictionary<string, PendingRecord>();
        // Rate-limit buckets: SteamId -> sliding window of command timestamps
        private readonly Dictionary<string, List<DateTime>> _rate = new Dictionary<string, List<DateTime>>();

        // Booster role ID resolved from name (cached 30 min to reduce API calls)
        private ulong    _boosterRoleIdResolved;
        private DateTime _roleCacheTimeUtc = DateTime.MinValue;

        // ─────────────────────────────────────────────────────────────────────────
        // DISCORD API DTOs
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Result type for CheckMemberBoostOrRole.
        /// Uses a plain private class instead of a C# 7.0 value tuple.
        ///
        /// REASON: Action-typed callbacks with value tuple parameters such as
        /// Action&lt;(bool, DateTime?, bool)?&gt; require System.ValueTuple, which is not
        /// present in the BCL on the .NET target used by Oxide's uMod build servers
        /// (below .NET 4.7, without a NuGet package reference). This causes CS8179:
        /// "Predefined type 'System.ValueTuple' is not defined or imported" at compile time.
        /// A private class compiles cleanly on all Oxide-supported .NET targets.
        /// </summary>
        private class BoostCheckResult
        {
            /// <summary>True when premium_since is non-null and TreatPremiumSinceAsBoost is enabled.</summary>
            public bool      IsPremiumBoosting;
            /// <summary>Raw premium_since value from Discord, or null if the member is not boosting.</summary>
            public DateTime? PremiumSince;
            /// <summary>True when the member has the configured Booster role.</summary>
            public bool      HasBoosterRole;
        }

        private class CreateDMResponse
        {
            [JsonProperty("id")] public string Id;
        }

        private class DiscordUser
        {
            [JsonProperty("id")]          public string Id;
            [JsonProperty("username")]    public string Username;
            [JsonProperty("global_name")] public string GlobalName;
        }

        private class GuildMember
        {
            [JsonProperty("user")]          public DiscordUser  User;
            [JsonProperty("premium_since")] public DateTime?    PremiumSince;
            [JsonProperty("roles")]         public List<string> Roles;
        }

        private class GuildRole
        {
            [JsonProperty("id")]   public string Id;
            [JsonProperty("name")] public string Name;
        }

        // ─────────────────────────────────────────────────────────────────────────
        // LIFECYCLE HOOKS
        // ─────────────────────────────────────────────────────────────────────────

        private void Init()
        {
            LoadConfigTyped();
            RegisterPermissions();
            LoadData();

            AddCovalenceCommand("nitrolink",           nameof(CmdNitroLink));
            AddCovalenceCommand("nitroverify",         nameof(CmdNitroVerify));
            AddCovalenceCommand("nitrostatus",         nameof(CmdNitroStatus));
            AddCovalenceCommand("nitroresync",         nameof(CmdNitroResync));
            AddCovalenceCommand("nitrodiscordbotlink", nameof(CmdNitroDiscordBotLink));
            AddCovalenceCommand("nitrodiag",           nameof(CmdNitroDiag));

            ScheduleRevalidation();
            ScheduleRateCleanup();
        }

        /// <summary>
        /// Delay prerequisite evaluation until all plugins are fully loaded.
        /// Calling in Init risks false negatives for plugins that load after us.
        /// </summary>
        private void OnServerInitialized() => ReevaluateHardFailPrereqs();

        /// <summary>Re-evaluate prerequisites when a tracked dependency loads.</summary>
        private void OnPluginLoaded(Plugin plugin)
        {
            if (plugin != null && MatchesDependency(plugin.Name))
                ReevaluateHardFailPrereqs();
        }

        /// <summary>Re-evaluate prerequisites when a tracked dependency unloads.</summary>
        private void OnPluginUnloaded(Plugin plugin)
        {
            if (plugin != null && MatchesDependency(plugin.Name))
                ReevaluateHardFailPrereqs();
        }

        private void Unload()
        {
            DestroyBarkTimers();
            SaveData();
        }

        // ─────────────────────────────────────────────────────────────────────────
        // PERMISSIONS AND GROUPS
        // ─────────────────────────────────────────────────────────────────────────

        private void RegisterPermissions()
        {
            permission.RegisterPermission(_config.OxidePermissionName, this);

            // Create group at startup only when GrantGroupIfExistsOnly is false
            if (_config.AlsoCreateOxideGroup
                && !_config.GrantGroupIfExistsOnly
                && !permission.GroupExists(_config.OxideGroupName))
            {
                permission.CreateGroup(_config.OxideGroupName, _config.OxideGroupName, 0);
            }
        }

        /// <summary>Grants OxidePermissionName and optionally adds the player to OxideGroupName.</summary>
        private void GrantVip(string steamId)
        {
            if (!permission.UserHasPermission(steamId, _config.OxidePermissionName))
                permission.GrantUserPermission(steamId, _config.OxidePermissionName, this);

            if (!_config.AlsoCreateOxideGroup) return;

            bool groupExists = permission.GroupExists(_config.OxideGroupName);

            if (!groupExists && !_config.GrantGroupIfExistsOnly)
            {
                permission.CreateGroup(_config.OxideGroupName, _config.OxideGroupName, 0);
                groupExists = true;
            }

            if (groupExists)
                permission.AddUserGroup(steamId, _config.OxideGroupName);
        }

        /// <summary>Revokes OxidePermissionName and removes the player from OxideGroupName.</summary>
        private void RevokeVip(string steamId)
        {
            if (permission.UserHasPermission(steamId, _config.OxidePermissionName))
                permission.RevokeUserPermission(steamId, _config.OxidePermissionName);

            if (_config.AlsoCreateOxideGroup && permission.GroupExists(_config.OxideGroupName))
                permission.RemoveUserGroup(steamId, _config.OxideGroupName);
        }

        // ─────────────────────────────────────────────────────────────────────────
        // HARD-FAIL LOGIC
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Checks all prerequisites and either enables the plugin or hard-disables it.
        ///
        /// SECURITY: All existing bark timers are destroyed before any new ones are
        /// created. Without this, every OnPluginLoaded / OnPluginUnloaded cycle that
        /// triggers a hard-disable would accumulate an additional permanent 300-second
        /// timer, causing unbounded memory growth and log flooding on servers that
        /// frequently reload plugins.
        /// </summary>
        private void ReevaluateHardFailPrereqs()
        {
            DestroyBarkTimers();

            bool imageLib = IsImageLibraryPresent();
            bool rustKits = IsRustKitsPresent();
            bool cak      = IsCustomAutoKitsPresent();
            bool discord  = !string.IsNullOrWhiteSpace(_config.DiscordBotToken)
                            && _config.DiscordGuildId != 0;

            if (!imageLib)
            {
                string msg = $"[NitroBoostLinker] HARD-FAIL: Image Library not loaded. Install: {UrlImageLibrary}";
                HardDisable(msg);
                StartBarkTimer(() => !IsImageLibraryPresent(), msg);
                return;
            }
            if (!rustKits)
            {
                string msg = $"[NitroBoostLinker] HARD-FAIL: Rust Kits not loaded. Install: {UrlRustKits}";
                HardDisable(msg);
                StartBarkTimer(() => !IsRustKitsPresent(), msg);
                return;
            }
            if (!cak)
            {
                string msg = $"[NitroBoostLinker] HARD-FAIL: Custom Auto Kits not loaded. Install: {UrlCustomKits}";
                HardDisable(msg);
                StartBarkTimer(() => !IsCustomAutoKitsPresent(), msg);
                return;
            }
            if (!discord)
            {
                string msg = "[NitroBoostLinker] HARD-FAIL: Discord bot token or guild ID not configured. " +
                             "Run: /nitrodiscordbotlink <BotToken> <GuildId> [BoosterRoleId|RoleName]";
                HardDisable(msg);
                StartBarkTimer(
                    () => string.IsNullOrWhiteSpace(_config.DiscordBotToken) || _config.DiscordGuildId == 0,
                    msg
                );
                return;
            }

            if (_hardDisabled)
                Puts("[NitroBoostLinker] All prerequisites satisfied — ENABLED.");

            _hardDisabled       = false;
            _hardDisabledReason = string.Empty;
        }

        private void HardDisable(string reason)
        {
            _hardDisabled       = true;
            _hardDisabledReason = reason;
            LogFailure(reason);
        }

        /// <summary>
        /// Creates a 300-second repeating timer that re-logs the failure message while
        /// the condition is still true. The timer is tracked in _barkTimers.
        /// </summary>
        private void StartBarkTimer(Func<bool> stillFailing, string message)
        {
            Timer t = timer.Every(300f, () =>
            {
                if (_hardDisabled && stillFailing())
                    LogFailure(message);
            });
            _barkTimers.Add(t);
        }

        /// <summary>Destroys and clears all active bark timers.</summary>
        private void DestroyBarkTimers()
        {
            foreach (Timer t in _barkTimers)
            {
                try { t?.Destroy(); } catch { /* ignore if already destroyed */ }
            }
            _barkTimers.Clear();
        }

        /// <summary>
        /// If the plugin is hard-disabled, replies to the player with the reason and
        /// writes to the log. Returns true to signal the caller to abort execution.
        /// </summary>
        private bool BarkIfHardDisabled(IPlayer player)
        {
            if (!_hardDisabled) return false;
            string msg = lang.GetMessage(Msg.HardDisabledNotice, this, player?.Id)
                             .Replace("{reason}", _hardDisabledReason);
            player?.Reply(msg);
            LogFailure(_hardDisabledReason);
            return true;
        }

        /// <summary>
        /// Writes to: Oxide console (PrintError), Oxide global error log,
        /// and oxide/logs/NitroBoostLinker.txt.
        /// </summary>
        private void LogFailure(string message)
        {
            PrintError(message);
            Interface.Oxide.LogError(message);
            LogToFile(LogFile, $"[{DateTime.UtcNow:u}] {message}", this);
        }

        // ─────────────────────────────────────────────────────────────────────────
        // COMMANDS
        // ─────────────────────────────────────────────────────────────────────────

        /// <summary>
        /// /nitrolink [DiscordUserID|help]
        ///
        /// Players: Begin the Discord link flow by sending a DM with a one-time code.
        /// /nitrolink help is always available, even while hard-disabled.
        /// </summary>
        private void CmdNitroLink(IPlayer player, string command, string[] args)
        {
            if (player == null || !player.IsConnected) return;

            if (args.Length == 1 && args[0].Equals("help", StringComparison.OrdinalIgnoreCase))
            {
                string help = lang.GetMessage(Msg.HelpText, this, player.Id)
                    .Replace("{version}",     DisplayVersion)
                    .Replace("{author}",      DisplayAuthor)
                    .Replace("{urlImageLib}", UrlImageLibrary)
                    .Replace("{urlKits}",     UrlRustKits)
                    .Replace("{urlCustomKits}", UrlCustomKits);
                player.Reply(help);

                if (_hardDisabled)
                    player.Reply(lang.GetMessage(Msg.HardDisabledNotice, this, player.Id)
                        .Replace("{reason}", _hardDisabledReason));
                return;
            }

            if (BarkIfHardDisabled(player)) return;
            if (!CheckRate(player))         return;

            if (args.Length != 1)
            {
                player.Reply(lang.GetMessage(Msg.UsageNitroLink, this, player.Id));
                return;
            }

            if (!ulong.TryParse(args[0], out ulong discordUserId) || discordUserId == 0)
            {
                player.Reply(lang.GetMessage(Msg.InvalidDiscordId, this, player.Id));
                return;
            }

            string steamId = player.Id;

            if (_linked.TryGetValue(steamId, out LinkRecord existing))
            {
                player.Reply(lang.GetMessage(Msg.AlreadyLinked, this, player.Id)
                    .Replace("{discordId}", existing.DiscordUserId.ToString()));
                return;
            }

            string   code = GenerateUniqueCode(_config.VerificationCodeLength);
            DateTime now  = DateTime.UtcNow;

            // Remove any stale pending attempt for this player before inserting new one
            if (_pendingBySteam.TryGetValue(steamId, out PendingRecord old))
                _pendingByCode.Remove(old.Code);

            var pending = new PendingRecord
            {
                SteamId       = steamId,
                DiscordUserId = discordUserId,
                Code          = code,
                CreatedUtc    = now,
                ExpiresUtc    = now.AddSeconds(_config.VerificationCodeTTLSeconds)
            };

            _pendingBySteam[steamId] = pending;
            _pendingByCode[code]     = pending;
            SaveData();

            SendVerificationDM(discordUserId, code, ok =>
            {
                if (!ok)
                {
                    _pendingBySteam.Remove(steamId);
                    _pendingByCode.Remove(code);
                    SaveData();
                    player.Reply(lang.GetMessage(Msg.DmFailed, this, player.Id));
                    return;
                }

                int minutes = Math.Max(1, _config.VerificationCodeTTLSeconds / 60);
                player.Reply(lang.GetMessage(Msg.DmSent, this, player.Id)
                    .Replace("{discordId}", discordUserId.ToString())
                    .Replace("{minutes}",   minutes.ToString()));
            });
        }

        /// <summary>
        /// /nitroverify CODE
        ///
        /// Players: Submit the one-time code received via Discord DM to complete linking.
        /// Code is upper-cased before lookup for fault tolerance.
        /// Ownership is verified: a player cannot use another player's code.
        /// </summary>
        private void CmdNitroVerify(IPlayer player, string command, string[] args)
        {
            if (player == null || !player.IsConnected) return;
            if (BarkIfHardDisabled(player)) return;
            if (!CheckRate(player))         return;

            if (args.Length != 1)
            {
                player.Reply(lang.GetMessage(Msg.UsageNitroVerify, this, player.Id));
                return;
            }

            string code = args[0].Trim().ToUpperInvariant();

            if (!_pendingByCode.TryGetValue(code, out PendingRecord pending))
            {
                player.Reply(lang.GetMessage(Msg.InvalidCode, this, player.Id));
                return;
            }

            // Ownership check: prevent one player from using another's code
            if (!string.Equals(pending.SteamId, player.Id, StringComparison.Ordinal))
            {
                player.Reply(lang.GetMessage(Msg.CodeOwnerMismatch, this, player.Id));
                return;
            }

            if (DateTime.UtcNow > pending.ExpiresUtc)
            {
                CleanupPending(pending);
                player.Reply(lang.GetMessage(Msg.CodeExpired, this, player.Id));
                return;
            }

            CheckMemberBoostOrRole(pending.DiscordUserId, result =>
            {
                if (result == null)
                {
                    player.Reply(lang.GetMessage(Msg.ApiError, this, player.Id));
                    return;
                }

                bool qualifies = result.IsPremiumBoosting || result.HasBoosterRole;
                DateTime ts    = DateTime.UtcNow;

                _linked[player.Id] = new LinkRecord
                {
                    SteamId               = player.Id,
                    DiscordUserId         = pending.DiscordUserId,
                    LinkedAtUtc           = ts,
                    LastVerifiedUtc       = ts,
                    IsBoosting            = qualifies,
                    LastKnownPremiumSince = result.PremiumSince
                };

                CleanupPending(pending);
                SaveData();

                if (qualifies)
                {
                    GrantVip(player.Id);
                    string msgKey = result.IsPremiumBoosting ? Msg.QualifiedBoost : Msg.QualifiedRole;
                    player.Reply(lang.GetMessage(msgKey, this, player.Id)
                        .Replace("{perm}", _config.OxidePermissionName));
                }
                else
                {
                    player.Reply(lang.GetMessage(Msg.NotQualified, this, player.Id));
                }
            });
        }

        /// <summary>
        /// /nitrostatus
        ///
        /// Players: Display current link and boost status.
        /// Prepends the disabled reason when hard-disabled.
        /// </summary>
        private void CmdNitroStatus(IPlayer player, string command, string[] args)
        {
            if (player == null || !player.IsConnected) return;

            string prefix = _hardDisabled
                ? lang.GetMessage(Msg.HardDisabledNotice, this, player.Id)
                      .Replace("{reason}", _hardDisabledReason) + "\n"
                : string.Empty;

            if (_linked.TryGetValue(player.Id, out LinkRecord link))
            {
                player.Reply(prefix + lang.GetMessage(Msg.StatusLinked, this, player.Id)
                    .Replace("{discordId}",    link.DiscordUserId.ToString())
                    .Replace("{boosting}",     link.IsBoosting ? "Yes" : "No")
                    .Replace("{premiumSince}", link.LastKnownPremiumSince?.ToString("u") ?? "n/a")
                    .Replace("{lastChecked}",  link.LastVerifiedUtc.ToString("u")));
            }
            else
            {
                player.Reply(prefix + lang.GetMessage(Msg.StatusNotLinked, this, player.Id));
            }
        }

        /// <summary>
        /// /nitroresync [player]
        ///
        /// Admin / console: Force re-check of boost / role status.
        /// With argument: re-checks one named / ID player.
        /// Without argument: queues re-check for all linked players.
        /// </summary>
        private void CmdNitroResync(IPlayer player, string command, string[] args)
        {
            if (player != null && !player.IsServer && !player.IsAdmin)
            {
                player.Reply(lang.GetMessage(Msg.AdminOnly, this, player.Id));
                return;
            }

            if (BarkIfHardDisabled(player)) return;

            if (args.Length == 1)
            {
                IPlayer target = FindPlayer(args[0]);
                if (target == null)
                {
                    player?.Reply(lang.GetMessage(Msg.PlayerNotFound, this, player?.Id));
                    return;
                }

                ResyncPlayer(target.Id, ok =>
                    player?.Reply(ok
                        ? lang.GetMessage(Msg.ResyncSuccess, this, player?.Id)
                              .Replace("{name}", target.Name)
                        : lang.GetMessage(Msg.ResyncFailed, this, player?.Id)
                              .Replace("{name}", target.Name)));
                return;
            }

            int count = 0;
            // Snapshot keys before iterating: ResyncPlayer dispatches an async HTTP
            // callback, and matching ScheduleRevalidation's pattern keeps the iteration
            // safe if future revisions ever mutate _linked from inside the callback.
            foreach (KeyValuePair<string, LinkRecord> kv in _linked.ToArray())
            {
                ResyncPlayer(kv.Key, null);
                count++;
            }
            player?.Reply(lang.GetMessage(Msg.ResyncQueued, this, player?.Id)
                .Replace("{count}", count.ToString()));
        }

        /// <summary>
        /// /nitrodiscordbotlink BotToken GuildId [BoosterRoleId|RoleName]
        ///
        /// Admin / console / RCON: Configure Discord credentials at runtime without editing files.
        ///
        /// SECURITY NOTE: The bot token is passed as a command argument. Oxide logs all console
        /// commands verbatim. Restrict console and RCON access to prevent token exposure.
        /// Rotate the token if console logs may have been viewed by unauthorized parties.
        /// </summary>
        private void CmdNitroDiscordBotLink(IPlayer player, string command, string[] args)
        {
            if (player != null && !player.IsServer && !player.IsAdmin)
            {
                player?.Reply(lang.GetMessage(Msg.AdminOnly, this, player?.Id));
                return;
            }

            if (args.Length < 2)
            {
                player?.Reply(lang.GetMessage(Msg.UsageBotLink, this, player?.Id));
                return;
            }

            if (!ulong.TryParse(args[1], out ulong newGuildId) || newGuildId == 0)
            {
                player?.Reply(lang.GetMessage(Msg.BadGuildId, this, player?.Id));
                return;
            }

            // Security warning: token will appear in Oxide's server console log verbatim
            Puts(lang.GetMessage(Msg.TokenWarning, this, null));

            // SECURITY: Snapshot existing credentials so we can roll back on validation
            // failure. Without this, an admin typo would leave the live plugin using
            // unsaved bad credentials until the next reload, even though the disk file
            // still contains the previously-valid values.
            string previousToken    = _config.DiscordBotToken;
            ulong  previousGuildId  = _config.DiscordGuildId;
            ulong  previousRoleId   = _config.BoosterRoleId;
            string previousRoleName = _config.BoosterRoleName;

            _config.DiscordBotToken = args[0];
            _config.DiscordGuildId  = newGuildId;

            if (args.Length >= 3)
            {
                if (ulong.TryParse(args[2], out ulong roleId) && roleId != 0)
                {
                    _config.BoosterRoleId   = roleId;
                    _config.BoosterRoleName = string.Empty;
                }
                else
                {
                    _config.BoosterRoleName = args[2];
                    _config.BoosterRoleId   = 0;
                }
            }

            ValidateDiscordCredentials(ok =>
            {
                if (!ok)
                {
                    _config.DiscordBotToken = previousToken;
                    _config.DiscordGuildId  = previousGuildId;
                    _config.BoosterRoleId   = previousRoleId;
                    _config.BoosterRoleName = previousRoleName;

                    player?.Reply(lang.GetMessage(Msg.BotLinkFailed, this, player?.Id));
                    LogFailure("[NitroBoostLinker] Discord validation failed during /nitrodiscordbotlink. Live credentials restored to previous values.");
                    return;
                }

                SaveConfigTyped();
                player?.Reply(lang.GetMessage(Msg.BotLinkSaved, this, player?.Id)
                    .Replace("{guildId}", _config.DiscordGuildId.ToString()));
                Puts("[NitroBoostLinker] Discord credentials validated and saved.");

                ReevaluateHardFailPrereqs();

                play
// ... [Code preview truncated due to length. Full source in attached file] ...

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху