Skip to content

Commit 78e4602

Browse files
committed
Add command audit feature
Signed-off-by: applenick <applenick@users.noreply.github.com>
1 parent bcad210 commit 78e4602

File tree

4 files changed

+303
-0
lines changed

4 files changed

+303
-0
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package dev.pgm.community.audit;
2+
3+
import com.google.common.collect.Lists;
4+
import dev.pgm.community.feature.config.FeatureConfigImpl;
5+
import java.util.List;
6+
import org.bukkit.configuration.Configuration;
7+
8+
public class CommandAuditConfig extends FeatureConfigImpl {
9+
10+
private static final String KEY = "command-audit";
11+
12+
private List<String> auditPermissions;
13+
private List<String> exemptPermissions;
14+
private List<String> includePrefixes;
15+
private List<String> includeCommands;
16+
private List<String> includePermissionContains;
17+
private List<String> excludeCommands;
18+
private boolean clickTeleport;
19+
20+
public CommandAuditConfig(Configuration config) {
21+
super(KEY, config);
22+
}
23+
24+
public List<String> getAuditPermissions() {
25+
return auditPermissions;
26+
}
27+
28+
public List<String> getExemptPermissions() {
29+
return exemptPermissions;
30+
}
31+
32+
public List<String> getIncludePrefixes() {
33+
return includePrefixes;
34+
}
35+
36+
public List<String> getIncludeCommands() {
37+
return includeCommands;
38+
}
39+
40+
public List<String> getIncludePermissionContains() {
41+
return includePermissionContains;
42+
}
43+
44+
public List<String> getExcludeCommands() {
45+
return excludeCommands;
46+
}
47+
48+
public boolean isClickTeleportEnabled() {
49+
return clickTeleport;
50+
}
51+
52+
@Override
53+
public void reload(Configuration config) {
54+
super.reload(config);
55+
this.auditPermissions =
56+
normalizePermissionList(config.getStringList(KEY + ".audit-permissions"));
57+
this.exemptPermissions =
58+
normalizePermissionList(config.getStringList(KEY + ".exempt-permissions"));
59+
this.includePrefixes = normalizeCommandList(config.getStringList(KEY + ".include-prefixes"));
60+
this.includeCommands = normalizeCommandList(config.getStringList(KEY + ".include-commands"));
61+
this.includePermissionContains =
62+
normalizePermissionList(config.getStringList(KEY + ".include-permissions"));
63+
this.excludeCommands = normalizeCommandList(config.getStringList(KEY + ".exclude-commands"));
64+
this.clickTeleport = config.getBoolean(KEY + ".click-teleport");
65+
}
66+
67+
private List<String> normalizeCommandList(List<String> list) {
68+
List<String> normalized = Lists.newArrayList();
69+
if (list == null) {
70+
return normalized;
71+
}
72+
for (String value : list) {
73+
if (value == null) continue;
74+
String trimmed = value.trim().toLowerCase();
75+
if (trimmed.isEmpty()) continue;
76+
if (!trimmed.startsWith("/")) {
77+
trimmed = "/" + trimmed;
78+
}
79+
normalized.add(trimmed);
80+
}
81+
return normalized;
82+
}
83+
84+
private List<String> normalizePermissionList(List<String> list) {
85+
List<String> normalized = Lists.newArrayList();
86+
if (list == null) {
87+
return normalized;
88+
}
89+
for (String value : list) {
90+
if (value == null) continue;
91+
String trimmed = value.trim().toLowerCase();
92+
if (trimmed.isEmpty()) continue;
93+
normalized.add(trimmed);
94+
}
95+
return normalized;
96+
}
97+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package dev.pgm.community.audit;
2+
3+
import static net.kyori.adventure.text.Component.newline;
4+
import static net.kyori.adventure.text.Component.space;
5+
import static net.kyori.adventure.text.Component.text;
6+
import static tc.oc.pgm.util.player.PlayerComponent.player;
7+
8+
import dev.pgm.community.feature.FeatureBase;
9+
import dev.pgm.community.utils.BroadcastUtils;
10+
import java.util.List;
11+
import java.util.logging.Logger;
12+
import net.kyori.adventure.text.Component;
13+
import net.kyori.adventure.text.TextComponent;
14+
import net.kyori.adventure.text.event.ClickEvent;
15+
import net.kyori.adventure.text.event.HoverEvent;
16+
import net.kyori.adventure.text.format.NamedTextColor;
17+
import org.bukkit.Bukkit;
18+
import org.bukkit.command.Command;
19+
import org.bukkit.command.CommandMap;
20+
import org.bukkit.command.PluginCommand;
21+
import org.bukkit.configuration.Configuration;
22+
import org.bukkit.entity.Player;
23+
import org.bukkit.event.EventHandler;
24+
import org.bukkit.event.EventPriority;
25+
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
26+
import tc.oc.pgm.util.named.NameStyle;
27+
28+
public class CommandAuditFeature extends FeatureBase {
29+
30+
public CommandAuditFeature(Configuration config, Logger logger) {
31+
super(new CommandAuditConfig(config), logger, "Staff Command Audit");
32+
33+
if (getConfig().isEnabled()) {
34+
enable();
35+
}
36+
}
37+
38+
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
39+
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
40+
Player player = event.getPlayer();
41+
if (!shouldAudit(player)) return;
42+
43+
String command = event.getMessage();
44+
if (!shouldLog(command)) return;
45+
46+
Component alert = buildAlert(player, command);
47+
BroadcastUtils.sendAdminChatMessage(alert, null, null, null);
48+
}
49+
50+
private boolean shouldAudit(Player player) {
51+
CommandAuditConfig config = getCommandAuditConfig();
52+
if (hasAnyPermission(player, config.getExemptPermissions())) {
53+
return false;
54+
}
55+
List<String> auditPermissions = config.getAuditPermissions();
56+
return auditPermissions.isEmpty() || hasAnyPermission(player, auditPermissions);
57+
}
58+
59+
private boolean shouldLog(String command) {
60+
if (command == null) return false;
61+
String normalized = command.trim().toLowerCase();
62+
if (normalized.isEmpty()) return false;
63+
64+
CommandAuditConfig config = getCommandAuditConfig();
65+
if (matchesAny(normalized, config.getExcludeCommands())) return false;
66+
if (matchesAny(normalized, config.getIncludePrefixes())) return true;
67+
if (matchesAny(normalized, config.getIncludeCommands())) return true;
68+
return matchesCommandPermission(normalized, config.getIncludePermissionContains());
69+
}
70+
71+
private boolean matchesCommandPermission(String command, List<String> permissionContains) {
72+
if (permissionContains.isEmpty()) return false;
73+
String label = extractCommandLabel(command);
74+
if (label.isEmpty()) return false;
75+
Command cmd = getCommand(label);
76+
if (cmd == null || cmd.getPermission() == null) return false;
77+
78+
String permission = cmd.getPermission().toLowerCase();
79+
for (String matcher : permissionContains) {
80+
if (permission.contains(matcher)) {
81+
return true;
82+
}
83+
}
84+
return false;
85+
}
86+
87+
private Command getCommand(String label) {
88+
PluginCommand pluginCommand = Bukkit.getPluginCommand(label);
89+
if (pluginCommand != null) {
90+
return pluginCommand;
91+
}
92+
93+
try {
94+
CommandMap commandMap = Bukkit.getServer().getCommandMap();
95+
if (commandMap != null) {
96+
return commandMap.getCommand(label);
97+
}
98+
} catch (NoSuchMethodError ignored) {
99+
100+
}
101+
return null;
102+
}
103+
104+
private String extractCommandLabel(String command) {
105+
String[] parts = command.split(" ");
106+
if (parts.length == 0) return "";
107+
String label = parts[0];
108+
if (label.startsWith("/")) {
109+
label = label.substring(1);
110+
}
111+
int namespaceIndex = label.indexOf(':');
112+
if (namespaceIndex >= 0 && namespaceIndex + 1 < label.length()) {
113+
label = label.substring(namespaceIndex + 1);
114+
}
115+
return label;
116+
}
117+
118+
private boolean matchesAny(String command, List<String> values) {
119+
for (String value : values) {
120+
if (command.startsWith(value)) return true;
121+
}
122+
return false;
123+
}
124+
125+
private boolean hasAnyPermission(Player player, List<String> permissions) {
126+
for (String permission : permissions) {
127+
if (player.hasPermission(permission)) {
128+
return true;
129+
}
130+
}
131+
return false;
132+
}
133+
134+
private Component buildAlert(Player player, String command) {
135+
TextComponent.Builder builder = text()
136+
.append(player(player, NameStyle.FANCY))
137+
.append(text(" executed ", NamedTextColor.GRAY))
138+
.append(text(command, NamedTextColor.LIGHT_PURPLE));
139+
140+
if (getCommandAuditConfig().isClickTeleportEnabled()) {
141+
builder.hoverEvent(HoverEvent.showText(DEFAULT_HOVER));
142+
builder.append(space()).append(buildViewComponent(player));
143+
} else {
144+
builder.hoverEvent(HoverEvent.showText(DEFAULT_HOVER));
145+
}
146+
147+
return builder.build();
148+
}
149+
150+
private static final Component DEFAULT_HOVER = text(
151+
"You've been alerted to this action", NamedTextColor.GRAY)
152+
.append(newline())
153+
.append(text("in order to promote transparency.", NamedTextColor.GRAY));
154+
155+
private Component buildViewComponent(Player player) {
156+
return text()
157+
.append(text("[", NamedTextColor.GRAY))
158+
.append(text("View", NamedTextColor.AQUA))
159+
.append(text("]", NamedTextColor.GRAY))
160+
.clickEvent(ClickEvent.runCommand(getTeleportCommand(player)))
161+
.hoverEvent(HoverEvent.showText(text("Click to teleport", NamedTextColor.GRAY)))
162+
.build();
163+
}
164+
165+
private String getTeleportCommand(Player player) {
166+
return String.format(
167+
"/tploc %d %d %d",
168+
player.getLocation().getBlockX(),
169+
player.getLocation().getBlockY(),
170+
player.getLocation().getBlockZ());
171+
}
172+
173+
public CommandAuditConfig getCommandAuditConfig() {
174+
return (CommandAuditConfig) getConfig();
175+
}
176+
}

