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<(bool, DateTime?, bool)?> 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] ...