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
This commit is contained in:
@@ -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 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 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. */
|
||||
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.
|
||||
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.*;
|
||||
* <ul>
|
||||
* <li>{@code /consent accept} – record consent for the current policy version</li>
|
||||
* <li>{@code /consent decline} – record decline and kick the player</li>
|
||||
* <li>{@code /consent withdraw} – withdraw previously given consent (Art. 7(3) GDPR)</li>
|
||||
* <li>{@code /consent status} – show current consent status</li>
|
||||
* <li>{@code /consent help} – show help text</li>
|
||||
* </ul>
|
||||
@@ -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("<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) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
@@ -225,7 +252,7 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (args.length == 1) {
|
||||
List<String> base = new ArrayList<>(List.of("accept", "decline", "status", "help"));
|
||||
List<String> base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help"));
|
||||
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
|
||||
return filter(base, args[0]);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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) {
|
||||
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.
|
||||
*
|
||||
* <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) {
|
||||
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
|
||||
|
||||
|
||||
@@ -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: |
|
||||
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
|
||||
<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>
|
||||
<green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
|
||||
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
|
||||
@@ -197,3 +208,15 @@ messages:
|
||||
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
|
||||
<aqua><click:open_url:'{url}'>{url}</click></aqua>
|
||||
<green>Type /<bold>{command} accept</bold> to accept.</green>
|
||||
# ── 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}
|
||||
Reference in New Issue
Block a user