From 17a782b487e387b8925e995d2b1eb199f116882a Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 17 Apr 2026 11:41:35 +0200 Subject: [PATCH] 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 --- consent-plugin/pom.xml | 106 +++++++ .../mclogger/consent/ConsentConfig.java | 167 +++++++++++ .../mclogger/consent/ConsentPlugin.java | 109 +++++++ .../consent/commands/ConsentCommand.java | 265 +++++++++++++++++ .../consent/database/ConsentDatabase.java | 263 +++++++++++++++++ .../consent/listeners/ConsentListener.java | 269 ++++++++++++++++++ .../mclogger/consent/util/MessageUtil.java | 30 ++ consent-plugin/src/main/resources/config.yml | 191 +++++++++++++ consent-plugin/src/main/resources/plugin.yml | 28 ++ web/app.py | 10 + web/blueprints/group_admin.py | 34 +++ web/panel_db.py | 35 +++ web/templates/group_admin/base.html | 1 + web/templates/group_admin/privacy_policy.html | 74 +++++ web/templates/group_policy.html | 64 +++++ 15 files changed, 1646 insertions(+) create mode 100644 consent-plugin/pom.xml create mode 100644 consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java create mode 100644 consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java create mode 100644 consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java create mode 100644 consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java create mode 100644 consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java create mode 100644 consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java create mode 100644 consent-plugin/src/main/resources/config.yml create mode 100644 consent-plugin/src/main/resources/plugin.yml create mode 100644 web/templates/group_admin/privacy_policy.html create mode 100644 web/templates/group_policy.html diff --git a/consent-plugin/pom.xml b/consent-plugin/pom.xml new file mode 100644 index 0000000..341b4d7 --- /dev/null +++ b/consent-plugin/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + de.simolzimol + mcconsent-paper + 1.0.0 + jar + + MCConsent-Paper + Privacy Policy consent enforcement for Minecraft servers + + + 17 + 17 + 17 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + + + io.papermc.paper + paper-api + 1.21-R0.1-SNAPSHOT + provided + + + + + com.zaxxer + HikariCP + 5.1.0 + + + + + org.mariadb.jdbc + mariadb-java-client + 3.4.1 + + + + + ${project.artifactId}-${project.version} + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + shade + + false + + + + com.zaxxer.hikari + de.simolzimol.mcconsent.lib.hikari + + + org.mariadb + de.simolzimol.mcconsent.lib.mariadb + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + META-INF/MANIFEST.MF + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + UTF-8 + + + + + diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java new file mode 100644 index 0000000..2b1344a --- /dev/null +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java @@ -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 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", "[Privacy] "); } + 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", "Thank you for accepting the Privacy Policy!"); } + public String getMsgDeclined() { return cfg.getString("messages.declined", "You declined the Privacy Policy."); } + 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", "Accept the Privacy Policy first! Type /{command} accept"); } + public String getMsgHoldChatBlocked() { return cfg.getString("messages.hold-chat-blocked", "You cannot chat until you accept the Privacy Policy."); } + public String getMsgHoldCmdBlocked() { return cfg.getString("messages.hold-command-blocked", "Commands disabled until you accept the Privacy Policy."); } + public String getMsgAlreadyAccepted() { return cfg.getString("messages.already-accepted", "You have already accepted the current Privacy Policy."); } + public String getMsgNoConsentNeeded() { return cfg.getString("messages.no-consent-required", "No consent is currently required."); } + public String getMsgGraceWarning() { return cfg.getString("messages.grace-warning", "You have {seconds}s left to accept or you will be disconnected."); } + public String getMsgAdminAccept() { return cfg.getString("messages.admin-notify-accept", "[Consent] {player} accepted (v{version})."); } + public String getMsgAdminDecline() { return cfg.getString("messages.admin-notify-decline", "[Consent] {player} declined (v{version})."); } + public String getMsgHelpHeader() { return cfg.getString("messages.help-header", "===== MCConsent Help ====="); } + public String getMsgRemind() { return cfg.getString("messages.remind-message", "Please accept our Privacy Policy. Type /{command} accept"); } + public String getMsgStatusAccepted() { return cfg.getString("messages.status-accepted", "You have accepted the Privacy Policy (v{version})."); } + public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "You have NOT yet accepted the Privacy Policy (v{version})."); } + public String getMsgNoPermission() { return cfg.getString("messages.no-permission", "You don't have permission to use this command."); } + public String getMsgAdminNotifyConsent() { return cfg.getString("messages.admin-notify-consent", "[Consent] {player} accepted the Privacy Policy (v{version})."); } + + /** 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 getHelpLines() { + List lines = cfg.getStringList("messages.help-lines"); + if (!lines.isEmpty()) return lines; + return List.of( + getMsgHelpHeader(), + "/consent accept Accept the Privacy Policy", + "/consent decline Decline and disconnect", + "/consent status Check your consent status", + "Policy: {policy_url}" + ); + } +} diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java new file mode 100644 index 0000000..2c2f584 --- /dev/null +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java @@ -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. + * + *

