new file: consent-plugin/pom.xml

new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
	new file:   consent-plugin/src/main/resources/config.yml
	new file:   consent-plugin/src/main/resources/plugin.yml
	modified:   web/app.py
	modified:   web/blueprints/group_admin.py
	modified:   web/panel_db.py
	modified:   web/templates/group_admin/base.html
	new file:   web/templates/group_admin/privacy_policy.html
	new file:   web/templates/group_policy.html
This commit is contained in:
simon
2026-04-17 11:41:35 +02:00
parent aa0544a4a5
commit 17a782b487
15 changed files with 1646 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
package de.simolzimol.mclogger.consent;
import org.bukkit.configuration.file.FileConfiguration;
import java.util.List;
/**
* Strongly-typed wrapper around the plugin's {@code config.yml}.
* Call {@link #reload()} after {@link ConsentPlugin#reloadConfig()} to
* pick up any changes.
*
* @author SimolZimol
*/
public class ConsentConfig {
/** Enforcement modes for the plugin. */
public enum EnforcementMode {
/** Kick the player at login if they have no stored consent. */
KICK,
/**
* Let the player join but freeze them until they accept.
* Kicks after the grace period if still pending.
*/
HOLD,
/** Allow full access; send periodic reminders but never kick. */
REMIND
}
private final ConsentPlugin plugin;
private FileConfiguration cfg;
public ConsentConfig(ConsentPlugin plugin) {
this.plugin = plugin;
reload();
}
/** Re-reads the YAML file into memory. */
public void reload() {
plugin.reloadConfig();
cfg = plugin.getConfig();
}
// ── Global ───────────────────────────────────────────────
public boolean isEnabled() {
return cfg.getBoolean("enabled", true);
}
// ── Database ─────────────────────────────────────────────
public String getDbHost() { return cfg.getString("database.host", "localhost"); }
public int getDbPort() { return cfg.getInt ("database.port", 3306); }
public String getDbDatabase() { return cfg.getString("database.database", "mclogger"); }
public String getDbUsername() { return cfg.getString("database.username", "mclogger"); }
public String getDbPassword() { return cfg.getString("database.password", ""); }
public boolean isDbSsl() { return cfg.getBoolean("database.ssl", false); }
public int getDbPoolSize() { return cfg.getInt ("database.pool-size", 5); }
// ── Consent settings ─────────────────────────────────────
public String getPolicyVersion() {
return cfg.getString("consent.policy-version", "1.0");
}
public String getPolicyUrl() {
return cfg.getString("consent.policy-url", "https://example.com/privacy-policy");
}
public EnforcementMode getEnforcementMode() {
String raw = cfg.getString("consent.enforcement-mode", "HOLD");
try {
return EnforcementMode.valueOf(raw.toUpperCase());
} catch (IllegalArgumentException e) {
plugin.getLogger().warning(
"[MCConsent] Unknown enforcement-mode '" + raw + "' defaulting to HOLD.");
return EnforcementMode.HOLD;
}
}
/** Display name for the consent command used in prompt text. */
public String getCommand() {
return cfg.getString("consent.command", "consent");
}
/** Seconds before a HOLD-mode player is kicked for not consenting (0 = infinite). */
public int getGracePeriodSeconds() {
return cfg.getInt("consent.grace-period-seconds", 120);
}
/** Seconds between reminder messages in REMIND mode (0 = once on join only). */
public int getReminderIntervalSeconds() {
return cfg.getInt("consent.reminder-interval-seconds", 300);
}
/** Minimum seconds between move-blocked messages (avoids chat spam). */
public int getMoveMessageCooldownSeconds() {
return cfg.getInt("consent.move-message-cooldown-seconds", 5);
}
/** Worlds listed here are exempt from consent enforcement. */
public List<String> getExemptWorlds() {
return cfg.getStringList("consent.exempt-worlds");
}
public String getBypassPermission() {
return cfg.getString("consent.bypass-permission", "consent.bypass");
}
// ── Admin settings ───────────────────────────────────────
public boolean isLogToMCLoggerDb() {
return cfg.getBoolean("admin.log-to-mclogger-db", true);
}
public boolean isNotifyAdmins() {
return cfg.getBoolean("admin.notify-admins", false);
}
public String getAdminPermission() {
return cfg.getString("admin.admin-permission", "consent.admin");
}
// ── Messages ─────────────────────────────────────────────
public String getPrefix() { return cfg.getString("messages.prefix", "<gold>[Privacy]</gold> "); }
public String getMsgJoinPrompt() { return cfg.getString("messages.join-prompt", "Please accept our Privacy Policy. Type /{command} accept"); }
public String getMsgAccepted() { return cfg.getString("messages.accepted", "<green>Thank you for accepting the Privacy Policy!</green>"); }
public String getMsgDeclined() { return cfg.getString("messages.declined", "<red>You declined the Privacy Policy.</red>"); }
public String getMsgKick() { return cfg.getString("messages.kick-message", "Privacy Policy consent required.\nVisit: {url}\nReconnect and type /{command} accept."); }
public String getMsgDeclineKick() { return cfg.getString("messages.decline-kick-message", "You declined the Privacy Policy."); }
public String getMsgGraceKick() { return cfg.getString("messages.grace-kick-message", "Consent timeout.\nVisit: {url}\nReconnect and type /{command} accept."); }
public String getMsgHoldMoveBlocked() { return cfg.getString("messages.hold-move-blocked", "<red>Accept the Privacy Policy first! Type /{command} accept</red>"); }
public String getMsgHoldChatBlocked() { return cfg.getString("messages.hold-chat-blocked", "<red>You cannot chat until you accept the Privacy Policy.</red>"); }
public String getMsgHoldCmdBlocked() { return cfg.getString("messages.hold-command-blocked", "<red>Commands disabled until you accept the Privacy Policy.</red>"); }
public String getMsgAlreadyAccepted() { return cfg.getString("messages.already-accepted", "<green>You have already accepted the current Privacy Policy.</green>"); }
public String getMsgNoConsentNeeded() { return cfg.getString("messages.no-consent-required", "<gray>No consent is currently required.</gray>"); }
public String getMsgGraceWarning() { return cfg.getString("messages.grace-warning", "<yellow>You have {seconds}s left to accept or you will be disconnected.</yellow>"); }
public String getMsgAdminAccept() { return cfg.getString("messages.admin-notify-accept", "<dark_gray>[Consent] {player} accepted (v{version}).</dark_gray>"); }
public String getMsgAdminDecline() { return cfg.getString("messages.admin-notify-decline", "<dark_gray>[Consent] {player} declined (v{version}).</dark_gray>"); }
public String getMsgHelpHeader() { return cfg.getString("messages.help-header", "<gold><bold>===== MCConsent Help =====</bold></gold>"); }
public String getMsgRemind() { return cfg.getString("messages.remind-message", "<yellow>Please accept our Privacy Policy. Type /{command} accept</yellow>"); }
public String getMsgStatusAccepted() { return cfg.getString("messages.status-accepted", "<green>You have accepted the Privacy Policy (v{version}).</green>"); }
public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "<yellow>You have NOT yet accepted the Privacy Policy (v{version}).</yellow>"); }
public String getMsgNoPermission() { return cfg.getString("messages.no-permission", "<red>You don't have permission to use this command.</red>"); }
public String getMsgAdminNotifyConsent() { return cfg.getString("messages.admin-notify-consent", "<dark_gray>[Consent] {player} accepted the Privacy Policy (v{version}).</dark_gray>"); }
/** Whether a message should be sent to online admins when a player accepts. */
public boolean isNotifyAdminsOnConsent() {
return cfg.getBoolean("admin.notify-admins-on-consent", false);
}
/**
* Returns the help text as a list of MiniMessage lines.
* Falls back to a built-in list if not configured.
*/
public List<String> getHelpLines() {
List<String> lines = cfg.getStringList("messages.help-lines");
if (!lines.isEmpty()) return lines;
return List.of(
getMsgHelpHeader(),
"<gray>/consent accept</gray> <white>Accept the Privacy Policy</white>",
"<gray>/consent decline</gray> <white>Decline and disconnect</white>",
"<gray>/consent status</gray> <white>Check your consent status</white>",
"<gray>Policy: <aqua>{policy_url}</aqua>"
);
}
}

