Compare commits

..

2 Commits

Author SHA1 Message Date
simon
2dbd5340a8 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/database/ConsentDatabase.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
2026-04-17 11:42:17 +02:00
simon
17a782b487 new file: consent-plugin/pom.xml
new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
	new file:   consent-plugin/src/main/resources/config.yml
	new file:   consent-plugin/src/main/resources/plugin.yml
	modified:   web/app.py
	modified:   web/blueprints/group_admin.py
	modified:   web/panel_db.py
	modified:   web/templates/group_admin/base.html
	new file:   web/templates/group_admin/privacy_policy.html
	new file:   web/templates/group_policy.html
2026-04-17 11:41:35 +02:00
15 changed files with 1660 additions and 0 deletions

106
consent-plugin/pom.xml Normal file
View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.simolzimol</groupId>
<artifactId>mcconsent-paper</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>MCConsent-Paper</name>
<description>Privacy Policy consent enforcement for Minecraft servers</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<!-- Paper API 1.21 -->
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- HikariCP Connection Pooling -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
<!-- MariaDB JDBC Driver -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.4.1</version>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<!-- Shade: package all dependencies into a single JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<!-- Relocate shaded libs to avoid collisions with MCLogger plugin -->
<relocations>
<relocation>
<pattern>com.zaxxer.hikari</pattern>
<shadedPattern>de.simolzimol.mcconsent.lib.hikari</shadedPattern>
</relocation>
<relocation>
<pattern>org.mariadb</pattern>
<shadedPattern>de.simolzimol.mcconsent.lib.mariadb</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>META-INF/MANIFEST.MF</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,167 @@
package de.simolzimol.mclogger.consent;
import java.util.List;
import org.bukkit.configuration.file.FileConfiguration;
/**
* Strongly-typed wrapper around the plugin's {@code config.yml}.
* Call {@link #reload()} after {@link ConsentPlugin#reloadConfig()} to
* pick up any changes.
*
* @author SimolZimol
*/
public class ConsentConfig {
/** Enforcement modes for the plugin. */
public enum EnforcementMode {
/** Kick the player at login if they have no stored consent. */
KICK,
/**
* Let the player join but freeze them until they accept.
* Kicks after the grace period if still pending.
*/
HOLD,
/** Allow full access; send periodic reminders but never kick. */
REMIND
}
private final ConsentPlugin plugin;
private FileConfiguration cfg;
public ConsentConfig(ConsentPlugin plugin) {
this.plugin = plugin;
reload();
}
/** Re-reads the YAML file into memory. */
public void reload() {
plugin.reloadConfig();
cfg = plugin.getConfig();
}
// ── Global ───────────────────────────────────────────────
public boolean isEnabled() {
return cfg.getBoolean("enabled", true);
}
// ── Database ─────────────────────────────────────────────
public String getDbHost() { return cfg.getString("database.host", "localhost"); }
public int getDbPort() { return cfg.getInt ("database.port", 3306); }
public String getDbDatabase() { return cfg.getString("database.database", "mclogger"); }
public String getDbUsername() { return cfg.getString("database.username", "mclogger"); }
public String getDbPassword() { return cfg.getString("database.password", ""); }
public boolean isDbSsl() { return cfg.getBoolean("database.ssl", false); }
public int getDbPoolSize() { return cfg.getInt ("database.pool-size", 5); }
// ── Consent settings ─────────────────────────────────────
public String getPolicyVersion() {
return cfg.getString("consent.policy-version", "1.0");
}
public String getPolicyUrl() {
return cfg.getString("consent.policy-url", "https://example.com/privacy-policy");
}
public EnforcementMode getEnforcementMode() {
String raw = cfg.getString("consent.enforcement-mode", "HOLD");
try {
return EnforcementMode.valueOf(raw.toUpperCase());
} catch (IllegalArgumentException e) {
plugin.getLogger().warning(
"[MCConsent] Unknown enforcement-mode '" + raw + "' defaulting to HOLD.");
return EnforcementMode.HOLD;
}
}
/** Display name for the consent command used in prompt text. */
public String getCommand() {
return cfg.getString("consent.command", "consent");
}
/** Seconds before a HOLD-mode player is kicked for not consenting (0 = infinite). */
public int getGracePeriodSeconds() {
return cfg.getInt("consent.grace-period-seconds", 120);
}
/** Seconds between reminder messages in REMIND mode (0 = once on join only). */
public int getReminderIntervalSeconds() {
return cfg.getInt("consent.reminder-interval-seconds", 300);
}
/** Minimum seconds between move-blocked messages (avoids chat spam). */
public int getMoveMessageCooldownSeconds() {
return cfg.getInt("consent.move-message-cooldown-seconds", 5);
}
/** Worlds listed here are exempt from consent enforcement. */
public List<String> getExemptWorlds() {
return cfg.getStringList("consent.exempt-worlds");
}
public String getBypassPermission() {
return cfg.getString("consent.bypass-permission", "consent.bypass");
}
// ── Admin settings ───────────────────────────────────────
public boolean isLogToMCLoggerDb() {
return cfg.getBoolean("admin.log-to-mclogger-db", true);
}
public boolean isNotifyAdmins() {
return cfg.getBoolean("admin.notify-admins", false);
}
public String getAdminPermission() {
return cfg.getString("admin.admin-permission", "consent.admin");
}
// ── Messages ─────────────────────────────────────────────
public String getPrefix() { return cfg.getString("messages.prefix", "<gold>[Privacy]</gold> "); }
public String getMsgJoinPrompt() { return cfg.getString("messages.join-prompt", "Please accept our Privacy Policy. Type /{command} accept"); }
public String getMsgAccepted() { return cfg.getString("messages.accepted", "<green>Thank you for accepting the Privacy Policy!</green>"); }
public String getMsgDeclined() { return cfg.getString("messages.declined", "<red>You declined the Privacy Policy.</red>"); }
public String getMsgKick() { return cfg.getString("messages.kick-message", "Privacy Policy consent required.\nVisit: {url}\nReconnect and type /{command} accept."); }
public String getMsgDeclineKick() { return cfg.getString("messages.decline-kick-message", "You declined the Privacy Policy."); }
public String getMsgGraceKick() { return cfg.getString("messages.grace-kick-message", "Consent timeout.\nVisit: {url}\nReconnect and type /{command} accept."); }
public String getMsgHoldMoveBlocked() { return cfg.getString("messages.hold-move-blocked", "<red>Accept the Privacy Policy first! Type /{command} accept</red>"); }
public String getMsgHoldChatBlocked() { return cfg.getString("messages.hold-chat-blocked", "<red>You cannot chat until you accept the Privacy Policy.</red>"); }
public String getMsgHoldCmdBlocked() { return cfg.getString("messages.hold-command-blocked", "<red>Commands disabled until you accept the Privacy Policy.</red>"); }
public String getMsgAlreadyAccepted() { return cfg.getString("messages.already-accepted", "<green>You have already accepted the current Privacy Policy.</green>"); }
public String getMsgNoConsentNeeded() { return cfg.getString("messages.no-consent-required", "<gray>No consent is currently required.</gray>"); }
public String getMsgGraceWarning() { return cfg.getString("messages.grace-warning", "<yellow>You have {seconds}s left to accept or you will be disconnected.</yellow>"); }
public String getMsgAdminAccept() { return cfg.getString("messages.admin-notify-accept", "<dark_gray>[Consent] {player} accepted (v{version}).</dark_gray>"); }
public String getMsgAdminDecline() { return cfg.getString("messages.admin-notify-decline", "<dark_gray>[Consent] {player} declined (v{version}).</dark_gray>"); }
public String getMsgHelpHeader() { return cfg.getString("messages.help-header", "<gold><bold>===== MCConsent Help =====</bold></gold>"); }
public String getMsgRemind() { return cfg.getString("messages.remind-message", "<yellow>Please accept our Privacy Policy. Type /{command} accept</yellow>"); }
public String getMsgStatusAccepted() { return cfg.getString("messages.status-accepted", "<green>You have accepted the Privacy Policy (v{version}).</green>"); }
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>"); }
/** Whether a message should be sent to online admins when a player accepts. */
public boolean isNotifyAdminsOnConsent() {
return cfg.getBoolean("admin.notify-admins-on-consent", false);
}
/**
* Returns the help text as a list of MiniMessage lines.
* Falls back to a built-in list if not configured.
*/
public List<String> getHelpLines() {
List<String> lines = cfg.getStringList("messages.help-lines");
if (!lines.isEmpty()) return lines;
return List.of(
getMsgHelpHeader(),
"<gray>/consent accept</gray> <white>Accept the Privacy Policy</white>",
"<gray>/consent decline</gray> <white>Decline and disconnect</white>",
"<gray>/consent status</gray> <white>Check your consent status</white>",
"<gray>Policy: <aqua>{policy_url}</aqua>"
);
}
}

