Compare commits
8 Commits
aa0544a4a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df8230111 | ||
|
|
408933106b | ||
|
|
c2b645ed58 | ||
|
|
aa8dfdcdbf | ||
|
|
83af428435 | ||
|
|
cd9a46b403 | ||
|
|
2dbd5340a8 | ||
|
|
17a782b487 |
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.17+ (lowest supported; ensures no 1.18+-only APIs are used) -->
|
||||
<dependency>
|
||||
<groupId>io.papermc.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>1.17.1-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,193 @@
|
||||
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>"); }
|
||||
public String getMsgWithdrawn() { return cfg.getString("messages.withdrawn", "<yellow>Your consent has been withdrawn. You will be disconnected.</yellow>"); }
|
||||
public String getMsgWithdrawKick() { return cfg.getString("messages.withdraw-kick-message", "Consent withdrawn.\nYou may reconnect at any time and accept the Privacy Policy.\nVisit: {policy_url}"); }
|
||||
|
||||
/** Whether a message should be sent to online admins when a player accepts. */
|
||||
public boolean isNotifyAdminsOnConsent() {
|
||||
return cfg.getBoolean("admin.notify-admins-on-consent", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days to keep accepted consent records (0 = keep forever).
|
||||
* GDPR Art. 5(1)(e) – storage limitation.
|
||||
*/
|
||||
public int getConsentRetentionDays() {
|
||||
return cfg.getInt("consent.retention-days-consent", 1095); // 3 years default
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days to keep decline records (0 = keep forever).
|
||||
*/
|
||||
public int getDeclineRetentionDays() {
|
||||
return cfg.getInt("consent.retention-days-declines", 1095);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the help text as a list of MiniMessage lines.
|
||||
* Falls back to a built-in list if not configured.
|
||||
*/
|
||||
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>"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When {@code true}, kick messages are forwarded to the Velocity / BungeeCord
|
||||
* proxy via the {@code BungeeCord} plugin-messaging channel so the player is
|
||||
* removed from the entire network rather than just the current server.
|
||||
*/
|
||||
public boolean isVelocityKick() {
|
||||
return cfg.getBoolean("consent.velocity-kick", false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import com.google.common.io.ByteArrayDataOutput;
|
||||
import com.google.common.io.ByteStreams;
|
||||
|
||||
import de.simolzimol.mclogger.consent.commands.ConsentCommand;
|
||||
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
|
||||
import de.simolzimol.mclogger.consent.listeners.ConsentListener;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
|
||||
/**
|
||||
* MCConsent – Privacy Policy consent enforcement for Paper servers.
|
||||
*
|
||||
* <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 plugin-messaging channel for Velocity / BungeeCord network kick
|
||||
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
|
||||
|
||||
// Register event listeners
|
||||
ConsentListener listener = new ConsentListener(this);
|
||||
getServer().getPluginManager().registerEvents(listener, this);
|
||||
|
||||
// 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());
|
||||
|
||||
// GDPR Art. 5(1)(e) – automatic retention purge, runs once every 24 h
|
||||
int consentDays = consentConfig.getConsentRetentionDays();
|
||||
int declineDays = consentConfig.getDeclineRetentionDays();
|
||||
if (consentDays > 0 || declineDays > 0) {
|
||||
long dayTicks = 20L * 60 * 60 * 24;
|
||||
getServer().getScheduler().runTaskTimerAsynchronously(this,
|
||||
() -> consentDatabase.purgeOldConsents(consentDays, declineDays),
|
||||
dayTicks, dayTicks);
|
||||
getLogger().info("[MCConsent] GDPR retention purge scheduled (consent=" + consentDays
|
||||
+ "d, declines=" + declineDays + "d).");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a player from the current server, or from the entire
|
||||
* Velocity / BungeeCord network when {@code velocity-kick: true} is set
|
||||
* in the config. Falls back to a normal kick if the proxy message fails.
|
||||
*/
|
||||
public void networkKick(Player player, Component reason) {
|
||||
if (consentConfig.isVelocityKick()) {
|
||||
try {
|
||||
ByteArrayDataOutput out = ByteStreams.newDataOutput();
|
||||
out.writeUTF("KickPlayer");
|
||||
out.writeUTF(player.getName());
|
||||
out.writeUTF(LegacyComponentSerializer.legacySection().serialize(reason));
|
||||
player.sendPluginMessage(this, "BungeeCord", out.toByteArray());
|
||||
} catch (Exception e) {
|
||||
getLogger().warning("[MCConsent] Velocity/BungeeCord network kick failed: " + e.getMessage());
|
||||
player.kick(reason);
|
||||
return;
|
||||
}
|
||||
// Fallback: if the proxy never kicks, remove the player after 1 s
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (player.isOnline()) player.kick(reason);
|
||||
}, 20L);
|
||||
} else {
|
||||
player.kick(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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 withdraw} – withdraw previously given consent (Art. 7(3) GDPR)</li>
|
||||
* <li>{@code /consent status} – show current consent status</li>
|
||||
* <li>{@code /consent help} – show help text</li>
|
||||
* </ul>
|
||||
*
|
||||
* <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 "withdraw" -> handleWithdraw(sender, cfg);
|
||||
case "status" -> handleStatus(sender, cfg);
|
||||
case "help" -> sendHelp(sender, cfg);
|
||||
case "admin" -> {
|
||||
if (!sender.hasPermission("mclogger.consent.admin")) {
|
||||
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
|
||||
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);
|
||||
plugin.networkKick(player, MM.deserialize(msg));
|
||||
}
|
||||
|
||||
private void handleWithdraw(CommandSender sender, ConsentConfig cfg) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
return;
|
||||
}
|
||||
|
||||
String uuid = player.getUniqueId().toString();
|
||||
String version = cfg.getPolicyVersion();
|
||||
|
||||
// Revoke all versions so the player must re-accept next time
|
||||
plugin.getConsentDatabase().revokeConsent(uuid, null);
|
||||
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||
|
||||
// Record the withdrawal in the decline table as an audit trail
|
||||
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version + "_WITHDRAWN",
|
||||
player.getAddress() != null ? player.getAddress().getAddress().getHostAddress() : "unknown");
|
||||
|
||||
String withdrawMsg = MessageUtil.replace(cfg.getMsgWithdrawn(), player, cfg);
|
||||
player.sendMessage(MM.deserialize(withdrawMsg));
|
||||
|
||||
// Kick using the configured kick message
|
||||
String kickMsg = MessageUtil.replace(cfg.getMsgWithdrawKick(), player, cfg);
|
||||
plugin.networkKick(player, MM.deserialize(kickMsg));
|
||||
}
|
||||
|
||||
private void handleStatus(CommandSender sender, ConsentConfig cfg) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
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", "withdraw", "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,380 @@
|
||||
package de.simolzimol.mclogger.consent.database;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
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.
|
||||
// ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
|
||||
// policy_hash is the SHA-256 of the policy_version string for accountability.
|
||||
stmt.execute(
|
||||
"CREATE TABLE IF NOT EXISTS player_consent (" +
|
||||
" uuid VARCHAR(36) NOT NULL," +
|
||||
" username VARCHAR(16) NOT NULL," +
|
||||
" policy_version VARCHAR(64) NOT NULL," +
|
||||
" policy_hash VARCHAR(64) NULL," +
|
||||
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
|
||||
" ip_address VARCHAR(45) NULL," +
|
||||
" PRIMARY KEY (uuid, policy_version)," +
|
||||
" INDEX idx_consent_uuid (uuid)," +
|
||||
" INDEX idx_consent_version (policy_version)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
// Migrate existing tables: add policy_hash if not present
|
||||
stmt.execute(
|
||||
"ALTER TABLE player_consent " +
|
||||
"ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");
|
||||
|
||||
// Decline audit trail
|
||||
stmt.execute(
|
||||
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
|
||||
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
||||
" 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.
|
||||
*
|
||||
* <p>The IP address is pseudonymized before storage (Art. 5(1)(c) GDPR):
|
||||
* last octet zeroed for IPv4, last 80 bits zeroed for IPv6.
|
||||
* A SHA-256 hash of the policy version string is stored for accountability
|
||||
* (Art. 5(2) GDPR).
|
||||
*/
|
||||
public void recordConsent(String uuid, String username, String policyVersion, String ip) {
|
||||
final String sql =
|
||||
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
|
||||
" VALUES (?, ?, ?, ?, ?)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, uuid);
|
||||
ps.setString(2, username);
|
||||
ps.setString(3, policyVersion);
|
||||
ps.setString(4, computePolicyHash(policyVersion));
|
||||
ps.setString(5, pseudonymizeIp(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; their IP is pseudonymized.
|
||||
*/
|
||||
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, pseudonymizeIp(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
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// GDPR – Art. 5(1)(e) Storage limitation
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deletes consent and decline records older than the configured retention
|
||||
* periods. Pass 0 to skip deletion for that table.
|
||||
*
|
||||
* <p>Intended to be called from an async scheduled task once per day.
|
||||
*/
|
||||
public void purgeOldConsents(int consentRetentionDays, int declineRetentionDays) {
|
||||
if (consentRetentionDays > 0) {
|
||||
final String sql =
|
||||
"DELETE FROM player_consent " +
|
||||
"WHERE consented_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setInt(1, consentRetentionDays);
|
||||
int rows = ps.executeUpdate();
|
||||
if (rows > 0) {
|
||||
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
|
||||
+ " consent record(s) older than " + consentRetentionDays + " days.");
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() consent failed", e);
|
||||
}
|
||||
}
|
||||
if (declineRetentionDays > 0) {
|
||||
final String sql =
|
||||
"DELETE FROM player_consent_declines " +
|
||||
"WHERE declined_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setInt(1, declineRetentionDays);
|
||||
int rows = ps.executeUpdate();
|
||||
if (rows > 0) {
|
||||
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
|
||||
+ " decline record(s) older than " + declineRetentionDays + " days.");
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() declines failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Privacy helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pseudonymizes an IP address for storage (Art. 5(1)(c) GDPR – data minimisation).
|
||||
* <ul>
|
||||
* <li>IPv4: last octet set to 0 (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})</li>
|
||||
* <li>IPv6: last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)</li>
|
||||
* </ul>
|
||||
* Returns {@code "unknown"} if the address cannot be parsed.
|
||||
*/
|
||||
static String pseudonymizeIp(String ip) {
|
||||
if (ip == null || ip.isBlank() || ip.equals("unknown") || ip.equals("admin-forced")) {
|
||||
return ip;
|
||||
}
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(ip);
|
||||
byte[] bytes = addr.getAddress();
|
||||
if (bytes.length == 4) {
|
||||
// IPv4 – zero last octet
|
||||
bytes[3] = 0;
|
||||
} else if (bytes.length == 16) {
|
||||
// IPv6 – zero last 10 bytes (80 bits)
|
||||
for (int i = 6; i < 16; i++) bytes[i] = 0;
|
||||
}
|
||||
return InetAddress.getByAddress(bytes).getHostAddress();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a SHA-256 hex digest of the policy version string.
|
||||
* Stored alongside each consent record so the exact version accepted can
|
||||
* be verified later (Art. 5(2) GDPR – accountability).
|
||||
*/
|
||||
static String computePolicyHash(String policyVersion) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(policyVersion.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
|
||||
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
|
||||
|
||||
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,345 @@
|
||||
package de.simolzimol.mclogger.consent.listeners;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.player.AsyncPlayerChatEvent;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerLoginEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig;
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
|
||||
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();
|
||||
|
||||
// Open the policy book 1 tick after join (openBook can't be called during join)
|
||||
String promptMini = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
if (!player.isOnline()) return;
|
||||
openPolicyBook(player, promptMini);
|
||||
}, 1L);
|
||||
|
||||
if (mode == EnforcementMode.HOLD) {
|
||||
plugin.getPendingConsent().add(player.getUniqueId());
|
||||
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));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// HOLD-mode: block block-break / place / interact
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onBlockPlace(BlockPlaceEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a written-book GUI with the policy prompt so the player can read
|
||||
* the full text including a clickable URL, without cluttering chat.
|
||||
* A short plain-chat hint is also sent so players know why the book opened.
|
||||
*/
|
||||
private void openPolicyBook(Player player, String promptMini) {
|
||||
MiniMessage mm = MiniMessage.miniMessage();
|
||||
|
||||
ItemStack book = new ItemStack(Material.WRITTEN_BOOK);
|
||||
BookMeta meta = (BookMeta) Objects.requireNonNull(book.getItemMeta());
|
||||
meta.title(mm.deserialize("<bold><blue>Privacy Policy</blue></bold>"));
|
||||
meta.author(Component.text("MCLogger"));
|
||||
|
||||
// First page: the configured join-prompt text (supports MiniMessage incl. click URLs)
|
||||
Component page1 = mm.deserialize(promptMini);
|
||||
meta.pages(List.of(page1));
|
||||
|
||||
book.setItemMeta(meta);
|
||||
player.openBook(book);
|
||||
|
||||
// Also send a brief chat message so the book title isn't the only indicator
|
||||
String hint = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgJoinPrompt(), player, plugin.getConsentConfig());
|
||||
player.sendMessage(mm.deserialize(hint));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a player from the server or – if velocity-kick is enabled – from
|
||||
* the entire network. Delegates to {@link ConsentPlugin#networkKick}.
|
||||
*/
|
||||
void doKick(Player player, Component reason) {
|
||||
plugin.networkKick(player, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MiniMessage-formatted message to the player with a cooldown to
|
||||
* avoid flooding the chat (e.g. when the player spams movement).
|
||||
*/
|
||||
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());
|
||||
doKick(p, 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,34 @@
|
||||
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 {url}} – configured policy URL</li>
|
||||
* <li>{@code {policy_url}} – configured policy URL (alias for {url})</li>
|
||||
* <li>{@code {command}} – consent command name (e.g. "consent")</li>
|
||||
* <li>{@code {version}} – policy version string</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static String replace(String template, Player player, ConsentConfig cfg) {
|
||||
if (template == null) return "";
|
||||
return template
|
||||
.replace("{player}", player.getName())
|
||||
.replace("{url}", cfg.getPolicyUrl())
|
||||
.replace("{policy_url}", cfg.getPolicyUrl())
|
||||
.replace("{command}", cfg.getCommand())
|
||||
.replace("{version}", cfg.getPolicyVersion());
|
||||
}
|
||||
}
|
||||
222
consent-plugin/src/main/resources/config.yml
Normal file
222
consent-plugin/src/main/resources/config.yml
Normal file
@@ -0,0 +1,222 @@
|
||||
# ============================================================
|
||||
# MCConsent – Paper Plugin Configuration
|
||||
# Author: SimolZimol
|
||||
# Website: https://log.devanturas.net
|
||||
# ============================================================
|
||||
|
||||
# ── 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"
|
||||
|
||||
# ── Velocity / BungeeCord network kick ──────────────────────
|
||||
# Set to true if this server runs behind a Velocity or BungeeCord
|
||||
# proxy. When enabled, kick messages are forwarded via the
|
||||
# BungeeCord plugin-messaging channel so the player is removed
|
||||
# from the entire network instead of just this server.
|
||||
# Requires the proxy to have the plugin-messaging channel enabled.
|
||||
velocity-kick: false
|
||||
|
||||
# ── Grace period (HOLD mode only) ───────────────────────────
|
||||
# How many seconds a player has to accept before being kicked.
|
||||
# Set to 0 to wait forever (player can take as long as they like).
|
||||
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"
|
||||
# ── GDPR – Aufbewahrungsfristen (Art. 5(1)(e)) ─────────────────
|
||||
# Number of days to keep accepted consent records.
|
||||
# After this period data is automatically deleted by the plugin.
|
||||
# Set to 0 to keep records indefinitely (not recommended for GDPR).
|
||||
retention-days-consent: 1095 # 3 years
|
||||
|
||||
# Number of days to keep decline records.
|
||||
retention-days-declines: 1095 # 3 years
|
||||
# ── Admin settings ───────────────────────────────────────────
|
||||
admin:
|
||||
# Log consent accept/decline events to the server_events table in
|
||||
# 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.
|
||||
# This text must inform the player what data is being collected
|
||||
# and for what purpose (Art. 13 GDPR).
|
||||
join-prompt: |
|
||||
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
|
||||
<gray>Before you can play you must read and accept our Privacy Policy.</gray>
|
||||
<dark_gray>We collect: chat messages, commands, login/logout times, IP address
|
||||
(pseudonymized), and player position – for server moderation purposes.</dark_gray>
|
||||
<aqua><click:open_url:'{url}'><hover:show_text:'<gray>Click to open in browser</gray>'>📄 {url}</hover></click></aqua>
|
||||
<green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
|
||||
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
|
||||
|
||||
# ── 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>
|
||||
# ── Consent withdrawn (Art. 7(3) GDPR) ───────────────────────
|
||||
# Shown in chat just before the player is kicked after /consent withdraw.
|
||||
withdrawn: "<yellow>Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time.</yellow>"
|
||||
|
||||
# Disconnect screen message shown after /consent withdraw.
|
||||
withdraw-kick-message: |
|
||||
Consent Withdrawn
|
||||
|
||||
You have successfully withdrawn your consent.
|
||||
Your data will be handled according to the Privacy Policy.
|
||||
You may reconnect at any time and accept the Privacy Policy.
|
||||
Visit: {url}
|
||||
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.17'
|
||||
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
|
||||
15
web/app.py
15
web/app.py
@@ -148,6 +148,21 @@ def create_app() -> Flask:
|
||||
policy_version=Config.PRIVACY_POLICY_VERSION,
|
||||
)
|
||||
|
||||
@app.route("/policy/<token>")
|
||||
def public_group_policy(token):
|
||||
"""Public, unauthenticated URL for a group's server privacy policy.
|
||||
|
||||
The token is an opaque UUID so the group ID is never exposed.
|
||||
"""
|
||||
import panel_db as db
|
||||
row = db.get_group_policy_by_token(token)
|
||||
if not row:
|
||||
return render_template("404.html"), 404
|
||||
# Build lightweight dicts that the template expects
|
||||
policy = row
|
||||
group = {"name": row["group_name"]}
|
||||
return render_template("group_policy.html", policy=policy, group=group)
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request(_):
|
||||
return "Bad request", 400
|
||||
|
||||
@@ -542,3 +542,38 @@ 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)
|
||||
token = policy["public_token"] if policy else None
|
||||
public_url = url_for("public_group_policy", token=token, _external=True) if token else None
|
||||
return render_template("group_admin/privacy_policy.html",
|
||||
policy=policy, group=group, public_url=public_url)
|
||||
|
||||
|
||||
@@ -175,6 +175,24 @@ 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),
|
||||
public_token CHAR(36) NULL UNIQUE,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
"Add group_privacy_policy table for group-hosted privacy policies"),
|
||||
(10,
|
||||
"ALTER TABLE group_privacy_policy ADD COLUMN IF NOT EXISTS "
|
||||
"public_token CHAR(36) NULL",
|
||||
"Add group_privacy_policy.public_token for opaque public URL"),
|
||||
(11,
|
||||
"ALTER TABLE group_privacy_policy ADD CONSTRAINT IF NOT EXISTS "
|
||||
"uq_gpp_public_token UNIQUE (public_token)",
|
||||
"Unique index on group_privacy_policy.public_token"),
|
||||
]
|
||||
|
||||
CREDS_SCHEMA = [
|
||||
@@ -753,6 +771,52 @@ 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, public_token, updated_at "
|
||||
"FROM group_privacy_policy WHERE group_id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def get_group_policy_by_token(token: str):
|
||||
"""Returns the policy row and group for the given opaque public_token, or None."""
|
||||
rows = _panel_query(
|
||||
"SELECT p.group_id, p.policy_text, p.policy_url, p.public_token, p.updated_at, "
|
||||
"g.name AS group_name "
|
||||
"FROM group_privacy_policy p "
|
||||
"JOIN user_groups g ON g.id = p.group_id "
|
||||
"WHERE p.public_token = %s",
|
||||
(token,),
|
||||
)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None:
|
||||
"""Upserts the privacy policy for a group.
|
||||
|
||||
The *public_token* (opaque UUID) is generated once on first INSERT and
|
||||
never changed on subsequent updates, so existing URLs stay valid.
|
||||
"""
|
||||
_panel_query(
|
||||
"INSERT INTO group_privacy_policy (group_id, policy_text, policy_url, public_token) "
|
||||
"VALUES (%s, %s, %s, UUID()) "
|
||||
"ON DUPLICATE KEY UPDATE "
|
||||
"policy_text = VALUES(policy_text), "
|
||||
"policy_url = VALUES(policy_url), "
|
||||
"public_token = COALESCE(public_token, VALUES(public_token)), "
|
||||
"updated_at = UTC_TIMESTAMP()",
|
||||
(group_id, policy_text, policy_url),
|
||||
write=True,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Audit-Log
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<footer class="text-center py-2 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -45,7 +46,7 @@
|
||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
81
web/templates/group_admin/privacy_policy.html
Normal file
81
web/templates/group_admin/privacy_policy.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% 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 ────────────────────────────────────── #}
|
||||
{% if public_url %}
|
||||
<div class="alert alert-info d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-link-45deg fs-5 flex-shrink-0"></i>
|
||||
<div>
|
||||
<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>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Save the policy once to generate a secret public URL.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Last updated ─────────────────────────────────────────── #}
|
||||
{% if policy and policy.updated_at %}
|
||||
<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 <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">MCLogger</a> — Made by Devanturas</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://log.devanturas.net" class="text-secondary" target="_blank" rel="noopener noreferrer">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>
|
||||
@@ -238,7 +238,7 @@
|
||||
<a href="{{ url_for('auth.login') }}" class="text-muted me-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
||||
</a>
|
||||
MCLogger — <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a>
|
||||
MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">Devanturas</a> — <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user