View File

@@ -0,0 +1,109 @@
package de.simolzimol.mclogger.consent;
import de.simolzimol.mclogger.consent.commands.ConsentCommand;
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
import de.simolzimol.mclogger.consent.listeners.ConsentListener;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* MCConsent Privacy Policy consent enforcement for Paper servers.
*
* <p>Key components:
* <ul>
* <li>{@link ConsentConfig} Strongly-typed configuration wrapper</li>
* <li>{@link ConsentDatabase} HikariCP-backed MariaDB/MySQL client</li>
* <li>{@link ConsentListener} Enforces consent on login/join/move/chat</li>
* <li>{@link ConsentCommand} /consent accept|decline|status|admin …</li>
* </ul>
*
* @author SimolZimol
* @version 1.0.0
*/
public class ConsentPlugin extends JavaPlugin {
private static ConsentPlugin instance;
private ConsentConfig consentConfig;
private ConsentDatabase consentDatabase;
/**
* UUIDs of players who have joined but not yet consented (HOLD mode).
* Thread-safe because both the main thread and async events touch this set.
*/
private final Set<UUID> pendingConsent = ConcurrentHashMap.newKeySet();
// ─────────────────────────────────────────────────────────
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
consentConfig = new ConsentConfig(this);
if (!consentConfig.isEnabled()) {
getLogger().info("[MCConsent] Plugin is disabled via config (enabled: false).");
return;
}
// Connect to database
consentDatabase = new ConsentDatabase(this);
if (!consentDatabase.connect()) {
getLogger().severe("[MCConsent] Could not connect to the database disabling plugin.");
getServer().getPluginManager().disablePlugin(this);
return;
}
// Register event listeners
ConsentListener listener = new ConsentListener(this);
getServer().getPluginManager().registerEvents(listener, this);
// Register /consent command
ConsentCommand cmdHandler = new ConsentCommand(this);
PluginCommand cmd = getCommand("consent");
if (cmd != null) {
cmd.setExecutor(cmdHandler);
cmd.setTabCompleter(cmdHandler);
} else {
getLogger().warning("[MCConsent] Could not find 'consent' command in plugin.yml!");
}
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
+ " PolicyVersion=" + consentConfig.getPolicyVersion());
}
// ─────────────────────────────────────────────────────────
@Override
public void onDisable() {
pendingConsent.clear();
if (consentDatabase != null) {
consentDatabase.disconnect();
}
getLogger().info("[MCConsent] Disabled.");
}
// ─────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────
public static ConsentPlugin getInstance() {
return instance;
}
public ConsentConfig getConsentConfig() {
return consentConfig;
}
public ConsentDatabase getConsentDatabase() {
return consentDatabase;
}
/** Live set of players currently waiting for consent (HOLD mode). */
public Set<UUID> getPendingConsent() {
return pendingConsent;
}
}