View File

@@ -0,0 +1,110 @@
package de.simolzimol.mclogger.consent;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
import de.simolzimol.mclogger.consent.commands.ConsentCommand;
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
import de.simolzimol.mclogger.consent.listeners.ConsentListener;
/**
* MCConsent Privacy Policy consent enforcement for Paper servers.
*
* <p>Key components:
* <ul>
* <li>{@link ConsentConfig} Strongly-typed configuration wrapper</li>
* <li>{@link ConsentDatabase} HikariCP-backed MariaDB/MySQL client</li>
* <li>{@link ConsentListener} Enforces consent on login/join/move/chat</li>
* <li>{@link ConsentCommand} /consent accept|decline|status|admin …</li>
* </ul>
*
* @author SimolZimol
* @version 1.0.0
*/
public class ConsentPlugin extends JavaPlugin {
private static ConsentPlugin instance;
private ConsentConfig consentConfig;
private ConsentDatabase consentDatabase;
/**
* UUIDs of players who have joined but not yet consented (HOLD mode).
* Thread-safe because both the main thread and async events touch this set.
*/
private final Set<UUID> pendingConsent = ConcurrentHashMap.newKeySet();
// ─────────────────────────────────────────────────────────
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
consentConfig = new ConsentConfig(this);
if (!consentConfig.isEnabled()) {
getLogger().info("[MCConsent] Plugin is disabled via config (enabled: false).");
return;
}
// Connect to database
consentDatabase = new ConsentDatabase(this);
if (!consentDatabase.connect()) {
getLogger().severe("[MCConsent] Could not connect to the database disabling plugin.");
getServer().getPluginManager().disablePlugin(this);
return;
}
// Register event listeners
ConsentListener listener = new ConsentListener(this);
getServer().getPluginManager().registerEvents(listener, this);
// Register /consent command
ConsentCommand cmdHandler = new ConsentCommand(this);
PluginCommand cmd = getCommand("consent");
if (cmd != null) {
cmd.setExecutor(cmdHandler);
cmd.setTabCompleter(cmdHandler);
} else {
getLogger().warning("[MCConsent] Could not find 'consent' command in plugin.yml!");
}
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
+ " PolicyVersion=" + consentConfig.getPolicyVersion());
}
// ─────────────────────────────────────────────────────────
@Override
public void onDisable() {
pendingConsent.clear();
if (consentDatabase != null) {
consentDatabase.disconnect();
}
getLogger().info("[MCConsent] Disabled.");
}
// ─────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────
public static ConsentPlugin getInstance() {
return instance;
}
public ConsentConfig getConsentConfig() {
return consentConfig;
}
public ConsentDatabase getConsentDatabase() {
return consentDatabase;
}
/** Live set of players currently waiting for consent (HOLD mode). */
public Set<UUID> getPendingConsent() {
return pendingConsent;
}
}

View File