Key components: + *

    + *
  • {@link ConsentConfig} – Strongly-typed configuration wrapper
  • + *
  • {@link ConsentDatabase} – HikariCP-backed MariaDB/MySQL client
  • + *
  • {@link ConsentListener} – Enforces consent on login/join/move/chat
  • + *
  • {@link ConsentCommand} – /consent accept|decline|status|admin …
  • + *
+ * + * @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 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 getPendingConsent() { + return pendingConsent; + } +} diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java new file mode 100644 index 0000000..3ebcc81 --- /dev/null +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java @@ -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. + * + *

Player sub-commands: + *

    + *
  • {@code /consent accept} – record consent for the current policy version
  • + *
  • {@code /consent decline} – record decline and kick the player
  • + *
  • {@code /consent status} – show current consent status
  • + *
  • {@code /consent help} – show help text
  • + *
+ * + *

Admin sub-commands} (require {@code mclogger.consent.admin}): + *

    + *
  • {@code /consent admin reload} – reload plugin config
  • + *
  • {@code /consent admin list} – list online players who haven't consented
  • + *
  • {@code /consent admin revoke } – revoke all consents for a player
  • + *
  • {@code /consent admin forceaccept } – record consent on behalf of a player
  • + *
+ */ +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("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("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("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("Usage: /consent admin ")); + return; + } + + switch (args[1].toLowerCase(Locale.ROOT)) { + case "reload" -> { + plugin.reloadConsentConfig(); + sender.sendMessage(MM.deserialize("[MCConsent] Config reloaded.")); + } + + case "list" -> { + Set pending = plugin.getPendingConsent(); + if (pending.isEmpty()) { + sender.sendMessage(MM.deserialize("[MCConsent] No players are currently awaiting consent.")); + return; + } + sender.sendMessage(MM.deserialize("[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(" - " + name)); + } + } + + case "revoke" -> { + if (args.length < 3) { + sender.sendMessage(MM.deserialize("Usage: /consent admin revoke ")); + return; + } + String target = args[2]; + @SuppressWarnings("deprecation") + OfflinePlayer op = Bukkit.getOfflinePlayer(target); + if (!op.hasPlayedBefore() && !op.isOnline()) { + sender.sendMessage(MM.deserialize("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("[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("Usage: /consent admin forceaccept ")); + return; + } + String target = args[2]; + @SuppressWarnings("deprecation") + OfflinePlayer op = Bukkit.getOfflinePlayer(target); + if (!op.hasPlayedBefore() && !op.isOnline()) { + sender.sendMessage(MM.deserialize("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("[MCConsent] Consent recorded for " + op.getName() + + " (version: " + version + ").")); + } + + default -> sender.sendMessage(MM.deserialize( + "Unknown admin sub-command. Use: reload | list | revoke | forceaccept")); + } + } + + // ───────────────────────────────────────────────────────── + // Tab completion + // ───────────────────────────────────────────────────────── + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + List 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 names = new ArrayList<>(); + Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName())); + return filter(names, args[2]); + } + return Collections.emptyList(); + } + + // ───────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────── + + private static List filter(List options, String prefix) { + List 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))); + } +} diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java new file mode 100644 index 0000000..91c05b2 --- /dev/null +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java @@ -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. + * + *

Tables managed by this class: + *

    + *
  • {@code player_consent} – stores accepted consents per UUID + version
  • + *
  • {@code player_consent_declines} – audit trail of declined consents
  • + *
+ * + *

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 } 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 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 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 + } + } +} diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java new file mode 100644 index 0000000..c9c5ee8 --- /dev/null +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java @@ -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. + * + *

Three modes: + *

    + *
  • KICK – player is kicked at {@link PlayerLoginEvent} if no consent.
  • + *
  • HOLD – 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.
  • + *
  • REMIND- player joins normally; a repeating task sends reminder + * messages until they accept.
  • + *
+ * + * @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 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(); + } +} diff --git a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java new file mode 100644 index 0000000..3cf7a2a --- /dev/null +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java @@ -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. + * + *

Supported placeholders: + *

    + *
  • {@code {player}} – player's display name
  • + *
  • {@code {policy_url}} – configured policy URL
  • + *
  • {@code {version}} – policy version string
  • + *