View File

@@ -0,0 +1,265 @@
package de.simolzimol.mclogger.consent.commands;
import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentPlugin;
import de.simolzimol.mclogger.consent.util.MessageUtil;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.*;
import org.bukkit.entity.Player;
import java.util.*;
/**
* Handles the {@code /consent} command and its sub-commands.
*
* <p><b>Player sub-commands:</b>
* <ul>
* <li>{@code /consent accept} record consent for the current policy version</li>
* <li>{@code /consent decline} record decline and kick the player</li>
* <li>{@code /consent status} show current consent status</li>
* <li>{@code /consent help} show help text</li>
* </ul>
*
* <p><b>Admin sub-commands} (require {@code mclogger.consent.admin}):</b>
* <ul>
* <li>{@code /consent admin reload} reload plugin config</li>
* <li>{@code /consent admin list} list online players who haven't consented</li>
* <li>{@code /consent admin revoke <player>} revoke all consents for a player</li>
* <li>{@code /consent admin forceaccept <player>} record consent on behalf of a player</li>
* </ul>
*/
public class ConsentCommand implements CommandExecutor, TabCompleter {
private static final MiniMessage MM = MiniMessage.miniMessage();
private final ConsentPlugin plugin;
public ConsentCommand(ConsentPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────
// Command dispatch
// ─────────────────────────────────────────────────────────
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
ConsentConfig cfg = plugin.getConsentConfig();
if (args.length == 0) {
sendHelp(sender, cfg);
return true;
}
switch (args[0].toLowerCase(Locale.ROOT)) {
case "accept" -> handleAccept(sender, cfg);
case "decline" -> handleDecline(sender, cfg);
case "status" -> handleStatus(sender, cfg);
case "help" -> sendHelp(sender, cfg);
case "admin" -> {
if (!sender.hasPermission("mclogger.consent.admin")) {
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
return true;
}
handleAdmin(sender, cfg, args);
}
default -> sendHelp(sender, cfg);
}
return true;
}
// ─────────────────────────────────────────────────────────
// Player sub-commands
// ─────────────────────────────────────────────────────────
private void handleAccept(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
String ip = player.getAddress() != null
? player.getAddress().getAddress().getHostAddress() : "unknown";
plugin.getConsentDatabase().recordConsent(uuid, player.getName(), version, ip);
plugin.getPendingConsent().remove(player.getUniqueId());
String msg = MessageUtil.replace(cfg.getMsgAccepted(), player, cfg);
player.sendMessage(MM.deserialize(msg));
// Notify admins if configured
if (cfg.isNotifyAdminsOnConsent()) {
String notify = MessageUtil.replace(cfg.getMsgAdminNotifyConsent(), player, cfg);
broadcastAdmins(notify);
}
}
private void handleDecline(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
String ip = player.getAddress() != null
? player.getAddress().getAddress().getHostAddress() : "unknown";
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version, ip);
plugin.getPendingConsent().remove(player.getUniqueId());
String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg);
player.kick(MM.deserialize(msg));
}
private void handleStatus(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
boolean consented = plugin.getConsentDatabase()
.hasConsented(player.getUniqueId().toString(), cfg.getPolicyVersion());
String template = consented ? cfg.getMsgStatusAccepted() : cfg.getMsgStatusPending();
player.sendMessage(MM.deserialize(MessageUtil.replace(template, player, cfg)));
}
private void sendHelp(CommandSender sender, ConsentConfig cfg) {
for (String line : cfg.getHelpLines()) {
if (sender instanceof Player player) {
sender.sendMessage(MM.deserialize(MessageUtil.replace(line, player, cfg)));
} else {
// Strip MiniMessage tags for console
sender.sendMessage(MM.stripTags(line));
}
}
}
// ─────────────────────────────────────────────────────────
// Admin sub-commands
// ─────────────────────────────────────────────────────────
private void handleAdmin(CommandSender sender, ConsentConfig cfg, String[] args) {
if (args.length < 2) {
sender.sendMessage(MM.deserialize("<yellow>Usage: /consent admin <reload|list|revoke|forceaccept>"));
return;
}
switch (args[1].toLowerCase(Locale.ROOT)) {
case "reload" -> {
plugin.reloadConsentConfig();
sender.sendMessage(MM.deserialize("<green>[MCConsent] Config reloaded."));
}
case "list" -> {
Set<UUID> pending = plugin.getPendingConsent();
if (pending.isEmpty()) {
sender.sendMessage(MM.deserialize("<green>[MCConsent] No players are currently awaiting consent."));
return;
}
sender.sendMessage(MM.deserialize("<yellow>[MCConsent] Pending consent (" + pending.size() + "):"));
for (UUID uuid : pending) {
Player p = Bukkit.getPlayer(uuid);
String name = p != null ? p.getName() : uuid.toString();
sender.sendMessage(MM.deserialize("<gray> - " + name));
}
}
case "revoke" -> {
if (args.length < 3) {
sender.sendMessage(MM.deserialize("<red>Usage: /consent admin revoke <player>"));
return;
}
String target = args[2];
@SuppressWarnings("deprecation")
OfflinePlayer op = Bukkit.getOfflinePlayer(target);
if (!op.hasPlayedBefore() && !op.isOnline()) {
sender.sendMessage(MM.deserialize("<red>Player not found: " + target));
return;
}
plugin.getConsentDatabase().revokeConsent(op.getUniqueId().toString(), null);
// Also add to pending if currently online
if (op.isOnline()) {
plugin.getPendingConsent().add(op.getUniqueId());
}
sender.sendMessage(MM.deserialize("<green>[MCConsent] Consent revoked for " + op.getName()
+ ". They will need to re-accept on next login."));
}
case "forceaccept" -> {
if (args.length < 3) {
sender.sendMessage(MM.deserialize("<red>Usage: /consent admin forceaccept <player>"));
return;
}
String target = args[2];
@SuppressWarnings("deprecation")
OfflinePlayer op = Bukkit.getOfflinePlayer(target);
if (!op.hasPlayedBefore() && !op.isOnline()) {
sender.sendMessage(MM.deserialize("<red>Player not found: " + target));
return;
}
String version = cfg.getPolicyVersion();
plugin.getConsentDatabase().recordConsent(
op.getUniqueId().toString(),
op.getName() != null ? op.getName() : "unknown",
version, "admin-forced");
plugin.getPendingConsent().remove(op.getUniqueId());
sender.sendMessage(MM.deserialize("<green>[MCConsent] Consent recorded for " + op.getName()
+ " (version: " + version + ")."));
}
default -> sender.sendMessage(MM.deserialize(
"<yellow>Unknown admin sub-command. Use: reload | list | revoke | forceaccept"));
}
}
// ─────────────────────────────────────────────────────────
// Tab completion
// ─────────────────────────────────────────────────────────
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
List<String> base = new ArrayList<>(List.of("accept", "decline", "status", "help"));
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
return filter(base, args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("admin")
&& sender.hasPermission("mclogger.consent.admin")) {
return filter(List.of("reload", "list", "revoke", "forceaccept"), args[1]);
}
if (args.length == 3
&& args[0].equalsIgnoreCase("admin")
&& (args[1].equalsIgnoreCase("revoke") || args[1].equalsIgnoreCase("forceaccept"))
&& sender.hasPermission("mclogger.consent.admin")) {
List<String> names = new ArrayList<>();
Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName()));
return filter(names, args[2]);
}
return Collections.emptyList();
}
// ─────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────
private static List<String> filter(List<String> options, String prefix) {
List<String> result = new ArrayList<>();
String lower = prefix.toLowerCase(Locale.ROOT);
for (String opt : options) {
if (opt.toLowerCase(Locale.ROOT).startsWith(lower)) result.add(opt);
}
return result;
}
private void broadcastAdmins(String miniMsg) {
Bukkit.getOnlinePlayers().stream()
.filter(p -> p.hasPermission("mclogger.consent.admin"))
.forEach(p -> p.sendMessage(MM.deserialize(miniMsg)));
}
}