@@ -0,0 +1,265 @@
package de.simolzimol.mclogger.consent.commands;
import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentPlugin;
import de.simolzimol.mclogger.consent.util.MessageUtil;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.*;
import org.bukkit.entity.Player;
import java.util.*;
/**
* Handles the {@code /consent} command and its sub-commands.
*
* <p><b>Player sub-commands:</b>
* <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 status} show current consent status</li>
* <li>{@code /consent help} show help text</li>
* </ul>
*
* <p><b>Admin sub-commands} (require {@code mclogger.consent.admin}):</b>
* <ul>
* <li>{@code /consent admin reload} reload plugin config</li>
* <li>{@code /consent admin list} list online players who haven't consented</li>
* <li>{@code /consent admin revoke <player>} revoke all consents for a player</li>
* <li>{@code /consent admin forceaccept <player>} record consent on behalf of a player</li>
* </ul>
*/
public class ConsentCommand implements CommandExecutor, TabCompleter {
private static final MiniMessage MM = MiniMessage.miniMessage();
private final ConsentPlugin plugin;
public ConsentCommand(ConsentPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────
// Command dispatch
// ─────────────────────────────────────────────────────────
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
ConsentConfig cfg = plugin.getConsentConfig();
if (args.length == 0) {
sendHelp(sender, cfg);
return true;
}
switch (args[0].toLowerCase(Locale.ROOT)) {
case "accept" -> handleAccept(sender, cfg);
case "decline" -> handleDecline(sender, cfg);
case "status" -> handleStatus(sender, cfg);
case "help" -> sendHelp(sender, cfg);
case "admin" -> {
if (!sender.hasPermission("mclogger.consent.admin")) {
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
return true;
}
handleAdmin(sender, cfg, args);
}
default -> sendHelp(sender, cfg);
}
return true;
}
// ─────────────────────────────────────────────────────────
// Player sub-commands
// ─────────────────────────────────────────────────────────
private void handleAccept(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
String ip = player.getAddress() != null
? player.getAddress().getAddress().getHostAddress() : "unknown";
plugin.getConsentDatabase().recordConsent(uuid, player.getName(), version, ip);
plugin.getPendingConsent().remove(player.getUniqueId());
String msg = MessageUtil.replace(cfg.getMsgAccepted(), player, cfg);
player.sendMessage(MM.deserialize(msg));
// Notify admins if configured
if (cfg.isNotifyAdminsOnConsent()) {
String notify = MessageUtil.replace(cfg.getMsgAdminNotifyConsent(), player, cfg);
broadcastAdmins(notify);
}
}
private void handleDecline(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
String ip = player.getAddress() != null
? player.getAddress().getAddress().getHostAddress() : "unknown";
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version, ip);
plugin.getPendingConsent().remove(player.getUniqueId());
String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg);
player.kick(MM.deserialize(msg));
}
private void handleStatus(CommandSender sender, ConsentConfig cfg) {
if (!(sender instanceof Player player)) {
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
return;
}
boolean consented = plugin.getConsentDatabase()
.hasConsented(player.getUniqueId().toString(), cfg.getPolicyVersion());
String template = consented ? cfg.getMsgStatusAccepted() : cfg.getMsgStatusPending();
player.sendMessage(MM.deserialize(MessageUtil.replace(template, player, cfg)));
}
private void sendHelp(CommandSender sender, ConsentConfig cfg) {
for (String line : cfg.getHelpLines()) {
if (sender instanceof Player player) {
sender.sendMessage(MM.deserialize(MessageUtil.replace(line, player, cfg)));
} else {
// Strip MiniMessage tags for console
sender.sendMessage(MM.stripTags(line));
}
}
}
// ─────────────────────────────────────────────────────────
// Admin sub-commands
// ─────────────────────────────────────────────────────────
private void handleAdmin(CommandSender sender, ConsentConfig cfg, String[] args) {
if (args.length < 2) {
sender.sendMessage(MM.deserialize("<yellow>Usage: /consent admin <reload|list|revoke|forceaccept>"));
return;
}
switch (args[1].toLowerCase(Locale.ROOT)) {
case "reload" -> {
plugin.reloadConsentConfig();
sender.sendMessage(MM.deserialize("<green>[MCConsent] Config reloaded."));
}
case "list" -> {
Set<UUID> pending = plugin.getPendingConsent();
if (pending.isEmpty()) {
sender.sendMessage(MM.deserialize("<green>[MCConsent] No players are currently awaiting consent."));
return;
}
sender.sendMessage(MM.deserialize("<yellow>[MCConsent] Pending consent (" + pending.size() + "):"));
for (UUID uuid : pending) {
Player p = Bukkit.getPlayer(uuid);
String name = p != null ? p.getName() : uuid.toString();
sender.sendMessage(MM.deserialize("<gray> - " + name));
}
}
case "revoke" -> {
if (args.length < 3) {
sender.sendMessage(MM.deserialize("<red>Usage: /consent admin revoke <player>"));
return;
}
String target = args[2];
@SuppressWarnings("deprecation")
OfflinePlayer op = Bukkit.getOfflinePlayer(target);
if (!op.hasPlayedBefore() && !op.isOnline()) {
sender.sendMessage(MM.deserialize("<red>Player not found: " + target));
return;
}
plugin.getConsentDatabase().revokeConsent(op.getUniqueId().toString(), null);
// Also add to pending if currently online
if (op.isOnline()) {
plugin.getPendingConsent().add(op.getUniqueId());
}
sender.sendMessage(MM.deserialize("<green>[MCConsent] Consent revoked for " + op.getName()
+ ". They will need to re-accept on next login."));
}
case "forceaccept" -> {
if (args.length < 3) {
sender.sendMessage(MM.deserialize("<red>Usage: /consent admin forceaccept <player>"));
return;
}
String target = args[2];
@SuppressWarnings("deprecation")
OfflinePlayer op = Bukkit.getOfflinePlayer(target);
if (!op.hasPlayedBefore() && !op.isOnline()) {
sender.sendMessage(MM.deserialize("<red>Player not found: " + target));
return;
}
String version = cfg.getPolicyVersion();
plugin.getConsentDatabase().recordConsent(
op.getUniqueId().toString(),
op.getName() != null ? op.getName() : "unknown",
version, "admin-forced");
plugin.getPendingConsent().remove(op.getUniqueId());
sender.sendMessage(MM.deserialize("<green>[MCConsent] Consent recorded for " + op.getName()
+ " (version: " + version + ")."));
}
default -> sender.sendMessage(MM.deserialize(
"<yellow>Unknown admin sub-command. Use: reload | list | revoke | forceaccept"));
}
}
// ─────────────────────────────────────────────────────────
// Tab completion
// ─────────────────────────────────────────────────────────
@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"));
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
return filter(base, args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("admin")
&& sender.hasPermission("mclogger.consent.admin")) {
return filter(List.of("reload", "list", "revoke", "forceaccept"), args[1]);
}
if (args.length == 3
&& args[0].equalsIgnoreCase("admin")
&& (args[1].equalsIgnoreCase("revoke") || args[1].equalsIgnoreCase("forceaccept"))
&& sender.hasPermission("mclogger.consent.admin")) {
List<String> names = new ArrayList<>();
Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName()));
return filter(names, args[2]);
}
return Collections.emptyList();
}
// ─────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────
private static List<String> filter(List<String> options, String prefix) {
List<String> result = new ArrayList<>();
String lower = prefix.toLowerCase(Locale.ROOT);
for (String opt : options) {
if (opt.toLowerCase(Locale.ROOT).startsWith(lower)) result.add(opt);
}
return result;
}
private void broadcastAdmins(String miniMsg) {
Bukkit.getOnlinePlayers().stream()
.filter(p -> p.hasPermission("mclogger.consent.admin"))
.forEach(p -> p.sendMessage(MM.deserialize(miniMsg)));
}
}

