Compare commits

...

6 Commits

Author SHA1 Message Date
simon
8df8230111 modified: consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java 2026-04-20 11:50:52 +02:00
simon
408933106b modified: consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	modified:   consent-plugin/src/main/resources/config.yml
2026-04-20 11:50:36 +02:00
SimolZimol
c2b645ed58 modified: consent-plugin/pom.xml
modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
	modified:   consent-plugin/src/main/resources/config.yml
	modified:   consent-plugin/src/main/resources/plugin.yml
2026-04-17 14:18:51 +02:00
SimolZimol
aa8dfdcdbf modified: web/templates/admin/base.html
modified:   web/templates/base.html
	modified:   web/templates/group_admin/base.html
	modified:   web/templates/group_policy.html
	modified:   web/templates/privacy_policy.html
2026-04-17 13:51:28 +02:00
simon
83af428435 modified: web/panel_db.py 2026-04-17 12:07:38 +02:00
simon
cd9a46b403 modified: web/app.py
modified:   web/blueprints/group_admin.py
	modified:   web/panel_db.py
	modified:   web/templates/group_admin/privacy_policy.html
2026-04-17 12:04:00 +02:00
18 changed files with 404 additions and 46 deletions

View File

@@ -27,11 +27,11 @@
</repositories> </repositories>
<dependencies> <dependencies>
<!-- Paper API 1.21 --> <!-- Paper API 1.17+ (lowest supported; ensures no 1.18+-only APIs are used) -->
<dependency> <dependency>
<groupId>io.papermc.paper</groupId> <groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId> <artifactId>paper-api</artifactId>
<version>1.21-R0.1-SNAPSHOT</version> <version>1.17.1-R0.1-SNAPSHOT</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>

View File

@@ -143,12 +143,29 @@ public class ConsentConfig {
public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "<yellow>You have NOT yet accepted the Privacy Policy (v{version}).</yellow>"); } 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 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>"); } public String getMsgAdminNotifyConsent() { return cfg.getString("messages.admin-notify-consent", "<dark_gray>[Consent] {player} accepted the Privacy Policy (v{version}).</dark_gray>"); }
public String getMsgWithdrawn() { return cfg.getString("messages.withdrawn", "<yellow>Your consent has been withdrawn. You will be disconnected.</yellow>"); }
public String getMsgWithdrawKick() { return cfg.getString("messages.withdraw-kick-message", "Consent withdrawn.\nYou may reconnect at any time and accept the Privacy Policy.\nVisit: {policy_url}"); }
/** Whether a message should be sent to online admins when a player accepts. */ /** Whether a message should be sent to online admins when a player accepts. */
public boolean isNotifyAdminsOnConsent() { public boolean isNotifyAdminsOnConsent() {
return cfg.getBoolean("admin.notify-admins-on-consent", false); return cfg.getBoolean("admin.notify-admins-on-consent", false);
} }
/**
* How many days to keep accepted consent records (0 = keep forever).
* GDPR Art. 5(1)(e) storage limitation.
*/
public int getConsentRetentionDays() {
return cfg.getInt("consent.retention-days-consent", 1095); // 3 years default
}
/**
* How many days to keep decline records (0 = keep forever).
*/
public int getDeclineRetentionDays() {
return cfg.getInt("consent.retention-days-declines", 1095);
}
/** /**
* Returns the help text as a list of MiniMessage lines. * Returns the help text as a list of MiniMessage lines.
* Falls back to a built-in list if not configured. * Falls back to a built-in list if not configured.
@@ -164,4 +181,13 @@ public class ConsentConfig {
"<gray>Policy: <aqua>{policy_url}</aqua>" "<gray>Policy: <aqua>{policy_url}</aqua>"
); );
} }
/**
* When {@code true}, kick messages are forwarded to the Velocity / BungeeCord
* proxy via the {@code BungeeCord} plugin-messaging channel so the player is
* removed from the entire network rather than just the current server.
*/
public boolean isVelocityKick() {
return cfg.getBoolean("consent.velocity-kick", false);
}
} }

View File