View File

@@ -0,0 +1,263 @@
package de.simolzimol.mclogger.consent.database;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentPlugin;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
/**
* HikariCP-backed database client for MCConsent.
*
* <p>Tables managed by this class:
* <ul>
* <li>{@code player_consent} stores accepted consents per UUID + version</li>
* <li>{@code player_consent_declines} audit trail of declined consents</li>
* </ul>
*
* <p>Both tables are created automatically on first connection.
* If {@code admin.log-to-mclogger-db} is {@code true} consent events are also
* inserted into the {@code server_events} table that MCLogger maintains.
*
* @author SimolZimol
*/
public class ConsentDatabase {
private final ConsentPlugin plugin;
private HikariDataSource dataSource;
public ConsentDatabase(ConsentPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────
/**
* Opens the connection pool and ensures the required tables exist.
*
* @return {@code true} if the connection was established successfully.
*/
public boolean connect() {
ConsentConfig cfg = plugin.getConsentConfig();
HikariConfig hk = new HikariConfig();
hk.setDriverClassName("de.simolzimol.mcconsent.lib.mariadb.jdbc.Driver");
hk.setJdbcUrl(String.format(
"jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
cfg.getDbHost(), cfg.getDbPort(), cfg.getDbDatabase(), cfg.isDbSsl()));
hk.setUsername(cfg.getDbUsername());
hk.setPassword(cfg.getDbPassword());
hk.setMaximumPoolSize(cfg.getDbPoolSize());
hk.setMinimumIdle(1);
hk.setConnectionTimeout(30_000);
hk.setIdleTimeout(600_000);
hk.setMaxLifetime(1_800_000);
hk.setPoolName("MCConsent");
hk.addDataSourceProperty("cachePrepStmts", "true");
hk.addDataSourceProperty("prepStmtCacheSize", "100");
try {
dataSource = new HikariDataSource(hk);
initTables();
return true;
} catch (Exception e) {
plugin.getLogger().log(Level.SEVERE, "[MCConsent] Database connection failed!", e);
return false;
}
}
/** Closes the connection pool. */
public void disconnect() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
// ─────────────────────────────────────────────────────────
// Table initialisation
// ─────────────────────────────────────────────────────────
private void initTables() throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// Accepted consents - primary record of who agreed to what version
stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent (" +
" uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," +
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," +
" PRIMARY KEY (uuid, policy_version)," +
" INDEX idx_consent_uuid (uuid)," +
" INDEX idx_consent_version (policy_version)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Decline audit trail - never deleted (important for GDPR accountability)
stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," +
" declined_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," +
" INDEX idx_decline_uuid (uuid)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
}
// ─────────────────────────────────────────────────────────
// Query helpers
// ─────────────────────────────────────────────────────────
/**
* Returns {@code true} if the player has accepted the given policy version.
* Runs synchronously; should be called from the main thread or a context
* where a brief pause is acceptable (e.g. {@code PlayerLoginEvent}).
*/
public boolean hasConsented(String uuid, String policyVersion) {
final String sql =
"SELECT 1 FROM player_consent WHERE uuid = ? AND policy_version = ? LIMIT 1";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, policyVersion);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] hasConsented() failed", e);
// Fail-open: if we can't check, allow the player in (avoids locking everyone out)
return true;
}
}
/**
* Records that the player has accepted the current policy version.
* Uses {@code INSERT IGNORE} so duplicate calls are safe.
*/
public void recordConsent(String uuid, String username, String policyVersion, String ip) {
final String sql =
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" +
" VALUES (?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, policyVersion);
ps.setString(4, ip);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
}
}
/**
* Records that the player declined the current policy version.
* Declines are appended to an audit table and are never deleted.
*/
public void recordDecline(String uuid, String username, String policyVersion, String ip) {
final String sql =
"INSERT INTO player_consent_declines (uuid, username, policy_version, ip_address)" +
" VALUES (?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, policyVersion);
ps.setString(4, ip);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
}
}
/**
* Removes a player's accepted consent for the given version.
* Used by {@code /consent admin revoke <player>} to force re-acceptance.
*
* @param uuid Player UUID string
* @param policyVersion Version to revoke; {@code null} revokes ALL versions
*/
public void revokeConsent(String uuid, String policyVersion) {
final String sql = policyVersion == null
? "DELETE FROM player_consent WHERE uuid = ?"
: "DELETE FROM player_consent WHERE uuid = ? AND policy_version = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
if (policyVersion != null) ps.setString(2, policyVersion);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] revokeConsent() failed", e);
}
}
/**
* Returns a list of UUIDs (as strings) that have no consent record for
* {@code currentVersion}. Only considers players known to the
* {@code players} table (MCLogger integration); returns an empty list if
* that table does not exist.
*/
public List<String> getPendingPlayerUuids(String currentVersion) {
final String sql =
"SELECT p.uuid FROM players p" +
" WHERE NOT EXISTS (" +
" SELECT 1 FROM player_consent c" +
" WHERE c.uuid = p.uuid AND c.policy_version = ?" +
" ) LIMIT 100";
List<String> result = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, currentVersion);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(rs.getString("uuid"));
}
} catch (SQLException e) {
// players table may not exist in standalone mode
plugin.getLogger().fine("[MCConsent] getPendingPlayerUuids(): " + e.getMessage());
}
return result;
}
/**
* Optionally writes a consent event into the MCLogger {@code server_events}
* table so it shows up in the web panel audit stream.
* Silently ignored if the table does not exist or the insert fails.
*
* @param eventType {@code "consent_accepted"} or {@code "consent_declined"}
* @param playerName In-game name for the log message
* @param uuid Player UUID string
* @param version Policy version string
*/
public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
final String sql =
"INSERT INTO server_events (event_type, server_name, message, details)" +
" VALUES (?, ?, ?, ?)";
String serverName = plugin.getConsentConfig().getDbDatabase();
String message = playerName + " " + eventType.replace('_', ' ') + " (v" + version + ")";
// Build minimal JSON manually to avoid a Gson dependency
String details = "{\"uuid\":\"" + uuid + "\",\"policy_version\":\"" + version + "\"}";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, eventType);
ps.setString(2, serverName);
ps.setString(3, message);
ps.setString(4, details);
ps.executeUpdate();
} catch (SQLException ignored) {
// Non-critical; server_events table may not exist in standalone mode
}
}
}