View File

@@ -0,0 +1,268 @@
package de.simolzimol.mclogger.consent.database;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentPlugin;
/**
* HikariCP-backed database client for MCConsent.
*
* <p>Tables managed by this class:
* <ul>
* <li>{@code player_consent} stores accepted consents per UUID + version</li>
* <li>{@code player_consent_declines} audit trail of declined consents</li>
* </ul>
*
* <p>Both tables are created automatically on first connection.
* If {@code admin.log-to-mclogger-db} is {@code true} consent events are also
* inserted into the {@code server_events} table that MCLogger maintains.
*
* @author SimolZimol
*/
public class ConsentDatabase {
private final ConsentPlugin plugin;
private HikariDataSource dataSource;
public ConsentDatabase(ConsentPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────
/**
* Opens the connection pool and ensures the required tables exist.
*
* @return {@code true} if the connection was established successfully.
*/
public boolean connect() {
ConsentConfig cfg = plugin.getConsentConfig();
HikariConfig hk = new HikariConfig();
hk.setDriverClassName("de.simolzimol.mcconsent.lib.mariadb.jdbc.Driver");
hk.setJdbcUrl(String.format(
"jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
cfg.getDbHost(), cfg.getDbPort(), cfg.getDbDatabase(), cfg.isDbSsl()));
hk.setUsername(cfg.getDbUsername());
hk.setPassword(cfg.getDbPassword());
hk.setMaximumPoolSize(cfg.getDbPoolSize());
hk.setMinimumIdle(1);
hk.setConnectionTimeout(30_000);
hk.setIdleTimeout(600_000);
hk.setMaxLifetime(1_800_000);
hk.setPoolName("MCConsent");
hk.addDataSourceProperty("cachePrepStmts", "true");
hk.addDataSourceProperty("prepStmtCacheSize", "100");
try {
dataSource = new HikariDataSource(hk);
initTables();
return true;
} catch (Exception e) {
plugin.getLogger().log(Level.SEVERE, "[MCConsent] Database connection failed!", e);
return false;
}
}
/** Closes the connection pool. */
public void disconnect() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
// ─────────────────────────────────────────────────────────
// Table initialisation
// ─────────────────────────────────────────────────────────
private void initTables() throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// Accepted consents - primary record of who agreed to what version
stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent (" +
" uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," +
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," +
" PRIMARY KEY (uuid, policy_version)," +
" INDEX idx_consent_uuid (uuid)," +
" INDEX idx_consent_version (policy_version)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
// Decline audit trail - never deleted (important for GDPR accountability)
stmt.execute(
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
" uuid VARCHAR(36) NOT NULL," +
" username VARCHAR(16) NOT NULL," +
" policy_version VARCHAR(64) NOT NULL," +
" declined_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
" ip_address VARCHAR(45) NULL," +
" INDEX idx_decline_uuid (uuid)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
}
// ─────────────────────────────────────────────────────────
// Query helpers
// ─────────────────────────────────────────────────────────
/**
* Returns {@code true} if the player has accepted the given policy version.
* Runs synchronously; should be called from the main thread or a context
* where a brief pause is acceptable (e.g. {@code PlayerLoginEvent}).
*/
public boolean hasConsented(String uuid, String policyVersion) {
final String sql =
"SELECT 1 FROM player_consent WHERE uuid = ? AND policy_version = ? LIMIT 1";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, policyVersion);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] hasConsented() failed", e);
// Fail-open: if we can't check, allow the player in (avoids locking everyone out)
return true;
}
}
/**
* Records that the player has accepted the current policy version.
* Uses {@code INSERT IGNORE} so duplicate calls are safe.
*/
public void recordConsent(String uuid, String username, String policyVersion, String ip) {
final String sql =
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" +
" VALUES (?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, policyVersion);
ps.setString(4, ip);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
}
}
/**
* Records that the player declined the current policy version.
* Declines are appended to an audit table and are never deleted.
*/
public void recordDecline(String uuid, String username, String policyVersion, String ip) {
final String sql =
"INSERT INTO player_consent_declines (uuid, username, policy_version, ip_address)" +
" VALUES (?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
ps.setString(2, username);
ps.setString(3, policyVersion);
ps.setString(4, ip);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
}
}
/**
* Removes a player's accepted consent for the given version.
* Used by {@code /consent admin revoke <player>} to force re-acceptance.
*
* @param uuid Player UUID string
* @param policyVersion Version to revoke; {@code null} revokes ALL versions
*/
public void revokeConsent(String uuid, String policyVersion) {
final String sql = policyVersion == null
? "DELETE FROM player_consent WHERE uuid = ?"
: "DELETE FROM player_consent WHERE uuid = ? AND policy_version = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, uuid);
if (policyVersion != null) ps.setString(2, policyVersion);
ps.executeUpdate();
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING, "[MCConsent] revokeConsent() failed", e);
}
}
/**
* Returns a list of UUIDs (as strings) that have no consent record for
* {@code currentVersion}. Only considers players known to the
* {@code players} table (MCLogger integration); returns an empty list if
* that table does not exist.
*/
public List<String> getPendingPlayerUuids(String currentVersion) {
final String sql =
"SELECT p.uuid FROM players p" +
" WHERE NOT EXISTS (" +
" SELECT 1 FROM player_consent c" +
" WHERE c.uuid = p.uuid AND c.policy_version = ?" +
" ) LIMIT 100";
List<String> result = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, currentVersion);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) result.add(rs.getString("uuid"));
}
} catch (SQLException e) {
// players table may not exist in standalone mode
plugin.getLogger().fine("[MCConsent] getPendingPlayerUuids(): " + e.getMessage());
}
return result;
}
/**
* Optionally writes a consent event into the MCLogger {@code server_events}
* table so it shows up in the web panel audit stream.
* Silently ignored if the table does not exist or the insert fails.
*
* @param eventType {@code "consent_accepted"} or {@code "consent_declined"}
* @param playerName In-game name for the log message
* @param uuid Player UUID string
* @param version Policy version string
*/
public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
final String sql =
"INSERT INTO server_events (event_type, server_name, message, details)" +
" VALUES (?, ?, ?, ?)";
String serverName = plugin.getConsentConfig().getDbDatabase();
String message = playerName + " " + eventType.replace('_', ' ') + " (v" + version + ")";
// Build minimal JSON manually to avoid a Gson dependency
String details = "{\"uuid\":\"" + uuid + "\",\"policy_version\":\"" + version + "\"}";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, eventType);
ps.setString(2, serverName);
ps.setString(3, message);
ps.setString(4, details);
ps.executeUpdate();
} catch (SQLException ignored) {
// Non-critical; server_events table may not exist in standalone mode
}
}
}

