Repository: python-discord/bot
Issue: Don't allow editing an active infraction's expiration into the past

`!infraction edit` can take an ISO datetime to edit the expiration time, but there's no guard to prevent it from specifying a past date, immediately pardoning the infraction as a result.

The same goes for `!infraction append`.

Changed files:
bot/decorators.py, bot/exts/moderation/infraction/superstarify.py, bot/exts/moderation/infraction/infractions.py, bot/exts/moderation/infraction/management.py

diff --git a/bot/decorators.py b/bot/decorators.py
index f4331264..8971898b 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -4,6 +4,7 @@ import types
 import typing as t
 from contextlib import suppress
 
+import arrow
 from discord import Member, NotFound
 from discord.ext import commands
 from discord.ext.commands import Cog, Context
@@ -236,3 +237,35 @@ def mock_in_debug(return_value: t.Any) -> t.Callable:
             return await func(*args, **kwargs)
         return wrapped
     return decorator
+
+
+def ensure_future_timestamp(timestamp_arg: function.Argument) -> t.Callable:
+    """
+    Ensure the timestamp argument is in the future.
+
+    If the condition fails, send a warning to the invoking context.
+
+    `timestamp_arg` is the keyword name or position index of the parameter of the decorated command
+    whose value is the target timestamp.
+
+    This decorator must go before (below) the `command` decorator.
+    """
+    def decorator(func: types.FunctionType) -> types.FunctionType:
+        @command_wraps(func)
+        async def wrapper(*args, **kwargs) -> t.Any:
+            bound_args = function.get_bound_args(func, args, kwargs)
+            target = function.get_arg_value(timestamp_arg, bound_args)
+
+            ctx = function.get_arg_value(1, bound_args)
+
+            try:
+                is_future = target > arrow.utcnow()
+            except TypeError:
+                is_future = True
+            if not is_future:
+                await ctx.send(":x: Provided timestamp is in the past.")
+                return
+
+            return await func(*args, **kwargs)
+        return wrapper
+    return decorator
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index af42ab1b..18bed508 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -10,7 +10,7 @@ from bot import constants
 from bot.bot import Bot
 from bot.constants import Event
 from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser
-from bot.decorators import respect_role_hierarchy
+from bot.decorators import ensure_future_timestamp, respect_role_hierarchy
 from bot.exts.moderation.infraction import _utils
 from bot.exts.moderation.infraction._scheduler import InfractionScheduler
 from bot.log import get_logger
@@ -81,6 +81,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await self.apply_kick(ctx, user, reason)
 
     @command()
+    @ensure_future_timestamp(timestamp_arg=3)
     async def ban(
         self,
         ctx: Context,
@@ -97,6 +98,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await self.apply_ban(ctx, user, reason, expires_at=duration)
 
     @command(aliases=("cban", "purgeban", "pban"))
+    @ensure_future_timestamp(timestamp_arg=3)
     async def cleanban(
         self,
         ctx: Context,
@@ -161,6 +163,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `voicemute`?")
 
     @command(aliases=("vmute",))
+    @ensure_future_timestamp(timestamp_arg=3)
     async def voicemute(
         self,
         ctx: Context,
@@ -180,6 +183,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     # region: Temporary infractions
 
     @command(aliases=["mute"])
+    @ensure_future_timestamp(timestamp_arg=3)
     async def tempmute(
         self, ctx: Context,
         user: UnambiguousMemberOrUser,
@@ -213,6 +217,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await self.apply_mute(ctx, user, reason, expires_at=duration)
 
     @command(aliases=("tban",))
+    @ensure_future_timestamp(timestamp_arg=3)
     async def tempban(
         self,
         ctx: Context,
@@ -248,6 +253,7 @@ class Infractions(InfractionScheduler, commands.Cog):
         await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `tempvoicemute`?")
 
     @command(aliases=("tempvmute", "tvmute"))
+    @ensure_future_timestamp(timestamp_arg=3)
     async def tempvoicemute(
         self,
         ctx: Context,
@@ -294,6 +300,7 @@ class Infractions(InfractionScheduler, commands.Cog):
     # region: Temporary shadow infractions
 
     @command(hidden=True, aliases=["shadowtempban", "stempban", "stban"])
+    @ensure_future_timestamp(timestamp_arg=3)
     async def shadow_tempban(
         self,
         ctx: Context,
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index c12dff92..62d34951 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -9,6 +9,7 @@ from discord.utils import escape_markdown
 from bot import constants
 from bot.bot import Bot
 from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings
+from bot.decorators import ensure_future_timestamp
 from bot.errors import InvalidInfraction
 from bot.exts.moderation.infraction import _utils
 from bot.exts.moderation.infraction.infractions import Infractions
@@ -122,6 +123,7 @@ class ModManagement(commands.Cog):
         await self.infraction_edit(ctx, infraction, duration, reason=reason)
 
     @infraction_group.command(name='edit', aliases=('e',))
+    @ensure_future_timestamp(timestamp_arg=3)
     async def infraction_edit(
         self,
         ctx: Context,
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index b91a5edb..c4a7e508 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -11,6 +11,7 @@ from discord.utils import escape_markdown
 from bot import constants
 from bot.bot import Bot
 from bot.converters import Duration, Expiry
+from bot.decorators import ensure_future_timestamp
 from bot.exts.moderation.infraction import _utils
 from bot.exts.moderation.infraction._scheduler import InfractionScheduler
 from bot.log import get_logger
@@ -103,6 +104,7 @@ class Superstarify(InfractionScheduler, Cog):
             await self.reapply_infraction(infraction, action)
 
     @command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar"))
+    @ensure_future_timestamp(timestamp_arg=3)
     async def superstarify(
         self,
         ctx: Context,