C#:
/*
* ModernItemBlocker v5.0.1
* Author : gjdunga (Gabriel Dungan, DunganSoft Technologies)
* License: GPL-3.0
*
* PURPOSE
* -------
* Blocks items, clothing, ammunition, and deployables either permanently or for
* a configurable duration after a server wipe. Integrates with Oxide permission
* groups and optionally skips players who are inside a Duel arena.
*
* COMPATIBILITY
* -------------
* Requires Oxide v2.0.7022+ (Rust Naval Update hook signatures).
* Hook signatures that changed in that release:
* CanEquipItem (PlayerInventory, Item, int targetSlot)
* CanWearItem (PlayerInventory, Item, int targetSlot)
* OnMagazineReload (BaseProjectile, IAmmoContainer, BasePlayer)
* CanBuild (Planner, Construction, Construction.Target)
*
* Verified compatible with Oxide 2.0.7423 (current as of 2026-06-06) and the
* Facepunch "Built Different" Rust update (build 2627.285.1, 2026-06-04).
*
* CHANGES IN 4.2.1
* ----------------
* COMPILE-FIX : Console / RCON command no longer fails to compile against the
* Built Different Rust DLL. Facepunch's StringView refactor
* retyped ConsoleSystem.Arg.Args from string[] to
* Facepunch.StringView[]:
*
* error CS1503: Argument 2: cannot convert from
* 'Facepunch.StringView[]' to 'string[]'
*
* ExecuteCommand still operates on plain string[] (it does
* case-insensitive switch / Equals / Trim work that is awkward
* to express on StringView and would force every internal helper
* to switch types). The fix is a boundary conversion in
* ConsoleCommand: iterate arg.Args once and call ToString() on
* each StringView, materialising a single string[] for the rest
* of the plugin. Null/empty Args propagates as a null string[]
* so the existing "no args -> usage" branch in ExecuteCommand
* still fires.
*
* The plugin remains source-compatible with the pre-Built
* Different ConsoleSystem.Arg.Args (string[]) because string[]
* indexing returns string, and string.ToString() is a no-op
* returning itself. Servers on older Rust builds therefore do
* not need a separate plugin build.
*
* COMPAT : Compatibility statement updated to Oxide 2.0.7423 and the
* Built Different Rust build (2627.285.1, 2026-06-04). None of
* the four blocking hooks (CanEquipItem, CanWearItem,
* OnMagazineReload, CanBuild) changed signature in this release;
* only the console-arg API did.
*
* CHANGES IN 4.2.0
* ----------------
* SECURITY : Path traversal check in HandleLogListCommand tightened. The
* previous prefix check (StartsWith on the resolved log directory)
* would accept any path whose absolute form merely began with the
* log directory string, e.g. ".../oxide/logs-evil/file.txt" would
* match ".../oxide/logs". The resolved directory is now suffixed
* with Path.DirectorySeparatorChar before the prefix comparison so
* only true children of the log directory pass the guard.
* SECURITY : LogBlockAttempt now null-guards player.transform before reading
* position. In rare teardown races (player disconnects between
* the block hook firing and LogBlockAttempt running), transform
* can be null; reading .position would throw NullReferenceException
* and abort the log write.
* PERF/SEC : InDuel exception logging now warns ONCE per duelling plugin per
* load rather than on every hook invocation. 4.1.3 introduced
* PrintWarning on caught exceptions to surface plugin-API mismatches,
* but on a busy server with a permanently-broken duel plugin this
* produced hundreds of warnings per second. A latched bool per
* plugin reference suppresses repeated identical warnings; the
* warning fires again after a plugin reload so the operator still
* sees the issue.
* COMPAT : Log directory derived from Interface.Oxide.LogDirectory when
* available, falling back to the previous Path.Combine of
* RootDirectory. More robust against future Oxide layout changes.
* META : Author updated to "Gabriel Dungan (DunganSoft Technologies)" in
* the [Info] attribute and across all repository metadata files.
* DOCS : CONTRIBUTING.md added. README, manifest, and changelog updated
* to reflect v4.2.0.
*
* CHANGES IN 4.1.4
* ----------------
* FIX : Null-coerce item names in hook handlers before passing to
* LogBlockAttempt. In 4.1.2 null guards were added to CheckBlocked
* so that null displayName.english values from custom or modded items
* do not throw ArgumentNullException during HashSet lookups. However,
* each hook handler read displayName.english a second time when calling
* LogBlockAttempt, after CheckBlocked had already confirmed a block.
* If displayName.english was null, SanitizeLog received null and
* returned null, and string interpolation in LogBlockAttempt produced
* the literal text "null" in the log entry rather than an empty string.
* The fix extracts displayName and shortname into local variables at the
* top of each block in each hook handler. Both CheckBlocked and
* LogBlockAttempt now receive the same already-coerced values, closing
* the inconsistency without duplicating the null-coerce logic.
*
* CHANGES IN 4.1.3
* ----------------
* VERIFIED : No (bool, DateTime?, bool)? value tuples were present in the
* codebase. CheckBlocked returns bool? (simple nullable bool)
* which compiles correctly on all Oxide build servers without
* System.ValueTuple. No code change required for this item.
* SECURITY : Unload() hook added. Oxide automatically unsubscribes hooks
* on plugin unload, but an explicit Unsubscribe pass in Unload()
* prevents any hook firing against a partially-torn-down instance
* in rare race conditions (e.g., late CanBuild events arriving
* while Oxide is removing the plugin). All six HashSets are also
* cleared to release references early.
* SECURITY : InDuel() now calls PrintWarning() on caught exceptions instead
* of silently swallowing them. Silent catch blocks mask real
* failures (e.g., wrong plugin API, version mismatch) making
* debugging nearly impossible. The warning includes the plugin
* name and exception message without a stack trace to keep server
* console output readable.
* SECURITY : HandleLogListCommand() replaced File.ReadAllLines() with a
* tail-read using FileStream + StreamReader. ReadAllLines() loads
* the entire file into memory; on a busy server the daily log can
* reach tens of MB. The new reader seeks from the end of the file
* and reads only enough bytes to collect 20 lines, capped at 64 KB
* to bound worst-case memory use absolutely.
* FIX : ConsoleCommand() now passes string.Empty (not null) to
* FindPlayerById when arg.Connection is null. FindPlayerById
* on some Covalence backends throws on a null-string argument
* rather than returning null gracefully.
* ADD : Russian (ru), Spanish (es), and Latin (la) lang files added to
* oxide/lang/.
* STRUCTURE : ModernItemBlocker.cs removed from repository root. The canonical
* path for Oxide plugins is oxide/plugins/; a duplicate root copy
* violated the umod submission structure requirement.
*
* CHANGES IN 4.1.2
* ----------------
* SECURITY : [Info] title corrected from "ModernItemBlocker" to "Modern Item Blocker"
* to comply with the umod style guide requirement for human-readable,
* space-separated plugin titles in the console.
* SECURITY : SanitizeLog now strips all ASCII control characters (0x00-0x1F and 0x7F)
* in addition to the pipe delimiter (|). Previously only \n and \r were
* removed; null bytes and other control chars could still corrupt log
* parsers. A compiled Regex replaces the manual char.Replace chain.
* SECURITY : Null guard added in CheckBlocked for displayName and shortName.
* Custom or modded items can have a null displayName.english string;
* passing null to HashSet.Contains throws ArgumentNullException. Both
* names are now replaced with an empty string when null.
* FIX : SendLists empty-list placeholder changed from "<none>" to "(none)".
* The angle brackets in "<none>" are parsed by Unity's Rich Text renderer
* as an unknown tag and may be silently dropped, producing blank output.
*
* CHANGES IN 4.1.1
* ----------------
* SECURITY : Log injection fix. player.displayName and item names are now
* sanitised before being written to the log file. Characters that
* could forge a new log line (\n, \r) or break the pipe-delimited
* format (|) are replaced with a space.
* SECURITY : Null list guard. ValidateConfig now null-coerces all six block-
* list fields. A config file with "Permanent Blocked Items": null
* previously caused BuildHashSets to throw NullReferenceException
* on startup; the plugin now falls back to an empty list instead.
* SECURITY : Item name length cap (256 chars) added to the add/remove command
* parser. Prevents log and config bloat from arbitrarily long names.
* SECURITY : Rich Text stripped from item names in SendLists output. Entries
* containing <color> or other Unity markup tags no longer render as
* formatted text in the chat reply.
* FIX : Removed the spurious SaveConfig() call in OnNewSave. _blockEnd
* is a runtime field, not persisted in the config file. The call
* was a no-op that could mislead readers into thinking wipe-time was
* being serialised.
* FIX : README minimum Oxide version corrected from v2.0.6599 to v2.0.7022.
* ADD : oxide/lang/en/ModernItemBlocker.json shipped in repo so operators
* can see all translatable keys without reading source code.
*
* CHANGES IN 4.1.0
* ----------------
* BUG FIX : Replaced OnEntityBuilt with CanBuild. OnEntityBuilt fires AFTER
* placement so returning false was a no-op; deployable blocking was
* silently non-functional. CanBuild fires BEFORE placement and its
* return value is respected by the engine.
* BUG FIX : loglist command now searches for the Oxide date-suffixed log file
* (ModernItemBlocker_YYYY-MM-DD.txt) instead of looking for the
* non-existent ModernItemBlocker.txt.
* BUG FIX : remove command now correctly distinguishes "removed" from
* "not found" and uses the NotFound lang key instead of always
* reporting success.
* PERF : Hex color re-validation removed from SendBlockMessage hot path;
* the validated value is cached at load/config-change time.
* PERF : CanBuild hook subscription replaces the now-removed OnEntityBuilt
* subscription, so Subscribe/Unsubscribe logic is consistent.
*/
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Modern Item Blocker", "gjdunga", "5.0.1")]
[Description("Blocks items, clothing, ammunition and deployables temporarily after a wipe or permanently until removed. Compatible with Oxide v2.0.7022+ and the Rust Naval Update.")]
public class ModernItemBlocker : RustPlugin
{
// ---------------------------------------------------------------
// Constants
// ---------------------------------------------------------------
/// <summary>
/// Key used with Oxide DataFileSystem for reading/writing the config.
/// Resolves to oxide/data/ModernItemBlockerConfig.json at runtime.
/// </summary>
private const string DataConfigName = "ModernItemBlockerConfig";
/// <summary>
/// Base name passed to Oxide's LogToFile helper.
/// Oxide appends _YYYY-MM-DD.txt, producing e.g. ModernItemBlocker_2025-06-01.txt.
/// </summary>
private const string LogFileName = "ModernItemBlocker";
/// <summary>
/// Maximum character length accepted for a block-list entry name.
/// Names longer than this are rejected by the add/remove command parser to
/// prevent config and log file bloat.
/// </summary>
private const int MaxNameLength = 256;
/// <summary>
/// Maximum number of recent log lines retained in memory for the loglist
/// command. The plugin reads recent entries from this in-memory ring buffer
/// rather than from disk — Oxide/uMod plugins must not read the filesystem
/// directly. The full history remains in oxide/logs/ModernItemBlocker_*.txt.
/// </summary>
private const int RecentLogMax = 50;
/// <summary>In-memory ring buffer of the most recent log lines (oldest first).</summary>
private readonly Queue<string> _recentLog = new Queue<string>();
// ---------------------------------------------------------------
// Compiled regexes (allocated once at class load time)
// ---------------------------------------------------------------
/// <summary>
/// Validates a 6-digit RGB hex colour string such as #f44253.
/// Only lowercase and uppercase hex digits are accepted; no 3-digit shorthand.
/// </summary>
private static readonly Regex HexColorRegex =
new Regex(@"^#[0-9A-Fa-f]{6}$", RegexOptions.Compiled);
/// <summary>
/// Matches any Unity Rich Text markup tag, e.g. <color=#fff>, <b>, </b>.
/// Used to strip operator-supplied strings before embedding them in chat messages.
/// </summary>
private static readonly Regex RichTextRegex =
new Regex(@"<[^>]+>", RegexOptions.Compiled);
/// <summary>
/// Matches all ASCII control characters (0x00-0x1F, 0x7F) and the pipe character
/// used as log field delimiter. All matches are replaced with a space by SanitizeLog.
///
/// Control characters beyond \n and \r (e.g. null bytes, BEL, DEL) can corrupt
/// log file parsers and terminal emulators; this regex eliminates the entire range.
/// </summary>
private static readonly Regex SanitizeLogRegex =
new Regex(@"[\x00-\x1F\x7F|]", RegexOptions.Compiled);
// ---------------------------------------------------------------
// Cached validated state (populated in ValidateConfig / Init)
// ---------------------------------------------------------------
/// <summary>
/// Hex colour string that has passed HexColorRegex validation.
/// Cached at load time so hot-path block messages do not re-validate.
/// Default matches the config default.
/// </summary>
private string _safeColor = "#f44253";
/// <summary>
/// Chat prefix with all Rich Text tags stripped.
/// Cached at load time to prevent per-message regex execution.
/// </summary>
private string _safePrefix = "[ModernBlocker]";
// ===================================================================
// REGION: Configuration
//
// The plugin stores its configuration in oxide/data/ rather than
// oxide/config/ so the data can be mutated at runtime via commands
// and re-read without a full plugin reload. Oxide's DataFileSystem
// handles JSON serialisation via Newtonsoft.Json.
// ===================================================================
#region Configuration
private Configuration _config;
/// <summary>
/// Serialisable configuration object.
///
/// All list fields hold raw item names or shortnames; the plugin matches
/// both at runtime so operators can use either form (e.g. "Assault Rifle"
/// or "rifle.ak"). Matching is always case-insensitive.
/// </summary>
public class Configuration
{
/// <summary>
/// How many hours after a wipe the timed block lists remain active.
/// Set to 0 to disable timed blocking entirely (permanent lists still apply).
/// Must be non-negative; negative values are corrected to 0 by ValidateConfig.
/// </summary>
[JsonProperty("Block Duration (Hours) after Wipe")]
public int BlockDurationHours = 30;
// ---- Permanent lists (never expire, persist across wipes) ----
/// <summary>
/// Item shortnames or display names that are always blocked, regardless of
/// how long ago the last wipe occurred.
/// Example: ["rifle.ak", "Rocket Launcher"]
/// </summary>
[JsonProperty("Permanent Blocked Items")]
public List<string> PermanentBlockedItems = new List<string>();
/// <summary>
/// Clothing shortnames or display names that are permanently blocked.
/// Example: ["metal.facemask", "Heavy Plate Jacket"]
/// </summary>
[JsonProperty("Permanent Blocked Clothes")]
public List<string> PermanentBlockedClothes = new List<string>();
/// <summary>
/// Ammunition shortnames or display names that are permanently blocked.
/// Example: ["ammo.rocket.hv", "High Velocity Rocket"]
/// </summary>
[JsonProperty("Permanent Blocked Ammo")]
public List<string> PermanentBlockedAmmo = new List<string>();
// ---- Timed lists (active only within BlockDurationHours of a wipe) ----
/// <summary>
/// Item shortnames or display names blocked for BlockDurationHours after
/// the most recent wipe. The block expires automatically when time elapses.
/// </summary>
[JsonProperty("Timed Blocked Items")]
public List<string> TimedBlockedItems = new List<string>();
/// <summary>
/// Clothing shortnames or display names blocked for BlockDurationHours after wipe.
/// </summary>
[JsonProperty("Timed Blocked Clothes")]
public List<string> TimedBlockedClothes = new List<string>();
/// <summary>
/// Ammo shortnames or display names blocked for BlockDurationHours after wipe.
/// </summary>
[JsonProperty("Timed Blocked Ammo")]
public List<string> TimedBlockedAmmo = new List<string>();
// ---- Permissions ----
/// <summary>
/// Oxide permission node that exempts the holder from all block checks.
/// Must begin with the plugin name prefix (modernitemblocker.) to avoid
/// namespace collisions; Init corrects any misconfigured value automatically.
/// Default: "modernitemblocker.bypass"
/// </summary>
[JsonProperty("Bypass Permission")]
public string BypassPermission = "modernitemblocker.bypass";
/// <summary>
/// Oxide permission node required to use /modernblocker management commands.
/// Must begin with the plugin name prefix.
/// Default: "modernitemblocker.admin"
/// </summary>
[JsonProperty("Admin Permission")]
public string AdminPermission = "modernitemblocker.admin";
// ---- UI ----
/// <summary>
/// Plain-text prefix prepended to every block notification in chat.
/// Rich Text tags are stripped from this value at load time.
/// Default: "[ModernBlocker]"
/// </summary>
[JsonProperty("Chat Prefix")]
public string ChatPrefix = "[ModernBlocker]";
/// <summary>
/// Six-digit hex colour (#RRGGBB) applied to the ChatPrefix via Unity Rich Text.
/// Must match ^#[0-9A-Fa-f]{6}$; invalid values are corrected to #f44253.
/// Default: "#f44253"
/// </summary>
[JsonProperty("Chat Prefix Color")]
public string ChatPrefixColor = "#f44253";
/// <summary>
/// Returns a new Configuration instance populated with sensible defaults
/// and empty block lists. Called when no config file exists.
/// </summary>
public static Configuration DefaultConfig() => new Configuration
{
BlockDurationHours = 30,
TimedBlockedItems = new List<string>(),
TimedBlockedClothes = new List<string>(),
TimedBlockedAmmo = new List<string>(),
PermanentBlockedItems = new List<string>(),
PermanentBlockedClothes = new List<string>(),
PermanentBlockedAmmo = new List<string>()
};
}
/// <summary>
/// Called by Oxide when no oxide/config file exists. Migrates a pre-4.2.6
/// configuration from the legacy oxide/data/ModernItemBlockerConfig.json if
/// one is present, so servers upgrading to the standard oxide/config location
/// keep their existing settings. Otherwise writes fresh defaults.
/// </summary>
protected override void LoadDefaultConfig()
{
try
{
if (Interface.Oxide.DataFileSystem.ExistsDatafile(DataConfigName))
{
_config = Interface.Oxide.DataFileSystem.ReadObject<Configuration>(DataConfigName);
if (_config != null)
PrintWarning("Migrated configuration from oxide/data to oxide/config.");
}
}
catch { /* fall through to defaults */ }
if (_config == null)
{
PrintWarning("Generating default configuration.");
_config = Configuration.DefaultConfig();
}
}
/// <summary>
/// Reads configuration from oxide/config/ModernItemBlocker.json via Oxide's
/// standard Config API. Oxide calls LoadDefaultConfig (which handles legacy
/// migration) and SaveConfig automatically when the file is missing. On a
/// corrupt/null read we fall back to defaults. ValidateConfig enforces field
/// constraints; SaveConfig persists new fields and corrections.
/// </summary>
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject<Configuration>();
if (_config == null)
throw new Exception("Deserialized config was null.");
}
catch
{
PrintWarning("Configuration file not found or invalid. Creating default.");
LoadDefaultConfig();
}
ValidateConfig();
SaveConfig();
}
/// <summary>
/// Serialises the current _config object to oxide/config/ModernItemBlocker.json
/// via Oxide's standard Config API (Newtonsoft.Json, indented format).
/// </summary>
protected override void SaveConfig() => Config.WriteObject(_config, true);
/// <summary>
/// Validates loaded configuration values, corrects out-of-range fields, and
/// caches derived state used in hot paths.
///
/// Security notes:
/// * Null list fields are coerced to empty lists so BuildHashSets does not
/// throw NullReferenceException on a malformed config file.
/// * ChatPrefixColor is validated against HexColorRegex; invalid values are
/// reset to the default to prevent Rich Text injection via colour strings.
/// * ChatPrefix has all Rich Text tags stripped and the result is cached in
/// _safePrefix so no per-message regex is needed in SendBlockMessage.
/// </summary>
private void ValidateConfig()
{
// ---- Null-coerce all six block lists -------------------------
// If the JSON file contains "Permanent Blocked Items": null (or any list
// is missing entirely), the deserializer sets that field to null.
// BuildHashSets passes these to HashSet<string>(IEnumerable<string>, ...)
// which throws ArgumentNullException on a null source. Replace each null
// with an empty list so the plugin starts safely.
_config.PermanentBlockedItems ??= new List<string>();
_config.PermanentBlockedClothes ??= new List<string>();
_config.PermanentBlockedAmmo ??= new List<string>();
_config.TimedBlockedItems ??= new List<string>();
_config.TimedBlockedClothes ??= new List<string>();
_config.TimedBlockedAmmo ??= new List<string>();
// ---- Numeric range check ------------------------------------
if (_config.BlockDurationHours < 0)
{
PrintWarning($"BlockDurationHours was {_config.BlockDurationHours}; reset to 0.");
_config.BlockDurationHours = 0;
}
// ---- Colour validation and caching --------------------------
if (HexColorRegex.IsMatch(_config.ChatPrefixColor ?? string.Empty))
{
_safeColor = _config.ChatPrefixColor;
}
else
{
PrintWarning($"ChatPrefixColor '{_config.ChatPrefixColor}' is invalid; reset to #f44253.");
_config.ChatPrefixColor = "#f44253";
_safeColor = "#f44253";
}
// ---- Prefix sanitisation and caching ------------------------
_safePrefix = StripRichText(_config.ChatPrefix ?? string.Empty);
if (_safePrefix != _config.ChatPrefix)
PrintWarning("Rich Text tags were stripped from ChatPrefix.");
}
#endregion
// ===================================================================
// REGION: Data & State
//
// Six HashSets mirror the six config lists. HashSet<string> with an
// OrdinalIgnoreCase comparer gives O(1) contains-check regardless of
// list size. The sets are rebuilt from the lists every time the config
// changes so the authoritative source of truth remains the config lists
// (which are serialised to disk).
// ===================================================================
#region Data & State
/// <summary>
/// UTC timestamp after which the timed block window has closed.
/// Computed from SaveRestore.SaveCreatedTime at startup and reset to
/// UtcNow + BlockDurationHours whenever OnNewSave fires.
/// </summary>
private DateTime _blockEnd;
// HashSets for O(1) item-name lookups, rebuilt by BuildHashSets().
private HashSet<string> _timedItems;
private HashSet<string> _timedClothes;
private HashSet<string> _timedAmmo;
private HashSet<string> _permanentItems;
private HashSet<string> _permanentClothes;
private HashSet<string> _permanentAmmo;
/// <summary>
/// Rebuilds all six block HashSets from the corresponding config lists.
///
/// Must be called after any change to the config lists (add, remove, reload)
/// so that in-memory lookups reflect the updated state. The config lists
/// remain the authoritative source; the HashSets are a read-only lookup cache.
/// </summary>
private void BuildHashSets()
{
_timedItems = new HashSet<string>(_config.TimedBlockedItems, StringComparer.OrdinalIgnoreCase);
_timedClothes = new HashSet<string>(_config.TimedBlockedClothes, StringComparer.OrdinalIgnoreCase);
_timedAmmo = new HashSet<string>(_config.TimedBlockedAmmo, StringComparer.OrdinalIgnoreCase);
_permanentItems = new HashSet<string>(_config.PermanentBlockedItems, StringComparer.OrdinalIgnoreCase);
_permanentClothes = new HashSet<string>(_config.PermanentBlockedClothes, StringComparer.OrdinalIgnoreCase);
_permanentAmmo = new HashSet<string>(_config.PermanentBlockedAmmo, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Returns true while the server clock is before _blockEnd, meaning the
/// timed block window is still active. Evaluated on every block check.
/// </summary>
private bool InTimedBlock => DateTime.UtcNow < _blockEnd;
#endregion
// ===================================================================
// REGION: Initialization
// ===================================================================
#region Initialization
/// <summary>
/// Called by Oxide before the server is fully initialised.
///
/// Validates the two permission node strings loaded from config and registers
/// them with Oxide so they appear in oxide.grant / oxide.revoke output and can
/// be assigned to groups immediately.
///
/// Permission strings that do not start with the plugin's lowercase name followed
/// by a dot (e.g. "modernitemblocker.") are automatically corrected and saved to
/// prevent misconfiguration such as using a bare word like "bypass".
/// </summary>
private void Init()
{
var prefix = Name.ToLowerInvariant();
if (!_config.BypassPermission.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))
{
_config.BypassPermission = $"{prefix}.bypass";
PrintWarning($"Bypass permission corrected to {_config.BypassPermission}.");
SaveConfig();
}
if (!_config.AdminPermission.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))
{
_config.AdminPermission = $"{prefix}.admin";
PrintWarning($"Admin permission corrected to {_config.AdminPermission}.");
SaveConfig();
}
permission.RegisterPermission(_config.BypassPermission, this);
permission.RegisterPermission(_config.AdminPermission, this);
}
/// <summary>
/// Called by Oxide once the server is fully initialised.
///
/// Computes _blockEnd from the save file creation timestamp rather than
/// UtcNow so that restarts within the timed window do not reset the clock.
/// Falls back to UtcNow when SaveRestore.SaveCreatedTime is unavailable.
///
/// After computing _blockEnd, builds the in-memory HashSets and subscribes
/// only the Oxide hooks that are needed given the current block lists.
/// </summary>
private void OnServerInitialized()
{
_blockEnd = SaveRestore.SaveCreatedTime != DateTime.MinValue
? SaveRestore.SaveCreatedTime.AddHours(_config.BlockDurationHours)
: DateTime.UtcNow.AddHours(_config.BlockDurationHours);
BuildHashSets();
SubscribeHooks();
}
/// <summary>
/// Called by Oxide when the plugin is unloaded (oxide.unload, server restart,
/// or hot-reload).
///
/// Explicitly unsubscribes all four hooks to prevent late-arriving events from
/// dispatching into a partially-torn-down plugin instance. Oxide's hook
/// dispatcher is usually correct, but the explicit pass closes any race window
/// between event queuing and plugin teardown.
///
/// Clears all six HashSets to release object references and allow GC to reclaim
/// memory immediately rather than waiting for the next full GC cycle.
/// </summary>
private void Unload()
{
Unsubscribe(nameof(CanEquipItem));
Unsubscribe(nameof(CanWearItem));
Unsubscribe(nameof(OnMagazineReload));
Unsubscribe(nameof(CanBuild));
_timedItems?.Clear();
_timedClothes?.Clear();
_timedAmmo?.Clear();
_permanentItems?.Clear();
_permanentClothes?.Clear();
_permanentAmmo?.Clear();
// Reset warn-once latches so a reload re-surfaces any still-broken
// duel-plugin integration to the operator.
_warnedDuelsManager = false;
_warnedDuel = false;
}
/// <summary>
/// Oxide hook fired when a new save file is created, signalling a wipe.
///
/// Resets _blockEnd to now + BlockDurationHours so the full timed-block
/// window is available from the start of the new wipe.
///
/// Note: SaveConfig() is intentionally NOT called here. _blockEnd is a
/// runtime field that is not stored in the config file; it is recomputed
/// from SaveRestore.SaveCreatedTime on every server start. Calling
/// SaveConfig() would write an unchanged config to disk unnecessarily.
/// </summary>
private void OnNewSave(string filename)
{
if (_config == null) return; // Guard: wipe fired before Init completed.
_blockEnd = DateTime.UtcNow.AddHours(_config.BlockDurationHours);
Puts($"Wipe detected. Timed block window ends {_blockEnd:yyyy-MM-dd HH:mm:ss} UTC.");
}
/// <summary>
/// Subscribes or unsubscribes each Oxide hook based on whether its corresponding
/// block lists contain any entries.
///
/// Unsubscribing hooks when their lists are empty eliminates Oxide's per-event
/// dispatch overhead for CanEquipItem, CanWearItem, OnMagazineReload, and CanBuild
/// when no blocking is configured. This matters on busy servers where these
/// events fire hundreds of times per second.
///
/// CanBuild is tied to the items lists because deployables are placed via
/// inventory items (e.g. "Large Wood Box") and are therefore classified as items.
/// </summary>
private void SubscribeHooks()
{
bool hasItems = _permanentItems.Count > 0 || _timedItems.Count > 0;
bool hasClothes = _permanentClothes.Count > 0 || _timedClothes.Count > 0;
bool hasAmmo = _permanentAmmo.Count > 0 || _timedAmmo.Count > 0;
ToggleHook(nameof(CanEquipItem), hasItems);
ToggleHook(nameof(CanWearItem), hasClothes);
ToggleHook(nameof(OnMagazineReload), hasAmmo);
ToggleHook(nameof(CanBuild), hasItems);
}
/// <summary>
/// Subscribe or unsubscribe a named hook in a single call.
/// Reduces call-site verbosity in SubscribeHooks.
/// </summary>
private void ToggleHook(string hookName, bool enable)
{
if (enable) Subscribe(hookName);
else Unsubscribe(hookName);
}
#endregion
// ===================================================================
// REGION: Localization
//
// All player-facing strings are registered via the Oxide Lang API.
// Operators can override these by editing oxide/lang/en/ModernItemBlocker.json
// or by creating parallel files for other language codes (ru, es, la, etc.).
// ===================================================================
#region Localization
/// <summary>
/// Registers the default English message set with Oxide's Lang API.
/// Keys are stable across versions; new keys are added without changing old ones.
/// </summary>
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["ItemBlocked"] = "You cannot use this item.",
["ClothBlocked"] = "You cannot wear this clothing item.",
["AmmoBlocked"] = "You cannot use this ammunition.",
["BuildBlocked"] = "You cannot deploy this item.",
["TimedSuffix"] = "\n{0}d {1:00}:{2:00}:{3:00} remaining until unblock.",
["PermanentSuffix"] = "\nThis item is permanently blocked until removed by an admin.",
["NotAllowed"] = "You do not have permission to use this command.",
["InvalidArgs"] = "Invalid syntax. Use /modernblocker help for details.",
["NameTooLong"] = "Item name is too long (max 256 characters).",
["Added"] = "Added '{0}' to the {1} {2} list.",
["Removed"] = "Removed '{0}' from the {1} {2} list.",
["NotFound"] = "'{0}' was not found in the {1} {2} list.",
["ListHeader"] = "Permanent Items: {0}\nPermanent Clothes: {1}\nPermanent Ammo: {2}\nTimed Items: {3}\nTimed Clothes: {4}\nTimed Ammo: {5}",
["Usage"] = "Usage:\n /modernblocker list\n /modernblocker add <permanent|timed> <item|cloth|ammo> <n>\n /modernblocker remove <permanent|timed> <item|cloth|ammo> <n>\n /modernblocker reload\n /modernblocker loglist"
}, this);
}
/// <summary>
/// Retrieves a localised message string for the given key.
/// When a player is provided, the message is returned in their configured
/// Oxide language; otherwise the server default (usually English) is used.
/// </summary>
private string Msg(string key, BasePlayer player = null) =>
lang.GetMessage(key, this, player?.UserIDString);
#endregion
// ===================================================================
// REGION: Permission Helpers
// ===================================================================
#region Permission Helpers
/// <summary>
/// Returns true if the player holds the bypass permission configured in
/// _config.BypassPermission. Bypass exempts the player from all block checks.
/// </summary>
private bool IsBypass(BasePlayer player) =>
permission.UserHasPermission(player.UserIDString, _config.BypassPermission);
/// <summary>
/// Returns true if the IPlayer is either a server admin (IsAdmin flag set by
/// Oxide for the server console and RCON) or holds the admin permission node.
/// </summary>
private bool IsAdmin(IPlayer player) =>
player.IsAdmin || permission.UserHasPermission(player.Id, _config.AdminPermission);
#endregion
// ===================================================================
// REGION: Blocking Logic
//
// CheckBlocked and ShouldSkip are called from every hook handler.
// They are kept as thin as possible to minimise per-event cost.
// ===================================================================
#region Blocking Logic
/// <summary>
/// Determines whether an item identified by display name or shortname should
/// be blocked, and whether that block is permanent or timed.
///
/// Return value semantics (bool?):
/// true = item is permanently blocked.
/// false = item is timed-blocked (InTimedBlock is true and name is in timed set).
/// null = item is not blocked by any active rule.
///
/// Permanent blocks take priority over timed blocks. Timed blocks are only
/// evaluated when InTimedBlock is true (i.e. within the post-wipe window).
///
/// NOTE: This method returns a simple nullable bool (bool?), NOT a value tuple.
/// System.ValueTuple is not required and the code builds on all Oxide build
/// servers without additional assembly references.
///
/// Security: displayName and shortName are null-coerced to string.Empty before
/// HashSet lookups. Custom or modded items can return a null displayName.english
/// string; passing null to HashSet.Contains throws ArgumentNullException.
/// </summary>
/// <param name="displayName">
/// The English display name of the item, e.g. "Assault Rifle".
/// Sourced from item.info.displayName.english. May be null for custom items.
/// </param>
/// <param name="shortName">
/// The engine shortname of the item, e.g. "rifle.ak".
/// Sourced from item.info.shortname. May be null for custom items.
/// </param>
/// <param name="permanent">HashSet of permanently blocked names for this category.</param>
/// <param name="timed">HashSet of timed-block names for this category.</param>
private bool? CheckBlocked(
string displayName, string shortName,
HashSet<string> permanent, HashSet<string> timed)
{
var dn = displayName ?? string.Empty;
var sn = shortName ?? string.Empty;
if (permanent.Contains(dn) || permanent.Contains(sn))
return true;
if (InTimedBlock && (timed.Contains(dn) || timed.Contains(sn)))
return false;
return null;
}
/// <summary>
/// Returns true if block checks should be skipped for this player entirely.
///
/// Short-circuit evaluation order (cheap checks first):
/// 1. player is null (defensive; callers should filter before calling)
/// 2. player is an NPC
/// 3. player is currently in an active duel (requires a duel plugin)
/// 4. player holds the bypass permission
/// </summary>
private bool ShouldSkip(BasePlayer player) =>
player == null || IsNPC(player) || InDuel(player) || IsBypass(player);
// ---------------------------------------------------------------
// Hook: CanEquipItem
// Signature introduced in Oxide v2.0.7022 (Rust Naval Update).
// The targetSlot parameter is unused but required for hook matching.
// ---------------------------------------------------------------
/// <summary>
/// Oxide hook fired when a player attempts to equip an item.
/// Returns false to cancel; null to allow.
/// </summary>
private object CanEquipItem(PlayerInventory inventory, Item item, int targetSlot)
{
if (item?.info == null) return null;
var player = inventory?.GetBaseEntity() as BasePlayer;
if (ShouldSkip(player)) return null;
// Extract names once. Both CheckBlocked and LogBlockAttempt receive the
// same values so a null displayName.english (possible on custom items) is
// coerced to string.Empty in one place rather than separately by each callee.
var dn = item.info.displayName.english ?? string.Empty;
var sn = item.info.shortname ?? string.Empty;
var blocked = CheckBlocked(dn, sn, _permanentItems, _timedItems);
if (blocked == null) return null;
SendBlockMessage(player, "ItemBlocked", blocked.Value);
LogBlockAttempt(player, dn, sn, "item");
return false;
}
// ---------------------------------------------------------------
// Hook: CanWearItem
// Signature introduced in Oxide v2.0.7022 (Rust Naval Update).
// ---------------------------------------------------------------
/// <summary>
/// Oxide hook fired when a player attempts to equip a clothing item.
/// Returns false to cancel; null to allow.
/// </summary>
private object CanWearItem(PlayerInventory inventory, Item item, int targetSlot)
{
if (item?.info == null) return null;
var player = inventory?.GetBaseEntity() as BasePlayer;
if (ShouldSkip(player)) return null;
var dn = item.info.displayName.english ?? string.Empty;
var sn = item.info.shortname ?? string.Empty;
var blocked = CheckBlocked(dn, sn, _permanentClothes, _timedClothes);
if (blocked == null) return null;
SendBlockMessage(player, "ClothBlocked", blocked.Value);
LogBlockAttempt(player, dn, sn, "clothing");
return false;
}
// ---------------------------------------------------------------
// Hook: OnMagazineReload
// Introduced in Oxide v2.0.7022. Reads ammo type from the magazine
// definition, avoiding Facepunch Pool allocations.
// ---------------------------------------------------------------
/// <summary>
/// Oxide hook fired when a player's weapon begins a magazine reload cycle.
/// Returns false to cancel; null to allow.
/// </summary>
private object OnMagazineReload(BaseProjectile instance, IAmmoContainer ammoSource, BasePlayer player)
{
if (ShouldSkip(player)) return null;
var ammoType = instance?.primaryMagazine?.ammoType;
if (ammoType == null) return null;
var dn = ammoType.displayName.english ?? string.Empty;
var sn = ammoType.shortname ?? string.Empty;
var blocked = CheckBlocked(dn, sn, _permanentAmmo, _timedAmmo);
if (blocked == null) return null;
SendBlockMessage(player, "AmmoBlocked", blocked.Value);
LogBlockAttempt(player, dn, sn, "ammunition");
return false;
}
// ---------------------------------------------------------------
// Hook: CanBuild
// Fires BEFORE entity placement. Returning non-null cancels build.
// Replaces the former OnEntityBuilt hook (which fired AFTER placement;
// returning false was a no-op in <= 4.0.0).
// ---------------------------------------------------------------
/// <summary>
/// Oxide hook fired when a player attempts to place a deployable item.
/// Returns false to cancel before entity spawn; null to allow.
/// </summary>
private obj
// ... [Code preview truncated due to length. Full source in attached file] ...