View File

@@ -0,0 +1,277 @@
package de.simolzimol.mclogger.consent.listeners;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
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 de.simolzimol.mclogger.consent.ConsentConfig;
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
import de.simolzimol.mclogger.consent.ConsentPlugin;
import de.simolzimol.mclogger.consent.util.MessageUtil;
import net.kyori.adventure.text.minimessage.MiniMessage;
/**
* Listens to player lifecycle events and enforces the configured consent mode.
*
* <p>Three modes:
* <ul>
* <li><b>KICK</b> player is kicked at {@link PlayerLoginEvent} if no consent.</li>
* <li><b>HOLD</b> player joins but movement/chat/commands are blocked until
* they type {@code /consent accept}. The grace-period timer
* runs as a scheduled task and kicks after the configured
* timeout.</li>
* <li><b>REMIND</b>- player joins normally; a repeating task sends reminder
* messages until they accept.</li>
* </ul>
*
* @author SimolZimol
*/
public class ConsentListener implements Listener {
private final ConsentPlugin plugin;
/** Last time a move-blocked message was sent to a player (millis). */
private final Map<UUID, Long> lastMoveMsgTime = new ConcurrentHashMap<>();
public ConsentListener(ConsentPlugin plugin) {
this.plugin = plugin;
}
// ─────────────────────────────────────────────────────────
// LoginEvent: KICK mode enforcement
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST)
public void onLogin(PlayerLoginEvent event) {
ConsentConfig cfg = plugin.getConsentConfig();
if (!cfg.isEnabled()) return;
Player player = event.getPlayer();
if (player.hasPermission(cfg.getBypassPermission())) return;
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
// Only kick immediately in KICK mode; other modes handle it on join
if (cfg.getEnforcementMode() == EnforcementMode.KICK) {
boolean consented = plugin.getConsentDatabase().hasConsented(uuid, version);
if (!consented) {
String kickText = MessageUtil.replace(cfg.getMsgKick(), player, cfg);
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, kickText);
}
}
}
// ─────────────────────────────────────────────────────────
// JoinEvent: HOLD / REMIND mode setup
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
ConsentConfig cfg = plugin.getConsentConfig();
Player player = event.getPlayer();
if (!cfg.isEnabled()) return;
if (player.hasPermission(cfg.getBypassPermission())) return;
if (isExemptWorld(player, cfg)) return;
String uuid = player.getUniqueId().toString();
String version = cfg.getPolicyVersion();
if (plugin.getConsentDatabase().hasConsented(uuid, version)) return;
EnforcementMode mode = cfg.getEnforcementMode();
// Send the join prompt
String prompt = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
player.sendMessage(MiniMessage.miniMessage().deserialize(prompt));
if (mode == EnforcementMode.HOLD) {
plugin.getPendingConsent().add(player.getUniqueId());
scheduleGraceKick(player);
} else if (mode == EnforcementMode.REMIND) {
int interval = cfg.getReminderIntervalSeconds();
if (interval > 0) {
scheduleReminder(player);
}
}
}
// ─────────────────────────────────────────────────────────
// QuitEvent: clean up
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
plugin.getPendingConsent().remove(uuid);
lastMoveMsgTime.remove(uuid);
}
// ─────────────────────────────────────────────────────────
// HOLD-mode: block movement
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (!isHoldPending(event.getPlayer())) return;
// Block if the player moved to a different block (ignore head rotation)
if (event.getFrom().getBlockX() == event.getTo().getBlockX()
&& event.getFrom().getBlockY() == event.getTo().getBlockY()
&& event.getFrom().getBlockZ() == event.getTo().getBlockZ()) {
return;
}
event.setCancelled(true);
sendThrottled(event.getPlayer(),
plugin.getConsentConfig().getMsgHoldMoveBlocked());
}
// ─────────────────────────────────────────────────────────
// HOLD-mode: block chat
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onChat(AsyncPlayerChatEvent event) {
if (!isHoldPending(event.getPlayer())) return;
event.setCancelled(true);
// Must run on main thread; schedule a tick delayed task
UUID uuid = event.getPlayer().getUniqueId();
Bukkit.getScheduler().runTask(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p != null) {
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgHoldChatBlocked(), p,
plugin.getConsentConfig());
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
});
}
// ─────────────────────────────────────────────────────────
// HOLD-mode: block commands (except /consent and /pp)
// ─────────────────────────────────────────────────────────
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCommand(PlayerCommandPreprocessEvent event) {
if (!isHoldPending(event.getPlayer())) return;
String cmd = event.getMessage().toLowerCase();
// Allow /consent, /privacy, /pp (any of the registered aliases)
if (cmd.startsWith("/consent") || cmd.startsWith("/privacy") || cmd.startsWith("/pp")) {
return;
}
event.setCancelled(true);
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgHoldCmdBlocked(),
event.getPlayer(), plugin.getConsentConfig());
event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
// ─────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────
private boolean isHoldPending(Player player) {
ConsentConfig cfg = plugin.getConsentConfig();
if (!cfg.isEnabled()) return false;
if (cfg.getEnforcementMode() != EnforcementMode.HOLD) return false;
return plugin.getPendingConsent().contains(player.getUniqueId());
}
private boolean isExemptWorld(Player player, ConsentConfig cfg) {
String worldName = player.getWorld().getName();
return cfg.getExemptWorlds().contains(worldName);
}
/**
* Sends a MiniMessage-formatted message to the player with a cooldown to
* avoid flooding the chat (e.g. when the player spams movement).
*/
private void sendThrottled(Player player, String miniMessageText) {
long now = System.currentTimeMillis();
long cooldown = plugin.getConsentConfig().getMoveMessageCooldownSeconds() * 1000L;
Long lastSent = lastMoveMsgTime.get(player.getUniqueId());
if (lastSent != null && (now - lastSent) < cooldown) return;
lastMoveMsgTime.put(player.getUniqueId(), now);
String msg = MessageUtil.replace(miniMessageText, player, plugin.getConsentConfig());
player.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}
/**
* Schedules a task that kicks the player after the grace period if they
* still haven't consented. Silently cancelled if the player leaves first
* or accepts in time.
*/
private void scheduleGraceKick(Player player) {
int graceSec = plugin.getConsentConfig().getGracePeriodSeconds();
if (graceSec <= 0) return; // infinite grace
UUID uuid = player.getUniqueId();
// Warning at half of the grace period
int warnTick = (graceSec / 2) * 20;
if (warnTick > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p == null || !plugin.getPendingConsent().contains(uuid)) return;
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgGraceWarning()
.replace("{seconds}", String.valueOf(graceSec / 2)),
p, plugin.getConsentConfig());
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}, warnTick);
}
// Final kick
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p == null) return;
if (!plugin.getPendingConsent().contains(uuid)) return;
plugin.getPendingConsent().remove(uuid);
String kickMsg = MessageUtil.replace(
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
p.kick(MiniMessage.miniMessage().deserialize(kickMsg));
}, (long) graceSec * 20);
}
/**
* Schedules a repeating reminder task in REMIND mode.
* Cancels automatically when the player has consented or leaves.
*/
private void scheduleReminder(Player player) {
int intervalSec = plugin.getConsentConfig().getReminderIntervalSeconds();
if (intervalSec <= 0) return;
UUID uuid = player.getUniqueId();
long intervalTicks = (long) intervalSec * 20;
final int[] taskId = {-1};
taskId[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
Player p = Bukkit.getPlayer(uuid);
if (p == null) {
Bukkit.getScheduler().cancelTask(taskId[0]);
return;
}
// Stop reminding once they've accepted
if (plugin.getConsentDatabase().hasConsented(
uuid.toString(), plugin.getConsentConfig().getPolicyVersion())) {
Bukkit.getScheduler().cancelTask(taskId[0]);
return;
}
String msg = MessageUtil.replace(
plugin.getConsentConfig().getMsgRemind(), p, plugin.getConsentConfig());
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
}, intervalTicks, intervalTicks).getTaskId();
}
}