+ */ + 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()); + } +} diff --git a/consent-plugin/src/main/resources/config.yml b/consent-plugin/src/main/resources/config.yml new file mode 100644 index 0000000..d029191 --- /dev/null +++ b/consent-plugin/src/main/resources/config.yml @@ -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/ + 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 + # /consent admin forceaccept + admin-permission: "consent.admin" + +# ── Messages ───────────────────────────────────────────────── +# All messages support MiniMessage formatting tags: +# , , , , , +# , , 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: "[Privacy] " + + # ── Join prompt ───────────────────────────────────────────── + # Sent to a player when they join and consent is required. + join-prompt: | + Privacy Policy Consent Required + Before you can play you must read and accept our Privacy Policy. + Click to open in browser
'>📄 {url} + ➜ Type /{command} accept to agree and start playing. + ➜ Type /{command} decline to disconnect. + + # ── Accepted ──────────────────────────────────────────────── + accepted: "✔ Thank you! You have accepted the Privacy Policy (v{version}). Enjoy your stay!" + + # ── Declined (shown just before the player is kicked) ─────── + declined: "You declined the Privacy Policy. You cannot play on this server without accepting it." + + # ── 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: "⛔ Please accept the Privacy Policy first! Type /{command} accept." + + # ── Chat blocked (HOLD mode) ──────────────────────────────── + hold-chat-blocked: "⛔ You cannot chat until you accept the Privacy Policy. Type /{command} accept." + + # ── Command blocked (HOLD mode) ───────────────────────────── + hold-command-blocked: "⛔ Commands are disabled until you accept the Privacy Policy. Type /{command} accept." + + # ── Already accepted ──────────────────────────────────────── + already-accepted: "✔ You have already accepted the current Privacy Policy (v{version})." + + # ── No consent currently required ─────────────────────────── + no-consent-required: "No consent is currently required on this server." + + # ── Grace period warning ───────────────────────────────────── + grace-warning: "⚠ You have {seconds}s left to accept the Privacy Policy or you will be disconnected." + + # ── Admin broadcast: player accepted ──────────────────────── + admin-notify-accept: "[Consent] {player} accepted the Privacy Policy (v{version})." + + # ── Admin broadcast: player declined ──────────────────────── + admin-notify-decline: "[Consent] {player} declined the Privacy Policy (v{version})." + + # ── Help header ───────────────────────────────────────────── + help-header: "===== MCConsent Help =====" + + # ── Remind mode reminder ───────────────────────────────────── + remind-message: | + ⚠ Reminder: Please accept our Privacy Policy. + {url} + Type /{command} accept to accept. diff --git a/consent-plugin/src/main/resources/plugin.yml b/consent-plugin/src/main/resources/plugin.yml new file mode 100644 index 0000000..f93116d --- /dev/null +++ b/consent-plugin/src/main/resources/plugin.yml @@ -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 + 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 diff --git a/web/app.py b/web/app.py index b8dc355..92d9412 100644 --- a/web/app.py +++ b/web/app.py @@ -148,6 +148,16 @@ def create_app() -> Flask: policy_version=Config.PRIVACY_POLICY_VERSION, ) + @app.route("/policy/") + def public_group_policy(group_id): + """Public, unauthenticated URL for a group's server privacy policy.""" + import panel_db as db + policy = db.get_group_policy(group_id) + group = db.get_group_by_id(group_id) + if not group: + return "Group not found", 404 + return render_template("group_policy.html", policy=policy, group=group) + @app.errorhandler(400) def bad_request(_): return "Bad request", 400 diff --git a/web/blueprints/group_admin.py b/web/blueprints/group_admin.py index bc2154f..0e5120f 100644 --- a/web/blueprints/group_admin.py +++ b/web/blueprints/group_admin.py @@ -542,3 +542,37 @@ def player_delete(uuid): return render_template("group_admin/player_delete_confirm.html", player=player, group=group) + + +# ─── Group Privacy Policy ───────────────────────────────────────────────────── + +@group_admin.route("/privacy-policy", methods=["GET", "POST"]) +@group_admin_required +def privacy_policy(): + """Group admins can write and publish their own server privacy policy.""" + from roles import OWNER_ONLY_ROLES as _OWNER_ONLY + if session.get("role") not in _OWNER_ONLY: + flash("Only the Group Owner can edit the privacy policy.", "danger") + return redirect(url_for("group_admin.dashboard")) + + group_id = session["group_id"] + policy = db.get_group_policy(group_id) + + if request.method == "POST": + policy_text = request.form.get("policy_text", "").strip() or None + policy_url = request.form.get("policy_url", "").strip() or None + db.set_group_policy(group_id, policy_text, policy_url) + db.log_audit_event( + session["user_id"], session["username"], "group.policy_updated", + entity_type="group", entity_id=str(group_id), + details={"policy_url": policy_url}, + group_id=group_id, ip_address=request.remote_addr, + ) + flash("Privacy policy saved.", "success") + return redirect(url_for("group_admin.privacy_policy")) + + group = db.get_group_by_id(group_id) + public_url = url_for("public_group_policy", group_id=group_id, _external=True) + return render_template("group_admin/privacy_policy.html", + policy=policy, group=group, public_url=public_url) + diff --git a/web/panel_db.py b/web/panel_db.py index 3cd1f8d..b42c6e6 100644 --- a/web/panel_db.py +++ b/web/panel_db.py @@ -175,6 +175,15 @@ PANEL_MIGRATIONS = [ (8, "ALTER TABLE users ADD COLUMN IF NOT EXISTS consented_at DATETIME NULL", "Add users.consented_at for GDPR consent timestamp"), + (9, + """CREATE TABLE IF NOT EXISTS group_privacy_policy ( + group_id INT PRIMARY KEY, + policy_text LONGTEXT, + policy_url VARCHAR(500), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", + "Add group_privacy_policy table for group-hosted privacy policies"), ] CREDS_SCHEMA = [ @@ -753,6 +762,32 @@ def set_user_consent(user_id: int, policy_version: str) -> None: ) +# ───────────────────────────────────────────────────────────── +# Group Privacy Policy +# ───────────────────────────────────────────────────────────── + +def get_group_policy(group_id: int): + """Returns the group_privacy_policy row for *group_id*, or None if not set.""" + rows = _panel_query( + "SELECT group_id, policy_text, policy_url, updated_at " + "FROM group_privacy_policy WHERE group_id = %s", + (group_id,), + ) + return rows[0] if rows else None + + +def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None: + """Upserts the privacy policy for a group.""" + _panel_query( + "INSERT INTO group_privacy_policy (group_id, policy_text, policy_url) " + "VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE policy_text = VALUES(policy_text), " + "policy_url = VALUES(policy_url), updated_at = UTC_TIMESTAMP()", + (group_id, policy_text, policy_url), + write=True, + ) + + # ───────────────────────────────────────────────────────────── # Audit-Log # ───────────────────────────────────────────────────────────── diff --git a/web/templates/group_admin/base.html b/web/templates/group_admin/base.html index e73af19..ac190b5 100644 --- a/web/templates/group_admin/base.html +++ b/web/templates/group_admin/base.html @@ -18,6 +18,7 @@ Dashboard Members Database + Privacy Policy Panel diff --git a/web/templates/group_admin/privacy_policy.html b/web/templates/group_admin/privacy_policy.html new file mode 100644 index 0000000..6dc09d1 --- /dev/null +++ b/web/templates/group_admin/privacy_policy.html @@ -0,0 +1,74 @@ +{% extends "group_admin/base.html" %} +{% block title %}Privacy Policy{% endblock %} + +{% block content %} +
+
+ +

Server Privacy Policy

+

+ Write your Minecraft server's privacy policy here. Players will be shown this page when + the MCConsent plugin asks them to consent before playing. +

+ + {# ── Public URL banner ────────────────────────────────────── #} +
+ +
+ Public URL — paste this into your consent-plugin/config.yml + as the policy-url value:
+ {{ public_url }} + +
+
+ + {# ── Last updated ─────────────────────────────────────────── #} + {% if policy and policy.updated_at %} +

Last updated: {{ policy.updated_at.strftime('%Y-%m-%d %H:%M UTC') }}

+ {% endif %} + + {# ── Editor form ──────────────────────────────────────────── #} +
+ + +
+ + +
+ If you host a full policy on your own website you can link it here. It will be + displayed as a button on the public policy page. +
+
+ +
+ + +
+ Plain text or basic Markdown is accepted. HTML is not rendered. +
+
+ +
+ + {% if policy %} + + Preview public page + + {% endif %} +
+
+ +
+
+{% endblock %} diff --git a/web/templates/group_policy.html b/web/templates/group_policy.html new file mode 100644 index 0000000..c68e2e7 --- /dev/null +++ b/web/templates/group_policy.html @@ -0,0 +1,64 @@ + + + + + + Privacy Policy — {{ group.name }} + + + + + + + +
+ + {% if not policy or not policy.policy_text %} +
+ + This server has not yet published a privacy policy. + Please contact the server administrator for more information. +
+ {% else %} + + {% if policy.updated_at %} +

+ + Last updated: {{ policy.updated_at.strftime('%B %d, %Y') }} +

+ {% endif %} + + {% if policy.policy_url %} + + View full policy on our website + + {% endif %} + +
+
+
{{ policy.policy_text }}
+
+
+ + {% endif %} + +
+

+ This page is hosted by MCLogger + on behalf of {{ group.name }}. +

+ +
+ + + +