core/src/main/java/dev/pgm/community/feature/FeatureManager.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import dev.pgm.community.assistance.feature.AssistanceFeature;
44
import dev.pgm.community.assistance.feature.types.SQLAssistanceFeature;
5+
import dev.pgm.community.audit.CommandAuditFeature;
56
import dev.pgm.community.broadcast.BroadcastFeature;
67
import dev.pgm.community.chat.management.ChatManagementFeature;
78
import dev.pgm.community.chat.network.NetworkChatFeature;
@@ -55,6 +56,7 @@ public class FeatureManager {
5556
private final FreezeFeature freeze;
5657
private final MutationFeature mutation;
5758
private final BroadcastFeature broadcast;
59+
private final CommandAuditFeature commandAudit;
5860
private final MobFeature mob;
5961
private final MapPartyFeature party;
6062
private final PollFeature polls;
@@ -91,6 +93,7 @@ public FeatureManager(
9193
this.freeze = new FreezeFeature(config, logger);
9294
this.mutation = new MutationFeature(config, logger, inventory);
9395
this.broadcast = new BroadcastFeature(config, logger);
96+
this.commandAudit = new CommandAuditFeature(config, logger);
9497
this.chatNetwork = new NetworkChatFeature(config, logger, network);
9598
this.mob = new MobFeature(config, logger);
9699
this.party = new MapPartyFeature(config, logger);
@@ -151,6 +154,10 @@ public BroadcastFeature getBroadcast() {
151154
return broadcast;
152155
}
153156

157+
public CommandAuditFeature getCommandAudit() {
158+
return commandAudit;
159+
}
160+
154161
public NetworkChatFeature getNetworkChat() {
155162
return chatNetwork;
156163
}
@@ -193,6 +200,7 @@ public void reloadConfig(Configuration config) {
193200
getMutations().getConfig().reload(config);
194201
getBroadcast().getConfig().reload(config);
195202
getNick().getConfig().reload(config);
203+
getCommandAudit().getConfig().reload(config);
196204
getNetworkChat().getConfig().reload(config);
197205
getRequests().getConfig().reload(config);
198206
getMobs().getConfig().reload(config);
@@ -219,6 +227,7 @@ public void disable() {
219227
if (getMutations().isEnabled()) getMutations().disable();
220228
if (getBroadcast().isEnabled()) getBroadcast().disable();
221229
if (getNick().isEnabled()) getNick().disable();
230+
if (getCommandAudit().isEnabled()) getCommandAudit().disable();
222231
if (getNetworkChat().isEnabled()) getNetworkChat().disable();
223232
if (getRequests().isEnabled()) getRequests().disable();
224233
if (getMobs().isEnabled()) getMobs().disable();

core/src/main/resources/config.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,27 @@ broadcast:
218218
- "&3This is an alert, you can adjust the messages inside of &aconfig.yml"
219219
- "&a&lYou can use &b&lcolor &4&lcodes &6&ltoo!"
220220

221+
# Command Audit - Broadcast staff actions to admin chat
222+
command-audit:
223+
enabled: true
224+
audit-permissions: # Only audit commands from players with one of these permissions
225+
- "community.staff"
226+
exempt-permissions: # Skip auditing when a player has any of these permissions
227+
- "community.command-audit.exempt"
228+
click-teleport: true # Allow clicking the alert to teleport to the command location
229+
include-prefixes: # Prefixes to always audit (matches the start of the full command)
230+
- "//"
231+
- "/worldedit:"
232+
- "/minecraft:"
233+
include-permissions: # Command permission nodes that include any of these strings will be audited
234+
- "worldedit"
235+
include-commands: # Commands to audit (matches the start of the full command)
236+
- "/summon"
237+
- "/enchant"
238+
exclude-commands: # Commands to ignore (matches the start of the full command)
239+
- "/sudo"
240+
- "/community:sudo"
241+
221242
# Freeze - Freeze players via command or observer hotbar tool (when PGM is enabled)
222243
freeze:
223244
enabled: true

0 commit comments

Comments
 (0)