View File

@@ -0,0 +1,30 @@
package de.simolzimol.mclogger.consent.util;
import de.simolzimol.mclogger.consent.ConsentConfig;
import org.bukkit.entity.Player;
/**
* Simple placeholder replacement helper.
*/
public final class MessageUtil {
private MessageUtil() {}
/**
* Replaces standard placeholders in a MiniMessage string.
*
* <p>Supported placeholders:
* <ul>
* <li>{@code {player}} player's display name</li>
* <li>{@code {policy_url}} configured policy URL</li>
* <li>{@code {version}} policy version string</li>
* </ul>
*/
public static String replace(String template, Player player, ConsentConfig cfg) {
if (template == null) return "";
return template
.replace("{player}", player.getName())
.replace("{policy_url}", cfg.getPolicyUrl())
.replace("{version}", cfg.getPolicyVersion());
}
}

View File

@@ -0,0 +1,191 @@
# ============================================================
# MCConsent Paper Plugin Configuration
# Author: SimolZimol
# Docs: https://github.com/SimolZimol/MCLogger
# ============================================================
# ── Global switch ────────────────────────────────────────────
# Set to false to completely disable all enforcement (plugin stays
# loaded but won't block any player).
enabled: true
# ── Database ─────────────────────────────────────────────────
# Connection details for the MariaDB/MySQL database where consent
# records are stored. You can point this at the same database as
# the MCLogger plugin so consent events appear in the web panel.
database:
host: "localhost"
port: 3306
database: "mclogger"
username: "mclogger"
password: "change_me_please"
ssl: false
# Maximum number of concurrent DB connections
pool-size: 5
# ── Consent settings ─────────────────────────────────────────
consent:
# ── Policy version ──────────────────────────────────────────
# Change this value every time you update your Privacy Policy.
# Players who accepted an older version will be prompted again.
# Tip: use the Document ID shown on your MCLogger web panel
# policy page (e.g. "ABC123") for automatic synchronisation.
policy-version: "1.0"
# ── Policy URL ──────────────────────────────────────────────
# Full URL players can visit to read the Privacy Policy.
# If you host it via the MCLogger web panel, the URL looks like:
# https://your-panel.example.com/policy/<group-id>
policy-url: "https://example.com/privacy-policy"
# ── Enforcement mode ────────────────────────────────────────
# KICK Kick the player immediately at login if no consent.
# Players see a disconnect screen with the policy URL.
# HOLD Allow the player to join but freeze them in place.
# They cannot move, chat, or run commands until they
# accept (or the grace period expires and they get kicked).
# REMIND Let the player join freely; send periodic reminders
# but never kick. Useful for soft opt-in scenarios.
enforcement-mode: HOLD
# ── Command name ────────────────────────────────────────────
# The command players type to interact with the consent system.
# NOTE: This only changes display text in prompts.
# The actual Bukkit command is always "/consent" (registered
# in plugin.yml with aliases /privacy and /pp).
command: "consent"
# ── Grace period (HOLD mode only) ───────────────────────────
# How many seconds a player has to accept before being kicked.
# Set to 0 to wait forever (player can take as long as they like).
grace-period-seconds: 120
# ── Reminder interval (REMIND mode only) ────────────────────
# How often (seconds) to resend the consent prompt to players
# who haven't responded. 0 = send once on join only.
reminder-interval-seconds: 300
# ── Move-message cooldown ────────────────────────────────────
# Minimum seconds between "please accept first" messages when a
# player tries to move in HOLD mode. Prevents chat spam.
move-message-cooldown-seconds: 5
# ── Exempt worlds ────────────────────────────────────────────
# Players in these worlds are not subject to consent enforcement.
# Useful if you have a lobby/hub world where players arrive before
# being redirected to a game world.
# Example: ["world_lobby", "world_hub"]
exempt-worlds: []
# ── Bypass permission ────────────────────────────────────────
# Players with this permission bypass all consent checks.
# Default: consent.bypass (assigned to ops by default)
bypass-permission: "consent.bypass"
# ── Admin settings ───────────────────────────────────────────
admin:
# Log consent accept/decline events to the server_events table in
# the MCLogger database. Requires database.database to point to
# the same DB as MCLogger.
log-to-mclogger-db: true
# Broadcast a message to online admins when a player
# accepts or declines the policy.
notify-admins: false
# Permission node required to use admin sub-commands:
# /consent admin reload
# /consent admin list
# /consent admin revoke <player>
# /consent admin forceaccept <player>
admin-permission: "consent.admin"
# ── Messages ─────────────────────────────────────────────────
# All messages support MiniMessage formatting tags:
# <red>, <green>, <gold>, <bold>, <italic>, <underlined>
# <click:open_url:'URL'>, <hover:show_text:'text'>, etc.
# Available placeholders:
# {url} The policy URL (consent.policy-url)
# {command} The consent command (consent.command)
# {player} The player's name
# {version} The current policy version
# {seconds} Remaining grace period seconds (where applicable)
messages:
# ── Prefix ──────────────────────────────────────────────────
# Prepended to every plugin message.
prefix: "<gold>[Privacy]</gold> "
# ── Join prompt ─────────────────────────────────────────────
# Sent to a player when they join and consent is required.
join-prompt: |
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
<gray>Before you can play you must read and accept our Privacy Policy.</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>
# ── Accepted ────────────────────────────────────────────────
accepted: "<green>✔ Thank you! You have accepted the Privacy Policy (v{version}). Enjoy your stay!</green>"
# ── Declined (shown just before the player is kicked) ───────
declined: "<red>You declined the Privacy Policy. You cannot play on this server without accepting it.</red>"
# ── Kick message KICK mode, no prior consent ───────────────
# This is the disconnect screen message (plain text only
# some Minecraft versions strip MiniMessage in kick screens).
kick-message: |
Privacy Policy Consent Required
You must accept our Privacy Policy to play on this server.
Visit: {url}
Reconnect and type /{command} accept to accept.
# ── Kick message player declined ──────────────────────────
decline-kick-message: |
Privacy Policy Declined
You have declined our Privacy Policy.
You cannot play on this server without accepting it.
Contact an admin if you have questions.
# ── Grace period kick (HOLD mode) ───────────────────────────
grace-kick-message: |
Consent Timeout
You did not accept the Privacy Policy within the allowed time.
Visit: {url}
Reconnect and type /{command} accept to accept.
# ── Movement blocked (HOLD mode) ────────────────────────────
hold-move-blocked: "<red>⛔ Please accept the Privacy Policy first! Type /<bold>{command} accept</bold>.</red>"
# ── Chat blocked (HOLD mode) ────────────────────────────────
hold-chat-blocked: "<red>⛔ You cannot chat until you accept the Privacy Policy. Type /<bold>{command} accept</bold>.</red>"
# ── Command blocked (HOLD mode) ─────────────────────────────
hold-command-blocked: "<red>⛔ Commands are disabled until you accept the Privacy Policy. Type /<bold>{command} accept</bold>.</red>"
# ── Already accepted ────────────────────────────────────────
already-accepted: "<green>✔ You have already accepted the current Privacy Policy (v{version}).</green>"
# ── No consent currently required ───────────────────────────
no-consent-required: "<gray>No consent is currently required on this server.</gray>"
# ── Grace period warning ─────────────────────────────────────
grace-warning: "<yellow>⚠ You have <bold>{seconds}s</bold> left to accept the Privacy Policy or you will be disconnected.</yellow>"
# ── Admin broadcast: player accepted ────────────────────────
admin-notify-accept: "<dark_gray>[Consent] <white>{player}</white> accepted the Privacy Policy (v{version}).</dark_gray>"
# ── Admin broadcast: player declined ────────────────────────
admin-notify-decline: "<dark_gray>[Consent] <white>{player}</white> <red>declined</red> the Privacy Policy (v{version}).</dark_gray>"
# ── Help header ─────────────────────────────────────────────
help-header: "<gold><bold>===== MCConsent Help =====</bold></gold>"
# ── Remind mode reminder ─────────────────────────────────────
remind-message: |
<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>

