Compare commits
6 Commits
2dbd5340a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df8230111 | ||
|
|
408933106b | ||
|
|
c2b645ed58 | ||
|
|
aa8dfdcdbf | ||
|
|
83af428435 | ||
|
|
cd9a46b403 |
@@ -27,11 +27,11 @@
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- Paper API 1.21 -->
|
||||
<!-- Paper API 1.17+ (lowest supported; ensures no 1.18+-only APIs are used) -->
|
||||
<dependency>
|
||||
<groupId>io.papermc.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>1.21-R0.1-SNAPSHOT</version>
|
||||
<version>1.17.1-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -164,4 +181,13 @@ public class ConsentConfig {
|
||||
"<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.entity.Player;
|
||||
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.database.ConsentDatabase;
|
||||
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.
|
||||
@@ -59,6 +65,9 @@ public class ConsentPlugin extends JavaPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register plugin-messaging channel for Velocity / BungeeCord network kick
|
||||
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
|
||||
|
||||
// Register event listeners
|
||||
ConsentListener listener = new ConsentListener(this);
|
||||
getServer().getPluginManager().registerEvents(listener, this);
|
||||
@@ -75,6 +84,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).");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -107,4 +128,31 @@ public class ConsentPlugin extends JavaPlugin {
|
||||
public Set<UUID> getPendingConsent() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
@@ -113,7 +115,32 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
|
||||
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||
|
||||
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) {
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
package de.simolzimol.mclogger.consent.listeners;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
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.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerLoginEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
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.EnforcementMode;
|
||||
@@ -93,9 +102,12 @@ public class ConsentListener implements Listener {
|
||||
|
||||
EnforcementMode mode = cfg.getEnforcementMode();
|
||||
|
||||
// Send the join prompt
|
||||
String prompt = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
|
||||
player.sendMessage(MiniMessage.miniMessage().deserialize(prompt));
|
||||
// Open the policy book 1 tick after join (openBook can't be called during join)
|
||||
String promptMini = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
if (!player.isOnline()) return;
|
||||
openPolicyBook(player, promptMini);
|
||||
}, 1L);
|
||||
|
||||
if (mode == EnforcementMode.HOLD) {
|
||||
plugin.getPendingConsent().add(player.getUniqueId());
|
||||
@@ -177,6 +189,28 @@ public class ConsentListener implements Listener {
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -193,6 +227,40 @@ public class ConsentListener implements Listener {
|
||||
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
|
||||
* avoid flooding the chat (e.g. when the player spams movement).
|
||||
@@ -241,7 +309,7 @@ public class ConsentListener implements Listener {
|
||||
plugin.getPendingConsent().remove(uuid);
|
||||
String kickMsg = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
|
||||
p.kick(MiniMessage.miniMessage().deserialize(kickMsg));
|
||||
doKick(p, MiniMessage.miniMessage().deserialize(kickMsg));
|
||||
}, (long) graceSec * 20);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ public final class MessageUtil {
|
||||
* <p>Supported placeholders:
|
||||
* <ul>
|
||||
* <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>
|
||||
* </ul>
|
||||
*/
|
||||
@@ -24,7 +26,9 @@ public final class MessageUtil {
|
||||
if (template == null) return "";
|
||||
return template
|
||||
.replace("{player}", player.getName())
|
||||
.replace("{url}", cfg.getPolicyUrl())
|
||||
.replace("{policy_url}", cfg.getPolicyUrl())
|
||||
.replace("{command}", cfg.getCommand())
|
||||
.replace("{version}", cfg.getPolicyVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ============================================================
|
||||
# MCConsent – Paper Plugin Configuration
|
||||
# Author: SimolZimol
|
||||
# Docs: https://github.com/SimolZimol/MCLogger
|
||||
# Website: https://log.devanturas.net
|
||||
# ============================================================
|
||||
|
||||
# ── Global switch ────────────────────────────────────────────
|
||||
@@ -55,6 +55,14 @@ consent:
|
||||
# in plugin.yml with aliases /privacy and /pp).
|
||||
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) ───────────────────────────
|
||||
# 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).
|
||||
@@ -81,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
|
||||
@@ -117,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>
|
||||
@@ -189,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}
|
||||
@@ -1,7 +1,7 @@
|
||||
name: MCConsent
|
||||
version: '1.0.0'
|
||||
main: de.simolzimol.mclogger.consent.ConsentPlugin
|
||||
api-version: '1.20'
|
||||
api-version: '1.17'
|
||||
description: Privacy Policy consent enforcement for Minecraft servers
|
||||
author: SimolZimol
|
||||
|
||||
|
||||
19
web/app.py
19
web/app.py
@@ -148,14 +148,19 @@ def create_app() -> Flask:
|
||||
policy_version=Config.PRIVACY_POLICY_VERSION,
|
||||
)
|
||||
|
||||
@app.route("/policy/<int:group_id>")
|
||||
def public_group_policy(group_id):
|
||||
"""Public, unauthenticated URL for a group's server privacy policy."""
|
||||
@app.route("/policy/<token>")
|
||||
def public_group_policy(token):
|
||||
"""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
|
||||
policy = db.get_group_policy(group_id)
|
||||
group = db.get_group_by_id(group_id)
|
||||
if not group:
|
||||
return "Group not found", 404
|
||||
row = db.get_group_policy_by_token(token)
|
||||
if not row:
|
||||
return render_template("404.html"), 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)
|
||||
|
||||
@app.errorhandler(400)
|
||||
|
||||
@@ -572,7 +572,8 @@ def privacy_policy():
|
||||
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)
|
||||
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",
|
||||
policy=policy, group=group, public_url=public_url)
|
||||
|
||||
|
||||
@@ -177,13 +177,22 @@ PANEL_MIGRATIONS = [
|
||||
"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,
|
||||
group_id INT PRIMARY KEY,
|
||||
policy_text LONGTEXT,
|
||||
policy_url VARCHAR(500),
|
||||
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
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
"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 = [
|
||||
@@ -769,20 +778,40 @@ def set_user_consent(user_id: int, policy_version: str) -> None:
|
||||
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 "
|
||||
"SELECT group_id, policy_text, policy_url, public_token, updated_at "
|
||||
"FROM group_privacy_policy WHERE group_id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
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:
|
||||
"""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(
|
||||
"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()",
|
||||
"INSERT INTO group_privacy_policy (group_id, policy_text, policy_url, public_token) "
|
||||
"VALUES (%s, %s, %s, UUID()) "
|
||||
"ON DUPLICATE KEY UPDATE "
|
||||
"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),
|
||||
write=True,
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<footer class="text-center py-2 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</p>
|
||||
|
||||
{# ── Public URL banner ────────────────────────────────────── #}
|
||||
{% if public_url %}
|
||||
<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>
|
||||
<div>
|
||||
@@ -23,6 +24,12 @@
|
||||
</button>
|
||||
</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 ─────────────────────────────────────────── #}
|
||||
{% if policy and policy.updated_at %}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<i class="bi bi-file-earmark-lock2 me-2 text-warning"></i>
|
||||
<strong>{{ group.name }}</strong> — Privacy Policy
|
||||
</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> — Made by Devanturas</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
<hr class="border-secondary mt-5">
|
||||
<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>.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
<a href="{{ url_for('auth.login') }}" class="text-muted me-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
||||
</a>
|
||||
MCLogger — <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a>
|
||||
MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">Devanturas</a> — <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user