@@ -5,11 +5,17 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import de.simolzimol.mclogger.consent.commands.ConsentCommand; import de.simolzimol.mclogger.consent.commands.ConsentCommand;
import de.simolzimol.mclogger.consent.database.ConsentDatabase; import de.simolzimol.mclogger.consent.database.ConsentDatabase;
import de.simolzimol.mclogger.consent.listeners.ConsentListener; import de.simolzimol.mclogger.consent.listeners.ConsentListener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
/** /**
* MCConsent Privacy Policy consent enforcement for Paper servers. * MCConsent Privacy Policy consent enforcement for Paper servers.
@@ -59,6 +65,9 @@ public class ConsentPlugin extends JavaPlugin {
return; return;
} }
// Register plugin-messaging channel for Velocity / BungeeCord network kick
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
// Register event listeners // Register event listeners
ConsentListener listener = new ConsentListener(this); ConsentListener listener = new ConsentListener(this);
getServer().getPluginManager().registerEvents(listener, this); getServer().getPluginManager().registerEvents(listener, this);
@@ -75,6 +84,18 @@ public class ConsentPlugin extends JavaPlugin {
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode() getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
+ " PolicyVersion=" + consentConfig.getPolicyVersion()); + " PolicyVersion=" + consentConfig.getPolicyVersion());
// GDPR Art. 5(1)(e) automatic retention purge, runs once every 24 h
int consentDays = consentConfig.getConsentRetentionDays();
int declineDays = consentConfig.getDeclineRetentionDays();
if (consentDays > 0 || declineDays > 0) {
long dayTicks = 20L * 60 * 60 * 24;
getServer().getScheduler().runTaskTimerAsynchronously(this,
() -> consentDatabase.purgeOldConsents(consentDays, declineDays),
dayTicks, dayTicks);
getLogger().info("[MCConsent] GDPR retention purge scheduled (consent=" + consentDays
+ "d, declines=" + declineDays + "d).");
}
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
@@ -107,4 +128,31 @@ public class ConsentPlugin extends JavaPlugin {
public Set<UUID> getPendingConsent() { public Set<UUID> getPendingConsent() {
return pendingConsent; return pendingConsent;
} }
/**
* Kicks a player from the current server, or from the entire
* Velocity / BungeeCord network when {@code velocity-kick: true} is set
* in the config. Falls back to a normal kick if the proxy message fails.
*/
public void networkKick(Player player, Component reason) {
if (consentConfig.isVelocityKick()) {
try {
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF("KickPlayer");
out.writeUTF(player.getName());
out.writeUTF(LegacyComponentSerializer.legacySection().serialize(reason));
player.sendPluginMessage(this, "BungeeCord", out.toByteArray());
} catch (Exception e) {
getLogger().warning("[MCConsent] Velocity/BungeeCord network kick failed: " + e.getMessage());
player.kick(reason);
return;
}
// Fallback: if the proxy never kicks, remove the player after 1 s
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) player.kick(reason);
}, 20L);
} else {
player.kick(reason);
}
}
} }

View File