View File

@@ -0,0 +1,28 @@
name: MCConsent
version: '1.0.0'
main: de.simolzimol.mclogger.consent.ConsentPlugin
api-version: '1.20'
description: Privacy Policy consent enforcement for Minecraft servers
author: SimolZimol
commands:
consent:
description: Accept, decline, or check your Privacy Policy consent status
usage: /consent <accept|decline|status|help>
permission: consent.use
aliases:
- privacy
- pp
permissions:
consent.use:
description: Use the /consent command
default: true
consent.bypass:
description: Skip consent checks entirely (e.g. for staff / ops)
default: op
consent.admin:
description: Access admin sub-commands (reload, list, revoke, forceaccept)
default: op

View File

@@ -148,6 +148,16 @@ 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."""
import panel_db as db
policy = db.get_group_policy(group_id)
group = db.get_group_by_id(group_id)
if not group:
return "Group not found", 404
return render_template("group_policy.html", policy=policy, group=group)
@app.errorhandler(400)
def bad_request(_):
return "Bad request", 400

View File

@@ -542,3 +542,37 @@ def player_delete(uuid):
return render_template("group_admin/player_delete_confirm.html",
player=player, group=group)
# ─── Group Privacy Policy ─────────────────────────────────────────────────────
@group_admin.route("/privacy-policy", methods=["GET", "POST"])
@group_admin_required
def privacy_policy():
"""Group admins can write and publish their own server privacy policy."""
from roles import OWNER_ONLY_ROLES as _OWNER_ONLY
if session.get("role") not in _OWNER_ONLY:
flash("Only the Group Owner can edit the privacy policy.", "danger")
return redirect(url_for("group_admin.dashboard"))
group_id = session["group_id"]
policy = db.get_group_policy(group_id)
if request.method == "POST":
policy_text = request.form.get("policy_text", "").strip() or None
policy_url = request.form.get("policy_url", "").strip() or None
db.set_group_policy(group_id, policy_text, policy_url)
db.log_audit_event(
session["user_id"], session["username"], "group.policy_updated",
entity_type="group", entity_id=str(group_id),
details={"policy_url": policy_url},
group_id=group_id, ip_address=request.remote_addr,
)
flash("Privacy policy saved.", "success")
return redirect(url_for("group_admin.privacy_policy"))
group = db.get_group_by_id(group_id)
public_url = url_for("public_group_policy", group_id=group_id, _external=True)
return render_template("group_admin/privacy_policy.html",
policy=policy, group=group, public_url=public_url)

View File

@@ -175,6 +175,15 @@ PANEL_MIGRATIONS = [
(8,
"ALTER TABLE users ADD COLUMN IF NOT EXISTS consented_at DATETIME NULL",
"Add users.consented_at for GDPR consent timestamp"),
(9,
"""CREATE TABLE IF NOT EXISTS group_privacy_policy (
group_id INT PRIMARY KEY,
policy_text LONGTEXT,
policy_url VARCHAR(500),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"Add group_privacy_policy table for group-hosted privacy policies"),
]
CREDS_SCHEMA = [
@@ -753,6 +762,32 @@ def set_user_consent(user_id: int, policy_version: str) -> None:
)
# ─────────────────────────────────────────────────────────────
# Group Privacy Policy
# ─────────────────────────────────────────────────────────────
def get_group_policy(group_id: int):
"""Returns the group_privacy_policy row for *group_id*, or None if not set."""
rows = _panel_query(
"SELECT group_id, policy_text, policy_url, updated_at "
"FROM group_privacy_policy WHERE group_id = %s",
(group_id,),
)
return rows[0] if rows else None
def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None:
"""Upserts the privacy policy for a group."""
_panel_query(
"INSERT INTO group_privacy_policy (group_id, policy_text, policy_url) "
"VALUES (%s, %s, %s) "
"ON DUPLICATE KEY UPDATE policy_text = VALUES(policy_text), "
"policy_url = VALUES(policy_url), updated_at = UTC_TIMESTAMP()",
(group_id, policy_text, policy_url),
write=True,
)
# ─────────────────────────────────────────────────────────────
# Audit-Log
# ─────────────────────────────────────────────────────────────

