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
This commit is contained in:
106
consent-plugin/pom.xml
Normal file
106
consent-plugin/pom.xml
Normal 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>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package de.simolzimol.mclogger.consent;
|
||||||
|
|
||||||
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package de.simolzimol.mclogger.consent;
|
||||||
|
|
||||||
|
import de.simolzimol.mclogger.consent.commands.ConsentCommand;
|
||||||
|
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
|
||||||
|
import de.simolzimol.mclogger.consent.listeners.ConsentListener;
|
||||||
|
import org.bukkit.command.PluginCommand;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package de.simolzimol.mclogger.consent.database;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import de.simolzimol.mclogger.consent.ConsentConfig;
|
||||||
|
import de.simolzimol.mclogger.consent.ConsentPlugin;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package de.simolzimol.mclogger.consent.listeners;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.*;
|
||||||
|
import org.bukkit.event.player.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
191
consent-plugin/src/main/resources/config.yml
Normal file
191
consent-plugin/src/main/resources/config.yml
Normal 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>
|
||||||
28
consent-plugin/src/main/resources/plugin.yml
Normal file
28
consent-plugin/src/main/resources/plugin.yml
Normal 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
|
||||||
10
web/app.py
10
web/app.py
@@ -148,6 +148,16 @@ def create_app() -> Flask:
|
|||||||
policy_version=Config.PRIVACY_POLICY_VERSION,
|
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)
|
@app.errorhandler(400)
|
||||||
def bad_request(_):
|
def bad_request(_):
|
||||||
return "Bad request", 400
|
return "Bad request", 400
|
||||||
|
|||||||
@@ -542,3 +542,37 @@ def player_delete(uuid):
|
|||||||
|
|
||||||
return render_template("group_admin/player_delete_confirm.html",
|
return render_template("group_admin/player_delete_confirm.html",
|
||||||
player=player, group=group)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ PANEL_MIGRATIONS = [
|
|||||||
(8,
|
(8,
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS consented_at DATETIME NULL",
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS consented_at DATETIME NULL",
|
||||||
"Add users.consented_at for GDPR consent timestamp"),
|
"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 = [
|
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
|
# Audit-Log
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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.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.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.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">
|
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-dark btn-sm">
|
||||||
<i class="bi bi-grid me-1"></i>Panel
|
<i class="bi bi-grid me-1"></i>Panel
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
74
web/templates/group_admin/privacy_policy.html
Normal file
74
web/templates/group_admin/privacy_policy.html
Normal 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 %}
|
||||||
64
web/templates/group_policy.html
Normal file
64
web/templates/group_policy.html
Normal 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>
|
||||||
Reference in New Issue
Block a user