@@ -18,6 +18,7 @@ import java.util.*;
* <ul> * <ul>
* <li>{@code /consent accept} record consent for the current policy version</li> * <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 decline} record decline and kick the player</li>
* <li>{@code /consent withdraw} withdraw previously given consent (Art. 7(3) GDPR)</li>
* <li>{@code /consent status} show current consent status</li> * <li>{@code /consent status} show current consent status</li>
* <li>{@code /consent help} show help text</li> * <li>{@code /consent help} show help text</li>
* </ul> * </ul>
@@ -54,10 +55,11 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
} }
switch (args[0].toLowerCase(Locale.ROOT)) { switch (args[0].toLowerCase(Locale.ROOT)) {
case "accept" -> handleAccept(sender, cfg); case "accept" -> handleAccept(sender, cfg);
case "decline" -> handleDecline(sender, cfg); case "decline" -> handleDecline(sender, cfg);
case "status" -> handleStatus(sender, cfg); case "withdraw" -> handleWithdraw(sender, cfg);
case "help" -> sendHelp(sender, cfg); case "status" -> handleStatus(sender, cfg);
case "help" -> sendHelp(sender, cfg);
case "admin" -> { case "admin" -> {
if (!sender.hasPermission("mclogger.consent.admin")) { if (!sender.hasPermission("mclogger.consent.admin")) {
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission())); sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
@@ -113,7 +115,32 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
plugin.getPendingConsent().remove(player.getUniqueId()); plugin.getPendingConsent().remove(player.getUniqueId());
String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg); String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg);
player.kick(MM.deserialize(msg)); plugin.networkKick(player, MM.deserialize(msg));
}
private void handleWithdraw(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();
// Revoke all versions so the player must re-accept next time
plugin.getConsentDatabase().revokeConsent(uuid, null);
plugin.getPendingConsent().remove(player.getUniqueId());
// Record the withdrawal in the decline table as an audit trail
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version + "_WITHDRAWN",
player.getAddress() != null ? player.getAddress().getAddress().getHostAddress() : "unknown");
String withdrawMsg = MessageUtil.replace(cfg.getMsgWithdrawn(), player, cfg);
player.sendMessage(MM.deserialize(withdrawMsg));
// Kick using the configured kick message
String kickMsg = MessageUtil.replace(cfg.getMsgWithdrawKick(), player, cfg);
plugin.networkKick(player, MM.deserialize(kickMsg));
} }
private void handleStatus(CommandSender sender, ConsentConfig cfg) { private void handleStatus(CommandSender sender, ConsentConfig cfg) {
@@ -225,7 +252,7 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
@Override @Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) { public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) { if (args.length == 1) {
List<String> base = new ArrayList<>(List.of("accept", "decline", "status", "help")); List<String> base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help"));
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin"); if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
return filter(base, args[0]); return filter(base, args[0]);
} }

View File

@@ -1,5 +1,9 @@
package de.simolzimol.mclogger.consent.database; package de.simolzimol.mclogger.consent.database;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
@@ -92,12 +96,15 @@ public class ConsentDatabase {
try (Connection conn = dataSource.getConnection(); try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) { Statement stmt = conn.createStatement()) {
// Accepted consents - primary record of who agreed to what version // Accepted consents primary record of who agreed to what version.
// ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
// policy_hash is the SHA-256 of the policy_version string for accountability.
stmt.execute( stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent (" + "CREATE TABLE IF NOT EXISTS player_consent (" +
" uuid VARCHAR(36) NOT NULL," + " uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," + " username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," + " policy_version VARCHAR(64) NOT NULL," +
" policy_hash VARCHAR(64) NULL," +
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + " consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," + " ip_address VARCHAR(45) NULL," +
" PRIMARY KEY (uuid, policy_version)," + " PRIMARY KEY (uuid, policy_version)," +
@@ -105,7 +112,12 @@ public class ConsentDatabase {
" INDEX idx_consent_version (policy_version)" + " INDEX idx_consent_version (policy_version)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Decline audit trail - never deleted (important for GDPR accountability) // Migrate existing tables: add policy_hash if not present
stmt.execute(
"ALTER TABLE player_consent " +
"ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");
// Decline audit trail
stmt.execute( stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent_declines (" + "CREATE TABLE IF NOT EXISTS player_consent_declines (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," + " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
@@ -148,17 +160,23 @@ public class ConsentDatabase {
/** /**
* Records that the player has accepted the current policy version. * Records that the player has accepted the current policy version.
* Uses {@code INSERT IGNORE} so duplicate calls are safe. * Uses {@code INSERT IGNORE} so duplicate calls are safe.
*
* <p>The IP address is pseudonymized before storage (Art. 5(1)(c) GDPR):
* last octet zeroed for IPv4, last 80 bits zeroed for IPv6.
* A SHA-256 hash of the policy version string is stored for accountability
* (Art. 5(2) GDPR).
*/ */
public void recordConsent(String uuid, String username, String policyVersion, String ip) { public void recordConsent(String uuid, String username, String policyVersion, String ip) {
final String sql = final String sql =
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" + "INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
" VALUES (?, ?, ?, ?)"; " VALUES (?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection(); try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) { PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid); ps.setString(1, uuid);
ps.setString(2, username); ps.setString(2, username);
ps.setString(3, policyVersion); ps.setString(3, policyVersion);
ps.setString(4, ip); ps.setString(4, computePolicyHash(policyVersion));
ps.setString(5, pseudonymizeIp(ip));
ps.executeUpdate(); ps.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e); plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
@@ -167,7 +185,7 @@ public class ConsentDatabase {
/** /**
* Records that the player declined the current policy version. * Records that the player declined the current policy version.
* Declines are appended to an audit table and are never deleted. * Declines are appended to an audit table; their IP is pseudonymized.
*/ */
public void recordDecline(String uuid, String username, String policyVersion, String ip) { public void recordDecline(String uuid, String username, String policyVersion, String ip) {
final String sql = final String sql =
@@ -178,7 +196,7 @@ public class ConsentDatabase {
ps.setString(1, uuid); ps.setString(1, uuid);
ps.setString(2, username); ps.setString(2, username);
ps.setString(3, policyVersion); ps.setString(3, policyVersion);
ps.setString(4, ip); ps.setString(4, pseudonymizeIp(ip));
ps.executeUpdate(); ps.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e); plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
@@ -243,6 +261,100 @@ public class ConsentDatabase {
* @param uuid Player UUID string * @param uuid Player UUID string
* @param version Policy version string * @param version Policy version string
*/ */
// ─────────────────────────────────────────────────────────
// GDPR Art. 5(1)(e) Storage limitation
// ─────────────────────────────────────────────────────────
/**
* Deletes consent and decline records older than the configured retention
* periods. Pass 0 to skip deletion for that table.
*
* <p>Intended to be called from an async scheduled task once per day.
*/
public void purgeOldConsents(int consentRetentionDays, int declineRetentionDays) {
if (consentRetentionDays > 0) {
final String sql =
"DELETE FROM player_consent " +
"WHERE consented_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, consentRetentionDays);
int rows = ps.executeUpdate();
if (rows > 0) {
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
+ " consent record(s) older than " + consentRetentionDays + " days.");
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() consent failed", e);
}
}
if (declineRetentionDays > 0) {
final String sql =
"DELETE FROM player_consent_declines " +
"WHERE declined_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, declineRetentionDays);
int rows = ps.executeUpdate();
if (rows > 0) {
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
+ " decline record(s) older than " + declineRetentionDays + " days.");
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() declines failed", e);
}
}
}
// ─────────────────────────────────────────────────────────
// Privacy helpers
// ─────────────────────────────────────────────────────────
/**
* Pseudonymizes an IP address for storage (Art. 5(1)(c) GDPR data minimisation).
* <ul>
* <li>IPv4: last octet set to 0 (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})</li>
* <li>IPv6: last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)</li>
* </ul>
* Returns {@code "unknown"} if the address cannot be parsed.
*/
static String pseudonymizeIp(String ip) {
if (ip == null || ip.isBlank() || ip.equals("unknown") || ip.equals("admin-forced")) {
return ip;
}
try {
InetAddress addr = InetAddress.getByName(ip);
byte[] bytes = addr.getAddress();
if (bytes.length == 4) {
// IPv4 zero last octet
bytes[3] = 0;
} else if (bytes.length == 16) {
// IPv6 zero last 10 bytes (80 bits)
for (int i = 6; i < 16; i++) bytes[i] = 0;
}
return InetAddress.getByAddress(bytes).getHostAddress();
} catch (Exception e) {
return "unknown";
}
}
/**
* Computes a SHA-256 hex digest of the policy version string.
* Stored alongside each consent record so the exact version accepted can
* be verified later (Art. 5(2) GDPR accountability).
*/
static String computePolicyHash(String policyVersion) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(policyVersion.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (NoSuchAlgorithmException e) {
return "unavailable";
}
}
public void logToMCLogger(String eventType, String playerName, String uuid, String version) { public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return; if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;

View File

@@ -1,20 +1,29 @@
package de.simolzimol.mclogger.consent.listeners; package de.simolzimol.mclogger.consent.listeners;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerLoginEvent; import org.bukkit.event.player.PlayerLoginEvent;
import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
import de.simolzimol.mclogger.consent.ConsentConfig; import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode; import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
@@ -93,9 +102,12 @@ public class ConsentListener implements Listener {
EnforcementMode mode = cfg.getEnforcementMode(); EnforcementMode mode = cfg.getEnforcementMode();
// Send the join prompt // Open the policy book 1 tick after join (openBook can't be called during join)
String prompt = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg); String promptMini = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
player.sendMessage(MiniMessage.miniMessage().deserialize(prompt)); Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
openPolicyBook(player, promptMini);
}, 1L);
if (mode == EnforcementMode.HOLD) { if (mode == EnforcementMode.HOLD) {
plugin.getPendingConsent().add(player.getUniqueId()); plugin.getPendingConsent().add(player.getUniqueId());
@@ -177,6 +189,28 @@ public class ConsentListener implements Listener {
event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg)); event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg));
} }
// ─────────────────────────────────────────────────────────
// HOLD-mode: block block-break / place / interact
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
if (!isHoldPending(event.getPlayer())) return;
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
if (!isHoldPending(event.getPlayer())) return;
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onInteract(PlayerInteractEvent event) {
if (!isHoldPending(event.getPlayer())) return;
event.setCancelled(true);
}
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// Internal helpers // Internal helpers
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
@@ -193,6 +227,40 @@ public class ConsentListener implements Listener {
return cfg.getExemptWorlds().contains(worldName); return cfg.getExemptWorlds().contains(worldName);
} }
/**
* Opens a written-book GUI with the policy prompt so the player can read
* the full text including a clickable URL, without cluttering chat.
* A short plain-chat hint is also sent so players know why the book opened.
*/
private void openPolicyBook(Player player, String promptMini) {
MiniMessage mm = MiniMessage.miniMessage();
ItemStack book = new ItemStack(Material.WRITTEN_BOOK);
BookMeta meta = (BookMeta) Objects.requireNonNull(book.getItemMeta());
meta.title(mm.deserialize("<bold><blue>Privacy Policy</blue></bold>"));
meta.author(Component.text("MCLogger"));
// First page: the configured join-prompt text (supports MiniMessage incl. click URLs)
Component page1 = mm.deserialize(promptMini);
meta.pages(List.of(page1));
book.setItemMeta(meta);
player.openBook(book);
// Also send a brief chat message so the book title isn't the only indicator
String hint = MessageUtil.replace(
plugin.getConsentConfig().getMsgJoinPrompt(), player, plugin.getConsentConfig());
player.sendMessage(mm.deserialize(hint));
}
/**
* Kicks a player from the server or if velocity-kick is enabled from
* the entire network. Delegates to {@link ConsentPlugin#networkKick}.
*/
void doKick(Player player, Component reason) {
plugin.networkKick(player, reason);
}
/** /**
* Sends a MiniMessage-formatted message to the player with a cooldown to * Sends a MiniMessage-formatted message to the player with a cooldown to
* avoid flooding the chat (e.g. when the player spams movement). * avoid flooding the chat (e.g. when the player spams movement).
@@ -241,7 +309,7 @@ public class ConsentListener implements Listener {
plugin.getPendingConsent().remove(uuid); plugin.getPendingConsent().remove(uuid);
String kickMsg = MessageUtil.replace( String kickMsg = MessageUtil.replace(
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig()); plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
p.kick(MiniMessage.miniMessage().deserialize(kickMsg)); doKick(p, MiniMessage.miniMessage().deserialize(kickMsg));
}, (long) graceSec * 20); }, (long) graceSec * 20);
} }

View File

@@ -16,7 +16,9 @@ public final class MessageUtil {
* <p>Supported placeholders: * <p>Supported placeholders:
* <ul> * <ul>
* <li>{@code {player}} player's display name</li> * <li>{@code {player}} player's display name</li>
* <li>{@code {policy_url}} configured policy URL</li> * <li>{@code {url}} configured policy URL</li>
* <li>{@code {policy_url}} configured policy URL (alias for {url})</li>
* <li>{@code {command}} consent command name (e.g. "consent")</li>
* <li>{@code {version}} policy version string</li> * <li>{@code {version}} policy version string</li>
* </ul> * </ul>
*/ */
@@ -24,7 +26,9 @@ public final class MessageUtil {
if (template == null) return ""; if (template == null) return "";
return template return template
.replace("{player}", player.getName()) .replace("{player}", player.getName())
.replace("{url}", cfg.getPolicyUrl())
.replace("{policy_url}", cfg.getPolicyUrl()) .replace("{policy_url}", cfg.getPolicyUrl())
.replace("{command}", cfg.getCommand())
.replace("{version}", cfg.getPolicyVersion()); .replace("{version}", cfg.getPolicyVersion());
} }
} }

View File

@@ -1,7 +1,7 @@
# ============================================================ # ============================================================
# MCConsent Paper Plugin Configuration # MCConsent Paper Plugin Configuration
# Author: SimolZimol # Author: SimolZimol
# Docs: https://github.com/SimolZimol/MCLogger # Website: https://log.devanturas.net
# ============================================================ # ============================================================
# ── Global switch ──────────────────────────────────────────── # ── Global switch ────────────────────────────────────────────
@@ -55,6 +55,14 @@ consent:
# in plugin.yml with aliases /privacy and /pp). # in plugin.yml with aliases /privacy and /pp).
command: "consent" command: "consent"
# ── Velocity / BungeeCord network kick ──────────────────────
# Set to true if this server runs behind a Velocity or BungeeCord
# proxy. When enabled, kick messages are forwarded via the
# BungeeCord plugin-messaging channel so the player is removed
# from the entire network instead of just this server.
# Requires the proxy to have the plugin-messaging channel enabled.
velocity-kick: false
# ── Grace period (HOLD mode only) ─────────────────────────── # ── Grace period (HOLD mode only) ───────────────────────────
# How many seconds a player has to accept before being kicked. # 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). # Set to 0 to wait forever (player can take as long as they like).
@@ -81,7 +89,14 @@ consent:
# Players with this permission bypass all consent checks. # Players with this permission bypass all consent checks.
# Default: consent.bypass (assigned to ops by default) # Default: consent.bypass (assigned to ops by default)
bypass-permission: "consent.bypass" bypass-permission: "consent.bypass"
# ── GDPR Aufbewahrungsfristen (Art. 5(1)(e)) ─────────────────
# Number of days to keep accepted consent records.
# After this period data is automatically deleted by the plugin.
# Set to 0 to keep records indefinitely (not recommended for GDPR).
retention-days-consent: 1095 # 3 years
# Number of days to keep decline records.
retention-days-declines: 1095 # 3 years
# ── Admin settings ─────────────────────────────────────────── # ── Admin settings ───────────────────────────────────────────
admin: admin:
# Log consent accept/decline events to the server_events table in # Log consent accept/decline events to the server_events table in
@@ -117,9 +132,13 @@ messages:
# ── Join prompt ───────────────────────────────────────────── # ── Join prompt ─────────────────────────────────────────────
# Sent to a player when they join and consent is required. # Sent to a player when they join and consent is required.
# This text must inform the player what data is being collected
# and for what purpose (Art. 13 GDPR).
join-prompt: | join-prompt: |
<yellow><bold>Privacy Policy Consent Required</bold></yellow> <yellow><bold>Privacy Policy Consent Required</bold></yellow>
<gray>Before you can play you must read and accept our Privacy Policy.</gray> <gray>Before you can play you must read and accept our Privacy Policy.</gray>
<dark_gray>We collect: chat messages, commands, login/logout times, IP address
(pseudonymized), and player position for server moderation purposes.</dark_gray>
<aqua><click:open_url:'{url}'><hover:show_text:'<gray>Click to open in browser</gray>'>📄 {url}</hover></click></aqua> <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> <green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red> <red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
@@ -189,3 +208,15 @@ messages:
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow> <yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
<aqua><click:open_url:'{url}'>{url}</click></aqua> <aqua><click:open_url:'{url}'>{url}</click></aqua>
<green>Type /<bold>{command} accept</bold> to accept.</green> <green>Type /<bold>{command} accept</bold> to accept.</green>
# ── Consent withdrawn (Art. 7(3) GDPR) ───────────────────────
# Shown in chat just before the player is kicked after /consent withdraw.
withdrawn: "<yellow>Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time.</yellow>"
# Disconnect screen message shown after /consent withdraw.
withdraw-kick-message: |
Consent Withdrawn
You have successfully withdrawn your consent.
Your data will be handled according to the Privacy Policy.
You may reconnect at any time and accept the Privacy Policy.
Visit: {url}

View File

@@ -1,7 +1,7 @@
name: MCConsent name: MCConsent
version: '1.0.0' version: '1.0.0'
main: de.simolzimol.mclogger.consent.ConsentPlugin main: de.simolzimol.mclogger.consent.ConsentPlugin
api-version: '1.20' api-version: '1.17'
description: Privacy Policy consent enforcement for Minecraft servers description: Privacy Policy consent enforcement for Minecraft servers
author: SimolZimol author: SimolZimol

View File

@@ -148,14 +148,19 @@ def create_app() -> Flask:
policy_version=Config.PRIVACY_POLICY_VERSION, policy_version=Config.PRIVACY_POLICY_VERSION,
) )
@app.route("/policy/<int:group_id>") @app.route("/policy/<token>")
def public_group_policy(group_id): def public_group_policy(token):
"""Public, unauthenticated URL for a group's server privacy policy.""" """Public, unauthenticated URL for a group's server privacy policy.
The token is an opaque UUID so the group ID is never exposed.
"""
import panel_db as db import panel_db as db
policy = db.get_group_policy(group_id) row = db.get_group_policy_by_token(token)
group = db.get_group_by_id(group_id) if not row:
if not group: return render_template("404.html"), 404
return "Group not found", 404 # Build lightweight dicts that the template expects
policy = row
group = {"name": row["group_name"]}
return render_template("group_policy.html", policy=policy, group=group) return render_template("group_policy.html", policy=policy, group=group)
@app.errorhandler(400) @app.errorhandler(400)

View File

@@ -572,7 +572,8 @@ def privacy_policy():
return redirect(url_for("group_admin.privacy_policy")) return redirect(url_for("group_admin.privacy_policy"))
group = db.get_group_by_id(group_id) group = db.get_group_by_id(group_id)
public_url = url_for("public_group_policy", group_id=group_id, _external=True) token = policy["public_token"] if policy else None
public_url = url_for("public_group_policy", token=token, _external=True) if token else None
return render_template("group_admin/privacy_policy.html", return render_template("group_admin/privacy_policy.html",
policy=policy, group=group, public_url=public_url) policy=policy, group=group, public_url=public_url)

View File

@@ -177,13 +177,22 @@ PANEL_MIGRATIONS = [
"Add users.consented_at for GDPR consent timestamp"), "Add users.consented_at for GDPR consent timestamp"),
(9, (9,
"""CREATE TABLE IF NOT EXISTS group_privacy_policy ( """CREATE TABLE IF NOT EXISTS group_privacy_policy (
group_id INT PRIMARY KEY, group_id INT PRIMARY KEY,
policy_text LONGTEXT, policy_text LONGTEXT,
policy_url VARCHAR(500), policy_url VARCHAR(500),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, public_token CHAR(36) NULL UNIQUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"Add group_privacy_policy table for group-hosted privacy policies"), "Add group_privacy_policy table for group-hosted privacy policies"),
(10,
"ALTER TABLE group_privacy_policy ADD COLUMN IF NOT EXISTS "
"public_token CHAR(36) NULL",
"Add group_privacy_policy.public_token for opaque public URL"),
(11,
"ALTER TABLE group_privacy_policy ADD CONSTRAINT IF NOT EXISTS "
"uq_gpp_public_token UNIQUE (public_token)",
"Unique index on group_privacy_policy.public_token"),
] ]
CREDS_SCHEMA = [ CREDS_SCHEMA = [
@@ -769,20 +778,40 @@ def set_user_consent(user_id: int, policy_version: str) -> None:
def get_group_policy(group_id: int): def get_group_policy(group_id: int):
"""Returns the group_privacy_policy row for *group_id*, or None if not set.""" """Returns the group_privacy_policy row for *group_id*, or None if not set."""
rows = _panel_query( rows = _panel_query(
"SELECT group_id, policy_text, policy_url, updated_at " "SELECT group_id, policy_text, policy_url, public_token, updated_at "
"FROM group_privacy_policy WHERE group_id = %s", "FROM group_privacy_policy WHERE group_id = %s",
(group_id,), (group_id,),
) )
return rows[0] if rows else None return rows[0] if rows else None
def get_group_policy_by_token(token: str):
"""Returns the policy row and group for the given opaque public_token, or None."""
rows = _panel_query(
"SELECT p.group_id, p.policy_text, p.policy_url, p.public_token, p.updated_at, "
"g.name AS group_name "
"FROM group_privacy_policy p "
"JOIN user_groups g ON g.id = p.group_id "
"WHERE p.public_token = %s",
(token,),
)
return rows[0] if rows else None
def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None: def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None:
"""Upserts the privacy policy for a group.""" """Upserts the privacy policy for a group.
The *public_token* (opaque UUID) is generated once on first INSERT and
never changed on subsequent updates, so existing URLs stay valid.
"""
_panel_query( _panel_query(
"INSERT INTO group_privacy_policy (group_id, policy_text, policy_url) " "INSERT INTO group_privacy_policy (group_id, policy_text, policy_url, public_token) "
"VALUES (%s, %s, %s) " "VALUES (%s, %s, %s, UUID()) "
"ON DUPLICATE KEY UPDATE policy_text = VALUES(policy_text), " "ON DUPLICATE KEY UPDATE "
"policy_url = VALUES(policy_url), updated_at = UTC_TIMESTAMP()", "policy_text = VALUES(policy_text), "
"policy_url = VALUES(policy_url), "
"public_token = COALESCE(public_token, VALUES(public_token)), "
"updated_at = UTC_TIMESTAMP()",
(group_id, policy_text, policy_url), (group_id, policy_text, policy_url),
write=True, write=True,
) )

View File

@@ -44,7 +44,7 @@
<footer class="text-center py-3 mt-4 border-top border-secondary"> <footer class="text-center py-3 mt-4 border-top border-secondary">
<small class="text-muted"> <small class="text-muted">
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a> <a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
&mdash; MCLogger &mdash; MCLogger &mdash; Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
</small> </small>
</footer> </footer>
</div> </div>

View File

@@ -174,7 +174,7 @@
<footer class="text-center py-2 border-top border-secondary"> <footer class="text-center py-2 border-top border-secondary">
<small class="text-muted"> <small class="text-muted">
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a> <a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
&mdash; MCLogger &mdash; MCLogger &mdash; Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
</small> </small>
</footer> </footer>
</div> </div>

View File

@@ -46,7 +46,7 @@
<footer class="text-center py-3 mt-4 border-top border-secondary"> <footer class="text-center py-3 mt-4 border-top border-secondary">
<small class="text-muted"> <small class="text-muted">
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a> <a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
&mdash; MCLogger &mdash; MCLogger &mdash; Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
</small> </small>
</footer> </footer>
</div> </div>

View File

@@ -12,6 +12,7 @@
</p> </p>
{# ── Public URL banner ────────────────────────────────────── #} {# ── Public URL banner ────────────────────────────────────── #}
{% if public_url %}
<div class="alert alert-info d-flex align-items-center gap-2 mb-4"> <div class="alert alert-info d-flex align-items-center gap-2 mb-4">
<i class="bi bi-link-45deg fs-5 flex-shrink-0"></i> <i class="bi bi-link-45deg fs-5 flex-shrink-0"></i>
<div> <div>
@@ -23,6 +24,12 @@
</button> </button>
</div> </div>
</div> </div>
{% else %}
<div class="alert alert-secondary mb-4">
<i class="bi bi-info-circle me-2"></i>
Save the policy once to generate a secret public URL.
</div>
{% endif %}
{# ── Last updated ─────────────────────────────────────────── #} {# ── Last updated ─────────────────────────────────────────── #}
{% if policy and policy.updated_at %} {% if policy and policy.updated_at %}

View File

@@ -15,7 +15,7 @@
<i class="bi bi-file-earmark-lock2 me-2 text-warning"></i> <i class="bi bi-file-earmark-lock2 me-2 text-warning"></i>
<strong>{{ group.name }}</strong> — Privacy Policy <strong>{{ group.name }}</strong> — Privacy Policy
</span> </span>
<span class="text-muted small">Powered by MCLogger</span> <span class="text-muted small">Powered by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">MCLogger</a> &mdash; Made by Devanturas</span>
</div> </div>
</nav> </nav>
@@ -53,7 +53,7 @@
<hr class="border-secondary mt-5"> <hr class="border-secondary mt-5">
<p class="text-muted small text-center mb-4"> <p class="text-muted small text-center mb-4">
This page is hosted by <a href="https://github.com/simolzimol/MCLogger" class="text-secondary">MCLogger</a> This page is hosted by <a href="https://log.devanturas.net" class="text-secondary" target="_blank" rel="noopener noreferrer">MCLogger</a>
on behalf of <em>{{ group.name }}</em>. on behalf of <em>{{ group.name }}</em>.
</p> </p>

View File

@@ -238,7 +238,7 @@
<a href="{{ url_for('auth.login') }}" class="text-muted me-3"> <a href="{{ url_for('auth.login') }}" class="text-muted me-3">
<i class="bi bi-arrow-left me-1"></i>Back to Login <i class="bi bi-arrow-left me-1"></i>Back to Login
</a> </a>
MCLogger &mdash; <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a> MCLogger &mdash; Made by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">Devanturas</a> &mdash; <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>