View File

@@ -18,6 +18,7 @@
<a href="{{ url_for('group_admin.dashboard') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.dashboard' }}">Dashboard</a>
<a href="{{ url_for('group_admin.members') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.members' }}">Members</a>
<a href="{{ url_for('group_admin.database') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.database' }}">Database</a>
<a href="{{ url_for('group_admin.privacy_policy') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.privacy_policy' }}">Privacy Policy</a>
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-dark btn-sm">
<i class="bi bi-grid me-1"></i>Panel
</a>

View File

@@ -0,0 +1,74 @@
{% extends "group_admin/base.html" %}
{% block title %}Privacy Policy{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-9">
<h2 class="mb-1"><i class="bi bi-file-earmark-lock2 me-2"></i>Server Privacy Policy</h2>
<p class="text-muted mb-4">
Write your Minecraft server's privacy policy here. Players will be shown this page when
the <strong>MCConsent</strong> plugin asks them to consent before playing.
</p>
{# ── Public URL banner ────────────────────────────────────── #}
<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>
<strong>Public URL</strong> — paste this into your <code>consent-plugin/config.yml</code>
as the <code>policy-url</code> value:<br>
<code id="publicUrl">{{ public_url }}</code>
<button class="btn btn-sm btn-outline-light ms-2" onclick="navigator.clipboard.writeText('{{ public_url }}')">
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
</div>
{# ── Last updated ─────────────────────────────────────────── #}
{% if policy and policy.updated_at %}
<p class="text-muted small">Last updated: {{ policy.updated_at.strftime('%Y-%m-%d %H:%M UTC') }}</p>
{% endif %}
{# ── Editor form ──────────────────────────────────────────── #}
<form method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="policy_url" class="form-label fw-semibold">
Additional / External Policy URL <span class="text-muted fw-normal">(optional)</span>
</label>
<input type="url" class="form-control font-monospace"
id="policy_url" name="policy_url" maxlength="500"
placeholder="https://your-website.example.com/privacy"
value="{{ (policy.policy_url or '') if policy else '' }}">
<div class="form-text">
If you host a full policy on your own website you can link it here. It will be
displayed as a button on the public policy page.
</div>
</div>
<div class="mb-3">
<label for="policy_text" class="form-label fw-semibold">Policy Text</label>
<textarea class="form-control font-monospace" id="policy_text" name="policy_text"
rows="24" placeholder="Enter your privacy policy here…"
style="resize: vertical;">{{ (policy.policy_text or '') if policy else '' }}</textarea>
<div class="form-text">
Plain text or basic Markdown is accepted. HTML is <strong>not</strong> rendered.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">
<i class="bi bi-floppy me-1"></i>Save Policy
</button>
{% if policy %}
<a href="{{ public_url }}" target="_blank" class="btn btn-outline-light">
<i class="bi bi-box-arrow-up-right me-1"></i>Preview public page
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — {{ group.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body class="bg-dark text-light">
<nav class="navbar navbar-dark bg-secondary bg-opacity-25 border-bottom border-secondary mb-4">
<div class="container">
<span class="navbar-brand">
<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>
</div>
</nav>
<div class="container" style="max-width: 860px;">
{% if not policy or not policy.policy_text %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
This server has not yet published a privacy policy.
Please contact the server administrator for more information.
</div>
{% else %}
{% if policy.updated_at %}
<p class="text-muted small mb-4">
<i class="bi bi-clock me-1"></i>
Last updated: {{ policy.updated_at.strftime('%B %d, %Y') }}
</p>
{% endif %}
{% if policy.policy_url %}
<a href="{{ policy.policy_url }}" target="_blank" rel="noopener noreferrer"
class="btn btn-outline-info btn-sm mb-4">
<i class="bi bi-box-arrow-up-right me-1"></i>View full policy on our website
</a>
{% endif %}
<div class="card bg-secondary bg-opacity-10 border-secondary">
<div class="card-body">
<pre class="mb-0 text-light" style="white-space: pre-wrap; word-break: break-word; font-family: inherit;">{{ policy.policy_text }}</pre>
</div>
</div>
{% endif %}
<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>
on behalf of <em>{{ group.name }}</em>.
</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>