From 408933106b2188488dc74ab8e6f0905ca0450848 Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 20 Apr 2026 11:50:36 +0200 Subject: [PATCH] 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 --- .../mclogger/consent/ConsentConfig.java | 17 +++ .../mclogger/consent/ConsentPlugin.java | 12 ++ .../consent/commands/ConsentCommand.java | 37 ++++- .../consent/database/ConsentDatabase.java | 126 +++++++++++++++++- consent-plugin/src/main/resources/config.yml | 23 ++++ 5 files changed, 203 insertions(+), 12 deletions(-) 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 index c23dc11..d0115c0 100644 --- a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java @@ -143,12 +143,29 @@ public class ConsentConfig { 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})."); } + public String getMsgWithdrawn() { return cfg.getString("messages.withdrawn", "Your consent has been withdrawn. You will be disconnected."); } + 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. */ public boolean isNotifyAdminsOnConsent() { 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. * Falls back to a built-in list if not configured. 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 index 25734a2..0e72117 100644 --- a/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java +++ b/consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java @@ -83,6 +83,18 @@ public class ConsentPlugin extends JavaPlugin { getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode() + " 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)."); + } } // ───────────────────────────────────────────────────────── 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 index a903b62..30734bc 100644 --- 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 @@ -18,6 +18,7 @@ import java.util.*; * @@ -54,10 +55,11 @@ public class ConsentCommand implements CommandExecutor, TabCompleter { } 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 "accept" -> handleAccept(sender, cfg); + case "decline" -> handleDecline(sender, cfg); + case "withdraw" -> handleWithdraw(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())); @@ -116,6 +118,31 @@ public class ConsentCommand implements CommandExecutor, TabCompleter { plugin.networkKick(player, MM.deserialize(msg)); } + private void handleWithdraw(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(); + + // 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) { if (!(sender instanceof Player player)) { sender.sendMessage(MM.deserialize("This command can only be used in-game.")); @@ -225,7 +252,7 @@ public class ConsentCommand implements CommandExecutor, TabCompleter { @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")); + List base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help")); if (sender.hasPermission("mclogger.consent.admin")) base.add("admin"); return filter(base, args[0]); } 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 index 1665075..1318993 100644 --- 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 @@ -1,5 +1,9 @@ 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.PreparedStatement; import java.sql.ResultSet; @@ -92,12 +96,15 @@ public class ConsentDatabase { try (Connection conn = dataSource.getConnection(); 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( "CREATE TABLE IF NOT EXISTS player_consent (" + " uuid VARCHAR(36) NOT NULL," + " username VARCHAR(16) NOT NULL," + " policy_version VARCHAR(64) NOT NULL," + + " policy_hash VARCHAR(64) NULL," + " consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," + " ip_address VARCHAR(45) NULL," + " PRIMARY KEY (uuid, policy_version)," + @@ -105,7 +112,12 @@ public class ConsentDatabase { " INDEX idx_consent_version (policy_version)" + ") 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( "CREATE TABLE IF NOT EXISTS player_consent_declines (" + " id BIGINT AUTO_INCREMENT PRIMARY KEY," + @@ -148,17 +160,23 @@ public class ConsentDatabase { /** * Records that the player has accepted the current policy version. * Uses {@code INSERT IGNORE} so duplicate calls are safe. + * + *

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) { final String sql = - "INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" + - " VALUES (?, ?, ?, ?)"; + "INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, 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.setString(4, computePolicyHash(policyVersion)); + ps.setString(5, pseudonymizeIp(ip)); ps.executeUpdate(); } catch (SQLException 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. - * 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) { final String sql = @@ -178,7 +196,7 @@ public class ConsentDatabase { ps.setString(1, uuid); ps.setString(2, username); ps.setString(3, policyVersion); - ps.setString(4, ip); + ps.setString(4, pseudonymizeIp(ip)); ps.executeUpdate(); } catch (SQLException e) { plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e); @@ -243,6 +261,100 @@ public class ConsentDatabase { * @param uuid Player UUID 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. + * + *

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). + *

    + *
  • IPv4: last octet set to 0 (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})
  • + *
  • IPv6: last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)
  • + *
+ * 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) { if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return; diff --git a/consent-plugin/src/main/resources/config.yml b/consent-plugin/src/main/resources/config.yml index 0b965c8..32f944f 100644 --- a/consent-plugin/src/main/resources/config.yml +++ b/consent-plugin/src/main/resources/config.yml @@ -89,7 +89,14 @@ consent: # Players with this permission bypass all consent checks. # Default: consent.bypass (assigned to ops by default) 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: # Log consent accept/decline events to the server_events table in @@ -125,9 +132,13 @@ messages: # ── Join prompt ───────────────────────────────────────────── # 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: | Privacy Policy Consent Required Before you can play you must read and accept our Privacy Policy. + We collect: chat messages, commands, login/logout times, IP address + (pseudonymized), and player position – for server moderation purposes. Click to open in browser'>📄 {url} ➜ Type /{command} accept to agree and start playing. ➜ Type /{command} decline to disconnect. @@ -197,3 +208,15 @@ messages: ⚠ Reminder: Please accept our Privacy Policy. {url} Type /{command} accept to accept. + # ── Consent withdrawn (Art. 7(3) GDPR) ─────────────────────── + # Shown in chat just before the player is kicked after /consent withdraw. + withdrawn: "Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time." + + # 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} \ No newline at end of file