View File

@@ -0,0 +1,269 @@
package de.simolzimol.mclogger.consent.listeners;
import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
import de.simolzimol.mclogger.consent.ConsentPlugin;
import de.simolzimol.mclogger.consent.util.MessageUtil;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.*;
import org.bukkit.event.player.*;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Listens to player lifecycle events and enforces the configured consent mode.
*
* <p>Three modes:
* <ul>
* <li><b>KICK</b> player is kicked at {@link PlayerLoginEvent} if no consent.</li>
* <li><b>HOLD</b> player joins but movement/chat/commands are blocked until
* they type {@code /consent accept}. The grace-period timer
* runs as a scheduled task and kicks after the configured
* timeout.</li>
* <li><b>REMIND</b>- player joins normally; a repeating task sends reminder
* messages until they accept.</li>
* </ul>
*
* @author SimolZimol
*/
public class ConsentListener implements Listener {
private final ConsentPlugin plugin;
/** Last time a move-blocked message was sent to a player (millis). */
private final Map<UUID, Long> lastMoveMsgTime = new ConcurrentHashMap<>();
public ConsentListener(ConsentPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────
// LoginEvent: KICK mode enforcement
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST)
public void onLogin(PlayerLoginEvent event) {
ConsentConfig cfg = plugin.getConsentConfig();
if (!cfg.isEnabled()) return;
Player player = event.getPlayer();
if (player.hasPermission(cfg.getBypassPermission())) return;
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
// Only kick immediately in KICK mode; other modes handle it on join
if (cfg.getEnforcementMode() == EnforcementMode.KICK) {
boolean consented = plugin.getConsentDatabase().hasConsented(uuid, version);
if (!consented) {
String kickText = MessageUtil.replace(cfg.getMsgKick(), player, cfg);
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, kickText);
}
}
}
// ─────────────────────────────────────────────────────────
// JoinEvent: HOLD / REMIND mode setup
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
ConsentConfig cfg = plugin.getConsentConfig();
Player player = event.getPlayer();
if (!cfg.isEnabled()) return;
if (player.hasPermission(cfg.getBypassPermission())) return;
if (isExemptWorld(player, cfg)) return;
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
if (plugin.getConsentDatabase().hasConsented(uuid, version)) return;
EnforcementMode mode = cfg.getEnforcementMode();
// Send the join prompt
String prompt = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
player.sendMessage(MiniMessage.miniMessage().deserialize(prompt));
if (mode == EnforcementMode.HOLD) {
plugin.getPendingConsent().add(player.getUniqueId());
scheduleGraceKick(player);
} else if (mode == EnforcementMode.REMIND) {
int interval = cfg.getReminderIntervalSeconds();
if (interval > 0) {
scheduleReminder(player);
}
}
}
// ─────────────────────────────────────────────────────────
// QuitEvent: clean up
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
plugin.getPendingConsent().remove(uuid);
lastMoveMsgTime.remove(uuid);
}
// ─────────────────────────────────────────────────────────
// HOLD-mode: block movement
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (!isHoldPending(event.getPlayer())) return;
// Block if the player moved to a different block (ignore head rotation)
if (event.getFrom().getBlockX() == event.getTo().getBlockX()
&& event.getFrom().getBlockY() == event.getTo().getBlockY()
&& event.getFrom().getBlockZ() == event.getTo().getBlockZ()) {
return;
}
event.setCancelled(true);
sendThrottled(event.getPlayer(),
plugin.getConsentConfig().getMsgHoldMoveBlocked());
}
// ─────────────────────────────────────────────────────────
// HOLD-mode: block chat
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onChat(AsyncPlayerChatEvent event) {
if (!isHoldPending(event.getPlayer())) return;
event.setCancelled(true);
// Must run on main thread; schedule a tick delayed task
UUID uuid = event.getPlayer().getUniqueId();
Bukkit.getScheduler().runTask(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p != null) {
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgHoldChatBlocked(), p,
plugin.getConsentConfig());
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
});
}
// ─────────────────────────────────────────────────────────
// HOLD-mode: block commands (except /consent and /pp)
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCommand(PlayerCommandPreprocessEvent event) {
if (!isHoldPending(event.getPlayer())) return;
String cmd = event.getMessage().toLowerCase();
// Allow /consent, /privacy, /pp (any of the registered aliases)
if (cmd.startsWith("/consent") || cmd.startsWith("/privacy") || cmd.startsWith("/pp")) {
return;
}
event.setCancelled(true);
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgHoldCmdBlocked(),
event.getPlayer(), plugin.getConsentConfig());
event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
// ─────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────
private boolean isHoldPending(Player player) {
ConsentConfig cfg = plugin.getConsentConfig();
if (!cfg.isEnabled()) return false;
if (cfg.getEnforcementMode() != EnforcementMode.HOLD) return false;
return plugin.getPendingConsent().contains(player.getUniqueId());
}
private boolean isExemptWorld(Player player, ConsentConfig cfg) {
String worldName = player.getWorld().getName();
return cfg.getExemptWorlds().contains(worldName);
}
/**
* Sends a MiniMessage-formatted message to the player with a cooldown to
* avoid flooding the chat (e.g. when the player spams movement).
*/
private void sendThrottled(Player player, String miniMessageText) {
long now = System.currentTimeMillis();
long cooldown = plugin.getConsentConfig().getMoveMessageCooldownSeconds() * 1000L;
Long lastSent = lastMoveMsgTime.get(player.getUniqueId());
if (lastSent != null && (now - lastSent) < cooldown) return;
lastMoveMsgTime.put(player.getUniqueId(), now);
String msg = MessageUtil.replace(miniMessageText, player, plugin.getConsentConfig());
player.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
/**
* Schedules a task that kicks the player after the grace period if they
* still haven't consented. Silently cancelled if the player leaves first
* or accepts in time.
*/
private void scheduleGraceKick(Player player) {
int graceSec = plugin.getConsentConfig().getGracePeriodSeconds();
if (graceSec <= 0) return; // infinite grace
UUID uuid = player.getUniqueId();
// Warning at half of the grace period
int warnTick = (graceSec / 2) * 20;
if (warnTick > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p == null || !plugin.getPendingConsent().contains(uuid)) return;
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgGraceWarning()
.replace("{seconds}", String.valueOf(graceSec / 2)),
p, plugin.getConsentConfig());
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}, warnTick);
}
// Final kick
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p == null) return;
if (!plugin.getPendingConsent().contains(uuid)) return;
plugin.getPendingConsent().remove(uuid);
String kickMsg = MessageUtil.replace(
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
p.kick(MiniMessage.miniMessage().deserialize(kickMsg));
}, (long) graceSec * 20);
}
/**
* Schedules a repeating reminder task in REMIND mode.
* Cancels automatically when the player has consented or leaves.
*/
private void scheduleReminder(Player player) {
int intervalSec = plugin.getConsentConfig().getReminderIntervalSeconds();
if (intervalSec <= 0) return;
UUID uuid = player.getUniqueId();
long intervalTicks = (long) intervalSec * 20;
final int[] taskId = {-1};
taskId[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p == null) {
Bukkit.getScheduler().cancelTask(taskId[0]);
return;
}
// Stop reminding once they've accepted
if (plugin.getConsentDatabase().hasConsented(
uuid.toString(), plugin.getConsentConfig().getPolicyVersion())) {
Bukkit.getScheduler().cancelTask(taskId[0]);
return;
}
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgRemind(), p, plugin.getConsentConfig());
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}, intervalTicks, intervalTicks).getTaskId();
}
}

View File

@@ -0,0 +1,30 @@
package de.simolzimol.mclogger.consent.util;
import de.simolzimol.mclogger.consent.ConsentConfig;
import org.bukkit.entity.Player;
/**
* Simple placeholder replacement helper.
*/
public final class MessageUtil {
private MessageUtil() {}
/**
* Replaces standard placeholders in a MiniMessage string.
*
* <p>Supported placeholders:
* <ul>
* <li>{@code {player}} player's display name</li>
* <li>{@code {policy_url}} configured policy URL</li>
* <li>{@code {version}} policy version string</li>
* </ul>
*/
public static String replace(String template, Player player, ConsentConfig cfg) {
if (template == null) return "";
return template
.replace("{player}", player.getName())
.replace("{policy_url}", cfg.getPolicyUrl())
.replace("{version}", cfg.getPolicyVersion());
}
}

View File

@@ -0,0 +1,191 @@
# ============================================================
# MCConsent Paper Plugin Configuration
# Author: SimolZimol
# Docs: https://github.com/SimolZimol/MCLogger
# ============================================================
# ── Global switch ────────────────────────────────────────────
# Set to false to completely disable all enforcement (plugin stays
# loaded but won't block any player).
enabled: true
# ── Database ─────────────────────────────────────────────────
# Connection details for the MariaDB/MySQL database where consent
# records are stored. You can point this at the same database as
# the MCLogger plugin so consent events appear in the web panel.
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "change_me_please"
ssl: false
# Maximum number of concurrent DB connections
pool-size: 5
# ── Consent settings ─────────────────────────────────────────
consent:
# ── Policy version ──────────────────────────────────────────
# Change this value every time you update your Privacy Policy.
# Players who accepted an older version will be prompted again.
# Tip: use the Document ID shown on your MCLogger web panel
# policy page (e.g. "ABC123") for automatic synchronisation.
policy-version: "1.0"
# ── Policy URL ──────────────────────────────────────────────
# Full URL players can visit to read the Privacy Policy.
# If you host it via the MCLogger web panel, the URL looks like:
# https://your-panel.example.com/policy/<group-id>
policy-url: "https://example.com/privacy-policy"
# ── Enforcement mode ────────────────────────────────────────
# KICK Kick the player immediately at login if no consent.
# Players see a disconnect screen with the policy URL.
# HOLD Allow the player to join but freeze them in place.
# They cannot move, chat, or run commands until they
# accept (or the grace period expires and they get kicked).
# REMIND Let the player join freely; send periodic reminders
# but never kick. Useful for soft opt-in scenarios.
enforcement-mode: HOLD
# ── Command name ────────────────────────────────────────────
# The command players type to interact with the consent system.
# NOTE: This only changes display text in prompts.
# The actual Bukkit command is always "/consent" (registered
# in plugin.yml with aliases /privacy and /pp).
command: "consent"
# ── Grace period (HOLD mode only) ───────────────────────────
# How many seconds a player has to accept before being kicked.
# Set to 0 to wait forever (player can take as long as they like).
grace-period-seconds: 120
# ── Reminder interval (REMIND mode only) ────────────────────
# How often (seconds) to resend the consent prompt to players
# who haven't responded. 0 = send once on join only.
reminder-interval-seconds: 300
# ── Move-message cooldown ────────────────────────────────────
# Minimum seconds between "please accept first" messages when a
# player tries to move in HOLD mode. Prevents chat spam.
move-message-cooldown-seconds: 5
# ── Exempt worlds ────────────────────────────────────────────
# Players in these worlds are not subject to consent enforcement.
# Useful if you have a lobby/hub world where players arrive before
# being redirected to a game world.
# Example: ["world_lobby", "world_hub"]
exempt-worlds: []
# ── Bypass permission ────────────────────────────────────────
# Players with this permission bypass all consent checks.
# Default: consent.bypass (assigned to ops by default)
bypass-permission: "consent.bypass"
# ── Admin settings ───────────────────────────────────────────
admin:
# Log consent accept/decline events to the server_events table in
# the MCLogger database. Requires database.database to point to
# the same DB as MCLogger.
log-to-mclogger-db: true
# Broadcast a message to online admins when a player
# accepts or declines the policy.
notify-admins: false
# Permission node required to use admin sub-commands:
# /consent admin reload
# /consent admin list
# /consent admin revoke <player>
# /consent admin forceaccept <player>
admin-permission: "consent.admin"
# ── Messages ─────────────────────────────────────────────────
# All messages support MiniMessage formatting tags:
# <red>, <green>, <gold>, <bold>, <italic>, <underlined>
# <click:open_url:'URL'>, <hover:show_text:'text'>, etc.
# Available placeholders:
# {url} The policy URL (consent.policy-url)
# {command} The consent command (consent.command)
# {player} The player's name
# {version} The current policy version
# {seconds} Remaining grace period seconds (where applicable)
messages:
# ── Prefix ──────────────────────────────────────────────────
# Prepended to every plugin message.
prefix: "<gold>[Privacy]</gold> "
# ── Join prompt ─────────────────────────────────────────────
# Sent to a player when they join and consent is required.
join-prompt: |
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
<gray>Before you can play you must read and accept our Privacy Policy.</gray>
<aqua><click:open_url:'{url}'><hover:show_text:'<gray>Click to open in browser</gray>'>📄 {url}</hover></click></aqua>
<green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
# ── Accepted ────────────────────────────────────────────────
accepted: "<green>✔ Thank you! You have accepted the Privacy Policy (v{version}). Enjoy your stay!</green>"
# ── Declined (shown just before the player is kicked) ───────
declined: "<red>You declined the Privacy Policy. You cannot play on this server without accepting it.</red>"
# ── Kick message KICK mode, no prior consent ───────────────
# This is the disconnect screen message (plain text only
# some Minecraft versions strip MiniMessage in kick screens).
kick-message: |
Privacy Policy Consent Required
You must accept our Privacy Policy to play on this server.
Visit: {url}
Reconnect and type /{command} accept to accept.
# ── Kick message player declined ──────────────────────────
decline-kick-message: |
Privacy Policy Declined
You have declined our Privacy Policy.
You cannot play on this server without accepting it.
Contact an admin if you have questions.
# ── Grace period kick (HOLD mode) ───────────────────────────
grace-kick-message: |
Consent Timeout
You did not accept the Privacy Policy within the allowed time.
Visit: {url}
Reconnect and type /{command} accept to accept.
# ── Movement blocked (HOLD mode) ────────────────────────────
hold-move-blocked: "<red>⛔ Please accept the Privacy Policy first! Type /<bold>{command} accept</bold>.</red>"
# ── Chat blocked (HOLD mode) ────────────────────────────────
hold-chat-blocked: "<red>⛔ You cannot chat until you accept the Privacy Policy. Type /<bold>{command} accept</bold>.</red>"
# ── Command blocked (HOLD mode) ─────────────────────────────
hold-command-blocked: "<red>⛔ Commands are disabled until you accept the Privacy Policy. Type /<bold>{command} accept</bold>.</red>"
# ── Already accepted ────────────────────────────────────────
already-accepted: "<green>✔ You have already accepted the current Privacy Policy (v{version}).</green>"
# ── No consent currently required ───────────────────────────
no-consent-required: "<gray>No consent is currently required on this server.</gray>"
# ── Grace period warning ─────────────────────────────────────
grace-warning: "<yellow>⚠ You have <bold>{seconds}s</bold> left to accept the Privacy Policy or you will be disconnected.</yellow>"
# ── Admin broadcast: player accepted ────────────────────────
admin-notify-accept: "<dark_gray>[Consent] <white>{player}</white> accepted the Privacy Policy (v{version}).</dark_gray>"
# ── Admin broadcast: player declined ────────────────────────
admin-notify-decline: "<dark_gray>[Consent] <white>{player}</white> <red>declined</red> the Privacy Policy (v{version}).</dark_gray>"
# ── Help header ─────────────────────────────────────────────
help-header: "<gold><bold>===== MCConsent Help =====</bold></gold>"
# ── Remind mode reminder ─────────────────────────────────────
remind-message: |
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
<aqua><click:open_url:'{url}'>{url}</click></aqua>
<green>Type /<bold>{command} accept</bold> to accept.</green>

View File

@@ -0,0 +1,28 @@
name: MCConsent
version: '1.0.0'
main: de.simolzimol.mclogger.consent.ConsentPlugin
api-version: '1.20'
description: Privacy Policy consent enforcement for Minecraft servers
author: SimolZimol
commands:
consent:
description: Accept, decline, or check your Privacy Policy consent status
usage: /consent <accept|decline|status|help>
permission: consent.use
aliases:
- privacy
- pp
permissions:
consent.use:
description: Use the /consent command
default: true
consent.bypass:
description: Skip consent checks entirely (e.g. for staff / ops)
default: op
consent.admin:
description: Access admin sub-commands (reload, list, revoke, forceaccept)
default: op