Compare commits
6 Commits
2dbd5340a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df8230111 | ||
|
|
408933106b | ||
|
|
c2b645ed58 | ||
|
|
aa8dfdcdbf | ||
|
|
83af428435 | ||
|
|
cd9a46b403 |
@@ -27,11 +27,11 @@
|
|||||||
</repositories>
|
</repositories>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- Paper API 1.21 -->
|
<!-- Paper API 1.17+ (lowest supported; ensures no 1.18+-only APIs are used) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.papermc.paper</groupId>
|
<groupId>io.papermc.paper</groupId>
|
||||||
<artifactId>paper-api</artifactId>
|
<artifactId>paper-api</artifactId>
|
||||||
<version>1.21-R0.1-SNAPSHOT</version>
|
<version>1.17.1-R0.1-SNAPSHOT</version>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|||||||
@@ -143,12 +143,29 @@ public class ConsentConfig {
|
|||||||
public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "<yellow>You have NOT yet accepted the Privacy Policy (v{version}).</yellow>"); }
|
public String 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 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 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. */
|
/** Whether a message should be sent to online admins when a player accepts. */
|
||||||
public boolean isNotifyAdminsOnConsent() {
|
public boolean isNotifyAdminsOnConsent() {
|
||||||
return cfg.getBoolean("admin.notify-admins-on-consent", false);
|
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.
|
* Returns the help text as a list of MiniMessage lines.
|
||||||
* Falls back to a built-in list if not configured.
|
* Falls back to a built-in list if not configured.
|
||||||
@@ -164,4 +181,13 @@ public class ConsentConfig {
|
|||||||
"<gray>Policy: <aqua>{policy_url}</aqua>"
|
"<gray>Policy: <aqua>{policy_url}</aqua>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When {@code true}, kick messages are forwarded to the Velocity / BungeeCord
|
||||||
|
* proxy via the {@code BungeeCord} plugin-messaging channel so the player is
|
||||||
|
* removed from the entire network rather than just the current server.
|
||||||
|
*/
|
||||||
|
public boolean isVelocityKick() {
|
||||||
|
return cfg.getBoolean("consent.velocity-kick", false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ import java.util.UUID;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import org.bukkit.command.PluginCommand;
|
import org.bukkit.command.PluginCommand;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
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.commands.ConsentCommand;
|
||||||
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
|
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
|
||||||
import de.simolzimol.mclogger.consent.listeners.ConsentListener;
|
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.
|
* MCConsent – Privacy Policy consent enforcement for Paper servers.
|
||||||
@@ -59,6 +65,9 @@ public class ConsentPlugin extends JavaPlugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register plugin-messaging channel for Velocity / BungeeCord network kick
|
||||||
|
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
|
||||||
|
|
||||||
// Register event listeners
|
// Register event listeners
|
||||||
ConsentListener listener = new ConsentListener(this);
|
ConsentListener listener = new ConsentListener(this);
|
||||||
getServer().getPluginManager().registerEvents(listener, this);
|
getServer().getPluginManager().registerEvents(listener, this);
|
||||||
@@ -75,6 +84,18 @@ public class ConsentPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
|
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
|
||||||
+ " PolicyVersion=" + consentConfig.getPolicyVersion());
|
+ " PolicyVersion=" + consentConfig.getPolicyVersion());
|
||||||
|
|
||||||
|
// GDPR Art. 5(1)(e) – automatic retention purge, runs once every 24 h
|
||||||
|
int consentDays = consentConfig.getConsentRetentionDays();
|
||||||
|
int declineDays = consentConfig.getDeclineRetentionDays();
|
||||||
|
if (consentDays > 0 || declineDays > 0) {
|
||||||
|
long dayTicks = 20L * 60 * 60 * 24;
|
||||||
|
getServer().getScheduler().runTaskTimerAsynchronously(this,
|
||||||
|
() -> consentDatabase.purgeOldConsents(consentDays, declineDays),
|
||||||
|
dayTicks, dayTicks);
|
||||||
|
getLogger().info("[MCConsent] GDPR retention purge scheduled (consent=" + consentDays
|
||||||
|
+ "d, declines=" + declineDays + "d).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -107,4 +128,31 @@ public class ConsentPlugin extends JavaPlugin {
|
|||||||
public Set<UUID> getPendingConsent() {
|
public Set<UUID> getPendingConsent() {
|
||||||
return pendingConsent;
|
return pendingConsent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kicks a player from the current server, or from the entire
|
||||||
|
* Velocity / BungeeCord network when {@code velocity-kick: true} is set
|
||||||
|
* in the config. Falls back to a normal kick if the proxy message fails.
|
||||||
|
*/
|
||||||
|
public void networkKick(Player player, Component reason) {
|
||||||
|
if (consentConfig.isVelocityKick()) {
|
||||||
|
try {
|
||||||
|
ByteArrayDataOutput out = ByteStreams.newDataOutput();
|
||||||
|
out.writeUTF("KickPlayer");
|
||||||
|
out.writeUTF(player.getName());
|
||||||
|
out.writeUTF(LegacyComponentSerializer.legacySection().serialize(reason));
|
||||||
|
player.sendPluginMessage(this, "BungeeCord", out.toByteArray());
|
||||||
|
} catch (Exception e) {
|
||||||
|
getLogger().warning("[MCConsent] Velocity/BungeeCord network kick failed: " + e.getMessage());
|
||||||
|
player.kick(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: if the proxy never kicks, remove the player after 1 s
|
||||||
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
|
if (player.isOnline()) player.kick(reason);
|
||||||
|
}, 20L);
|
||||||
|
} else {
|
||||||
|
player.kick(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.util.*;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code /consent accept} – record consent for the current policy version</li>
|
* <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 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 status} – show current consent status</li>
|
||||||
* <li>{@code /consent help} – show help text</li>
|
* <li>{@code /consent help} – show help text</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -54,10 +55,11 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (args[0].toLowerCase(Locale.ROOT)) {
|
switch (args[0].toLowerCase(Locale.ROOT)) {
|
||||||
case "accept" -> handleAccept(sender, cfg);
|
case "accept" -> handleAccept(sender, cfg);
|
||||||
case "decline" -> handleDecline(sender, cfg);
|
case "decline" -> handleDecline(sender, cfg);
|
||||||
case "status" -> handleStatus(sender, cfg);
|
case "withdraw" -> handleWithdraw(sender, cfg);
|
||||||
case "help" -> sendHelp(sender, cfg);
|
case "status" -> handleStatus(sender, cfg);
|
||||||
|
case "help" -> sendHelp(sender, cfg);
|
||||||
case "admin" -> {
|
case "admin" -> {
|
||||||
if (!sender.hasPermission("mclogger.consent.admin")) {
|
if (!sender.hasPermission("mclogger.consent.admin")) {
|
||||||
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
|
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
|
||||||
@@ -113,7 +115,32 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
|
|||||||
plugin.getPendingConsent().remove(player.getUniqueId());
|
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||||
|
|
||||||
String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg);
|
String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg);
|
||||||
player.kick(MM.deserialize(msg));
|
plugin.networkKick(player, MM.deserialize(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleWithdraw(CommandSender sender, ConsentConfig cfg) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String uuid = player.getUniqueId().toString();
|
||||||
|
String version = cfg.getPolicyVersion();
|
||||||
|
|
||||||
|
// Revoke all versions so the player must re-accept next time
|
||||||
|
plugin.getConsentDatabase().revokeConsent(uuid, null);
|
||||||
|
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||||
|
|
||||||
|
// Record the withdrawal in the decline table as an audit trail
|
||||||
|
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version + "_WITHDRAWN",
|
||||||
|
player.getAddress() != null ? player.getAddress().getAddress().getHostAddress() : "unknown");
|
||||||
|
|
||||||
|
String withdrawMsg = MessageUtil.replace(cfg.getMsgWithdrawn(), player, cfg);
|
||||||
|
player.sendMessage(MM.deserialize(withdrawMsg));
|
||||||
|
|
||||||
|
// Kick using the configured kick message
|
||||||
|
String kickMsg = MessageUtil.replace(cfg.getMsgWithdrawKick(), player, cfg);
|
||||||
|
plugin.networkKick(player, MM.deserialize(kickMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleStatus(CommandSender sender, ConsentConfig cfg) {
|
private void handleStatus(CommandSender sender, ConsentConfig cfg) {
|
||||||
@@ -225,7 +252,7 @@ public class ConsentCommand implements CommandExecutor, TabCompleter {
|
|||||||
@Override
|
@Override
|
||||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||||
if (args.length == 1) {
|
if (args.length == 1) {
|
||||||
List<String> base = new ArrayList<>(List.of("accept", "decline", "status", "help"));
|
List<String> base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help"));
|
||||||
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
|
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
|
||||||
return filter(base, args[0]);
|
return filter(base, args[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.simolzimol.mclogger.consent.database;
|
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.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
@@ -92,12 +96,15 @@ public class ConsentDatabase {
|
|||||||
try (Connection conn = dataSource.getConnection();
|
try (Connection conn = dataSource.getConnection();
|
||||||
Statement stmt = conn.createStatement()) {
|
Statement stmt = conn.createStatement()) {
|
||||||
|
|
||||||
// Accepted consents - primary record of who agreed to what version
|
// Accepted consents – primary record of who agreed to what version.
|
||||||
|
// ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
|
||||||
|
// policy_hash is the SHA-256 of the policy_version string for accountability.
|
||||||
stmt.execute(
|
stmt.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS player_consent (" +
|
"CREATE TABLE IF NOT EXISTS player_consent (" +
|
||||||
" uuid VARCHAR(36) NOT NULL," +
|
" uuid VARCHAR(36) NOT NULL," +
|
||||||
" username VARCHAR(16) NOT NULL," +
|
" username VARCHAR(16) NOT NULL," +
|
||||||
" policy_version VARCHAR(64) NOT NULL," +
|
" policy_version VARCHAR(64) NOT NULL," +
|
||||||
|
" policy_hash VARCHAR(64) NULL," +
|
||||||
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
|
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
|
||||||
" ip_address VARCHAR(45) NULL," +
|
" ip_address VARCHAR(45) NULL," +
|
||||||
" PRIMARY KEY (uuid, policy_version)," +
|
" PRIMARY KEY (uuid, policy_version)," +
|
||||||
@@ -105,7 +112,12 @@ public class ConsentDatabase {
|
|||||||
" INDEX idx_consent_version (policy_version)" +
|
" INDEX idx_consent_version (policy_version)" +
|
||||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
// Decline audit trail - never deleted (important for GDPR accountability)
|
// Migrate existing tables: add policy_hash if not present
|
||||||
|
stmt.execute(
|
||||||
|
"ALTER TABLE player_consent " +
|
||||||
|
"ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");
|
||||||
|
|
||||||
|
// Decline audit trail
|
||||||
stmt.execute(
|
stmt.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
|
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
|
||||||
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
||||||
@@ -148,17 +160,23 @@ public class ConsentDatabase {
|
|||||||
/**
|
/**
|
||||||
* Records that the player has accepted the current policy version.
|
* Records that the player has accepted the current policy version.
|
||||||
* Uses {@code INSERT IGNORE} so duplicate calls are safe.
|
* 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) {
|
public void recordConsent(String uuid, String username, String policyVersion, String ip) {
|
||||||
final String sql =
|
final String sql =
|
||||||
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, ip_address)" +
|
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
|
||||||
" VALUES (?, ?, ?, ?)";
|
" VALUES (?, ?, ?, ?, ?)";
|
||||||
try (Connection conn = dataSource.getConnection();
|
try (Connection conn = dataSource.getConnection();
|
||||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
ps.setString(1, uuid);
|
ps.setString(1, uuid);
|
||||||
ps.setString(2, username);
|
ps.setString(2, username);
|
||||||
ps.setString(3, policyVersion);
|
ps.setString(3, policyVersion);
|
||||||
ps.setString(4, ip);
|
ps.setString(4, computePolicyHash(policyVersion));
|
||||||
|
ps.setString(5, pseudonymizeIp(ip));
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
|
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
|
||||||
@@ -167,7 +185,7 @@ public class ConsentDatabase {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Records that the player declined the current policy version.
|
* Records that the player declined the current policy version.
|
||||||
* Declines are appended to an audit table and are never deleted.
|
* Declines are appended to an audit table; their IP is pseudonymized.
|
||||||
*/
|
*/
|
||||||
public void recordDecline(String uuid, String username, String policyVersion, String ip) {
|
public void recordDecline(String uuid, String username, String policyVersion, String ip) {
|
||||||
final String sql =
|
final String sql =
|
||||||
@@ -178,7 +196,7 @@ public class ConsentDatabase {
|
|||||||
ps.setString(1, uuid);
|
ps.setString(1, uuid);
|
||||||
ps.setString(2, username);
|
ps.setString(2, username);
|
||||||
ps.setString(3, policyVersion);
|
ps.setString(3, policyVersion);
|
||||||
ps.setString(4, ip);
|
ps.setString(4, pseudonymizeIp(ip));
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
|
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
|
||||||
@@ -243,6 +261,100 @@ public class ConsentDatabase {
|
|||||||
* @param uuid Player UUID string
|
* @param uuid Player UUID string
|
||||||
* @param version Policy version 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) {
|
public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
|
||||||
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
|
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
package de.simolzimol.mclogger.consent.listeners;
|
package de.simolzimol.mclogger.consent.listeners;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
import org.bukkit.event.EventPriority;
|
import org.bukkit.event.EventPriority;
|
||||||
import org.bukkit.event.Listener;
|
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.AsyncPlayerChatEvent;
|
||||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
import org.bukkit.event.player.PlayerJoinEvent;
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
import org.bukkit.event.player.PlayerLoginEvent;
|
import org.bukkit.event.player.PlayerLoginEvent;
|
||||||
import org.bukkit.event.player.PlayerMoveEvent;
|
import org.bukkit.event.player.PlayerMoveEvent;
|
||||||
import org.bukkit.event.player.PlayerQuitEvent;
|
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;
|
||||||
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
|
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
|
||||||
@@ -93,9 +102,12 @@ public class ConsentListener implements Listener {
|
|||||||
|
|
||||||
EnforcementMode mode = cfg.getEnforcementMode();
|
EnforcementMode mode = cfg.getEnforcementMode();
|
||||||
|
|
||||||
// Send the join prompt
|
// Open the policy book 1 tick after join (openBook can't be called during join)
|
||||||
String prompt = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
|
String promptMini = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
|
||||||
player.sendMessage(MiniMessage.miniMessage().deserialize(prompt));
|
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||||
|
if (!player.isOnline()) return;
|
||||||
|
openPolicyBook(player, promptMini);
|
||||||
|
}, 1L);
|
||||||
|
|
||||||
if (mode == EnforcementMode.HOLD) {
|
if (mode == EnforcementMode.HOLD) {
|
||||||
plugin.getPendingConsent().add(player.getUniqueId());
|
plugin.getPendingConsent().add(player.getUniqueId());
|
||||||
@@ -177,6 +189,28 @@ public class ConsentListener implements Listener {
|
|||||||
event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg));
|
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
|
// Internal helpers
|
||||||
// ─────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────
|
||||||
@@ -193,6 +227,40 @@ public class ConsentListener implements Listener {
|
|||||||
return cfg.getExemptWorlds().contains(worldName);
|
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
|
* Sends a MiniMessage-formatted message to the player with a cooldown to
|
||||||
* avoid flooding the chat (e.g. when the player spams movement).
|
* avoid flooding the chat (e.g. when the player spams movement).
|
||||||
@@ -241,7 +309,7 @@ public class ConsentListener implements Listener {
|
|||||||
plugin.getPendingConsent().remove(uuid);
|
plugin.getPendingConsent().remove(uuid);
|
||||||
String kickMsg = MessageUtil.replace(
|
String kickMsg = MessageUtil.replace(
|
||||||
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
|
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
|
||||||
p.kick(MiniMessage.miniMessage().deserialize(kickMsg));
|
doKick(p, MiniMessage.miniMessage().deserialize(kickMsg));
|
||||||
}, (long) graceSec * 20);
|
}, (long) graceSec * 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ public final class MessageUtil {
|
|||||||
* <p>Supported placeholders:
|
* <p>Supported placeholders:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code {player}} – player's display name</li>
|
* <li>{@code {player}} – player's display name</li>
|
||||||
* <li>{@code {policy_url}} – configured policy URL</li>
|
* <li>{@code {url}} – configured policy URL</li>
|
||||||
|
* <li>{@code {policy_url}} – configured policy URL (alias for {url})</li>
|
||||||
|
* <li>{@code {command}} – consent command name (e.g. "consent")</li>
|
||||||
* <li>{@code {version}} – policy version string</li>
|
* <li>{@code {version}} – policy version string</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@@ -24,7 +26,9 @@ public final class MessageUtil {
|
|||||||
if (template == null) return "";
|
if (template == null) return "";
|
||||||
return template
|
return template
|
||||||
.replace("{player}", player.getName())
|
.replace("{player}", player.getName())
|
||||||
|
.replace("{url}", cfg.getPolicyUrl())
|
||||||
.replace("{policy_url}", cfg.getPolicyUrl())
|
.replace("{policy_url}", cfg.getPolicyUrl())
|
||||||
|
.replace("{command}", cfg.getCommand())
|
||||||
.replace("{version}", cfg.getPolicyVersion());
|
.replace("{version}", cfg.getPolicyVersion());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# MCConsent – Paper Plugin Configuration
|
# MCConsent – Paper Plugin Configuration
|
||||||
# Author: SimolZimol
|
# Author: SimolZimol
|
||||||
# Docs: https://github.com/SimolZimol/MCLogger
|
# Website: https://log.devanturas.net
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# ── Global switch ────────────────────────────────────────────
|
# ── Global switch ────────────────────────────────────────────
|
||||||
@@ -55,6 +55,14 @@ consent:
|
|||||||
# in plugin.yml with aliases /privacy and /pp).
|
# in plugin.yml with aliases /privacy and /pp).
|
||||||
command: "consent"
|
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) ───────────────────────────
|
# ── Grace period (HOLD mode only) ───────────────────────────
|
||||||
# How many seconds a player has to accept before being kicked.
|
# 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).
|
# Set to 0 to wait forever (player can take as long as they like).
|
||||||
@@ -81,7 +89,14 @@ consent:
|
|||||||
# Players with this permission bypass all consent checks.
|
# Players with this permission bypass all consent checks.
|
||||||
# Default: consent.bypass (assigned to ops by default)
|
# Default: consent.bypass (assigned to ops by default)
|
||||||
bypass-permission: "consent.bypass"
|
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 settings ───────────────────────────────────────────
|
||||||
admin:
|
admin:
|
||||||
# Log consent accept/decline events to the server_events table in
|
# Log consent accept/decline events to the server_events table in
|
||||||
@@ -117,9 +132,13 @@ messages:
|
|||||||
|
|
||||||
# ── Join prompt ─────────────────────────────────────────────
|
# ── Join prompt ─────────────────────────────────────────────
|
||||||
# Sent to a player when they join and consent is required.
|
# 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: |
|
join-prompt: |
|
||||||
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
|
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
|
||||||
<gray>Before you can play you must read and accept our Privacy Policy.</gray>
|
<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>
|
<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>
|
<green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
|
||||||
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
|
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
|
||||||
@@ -189,3 +208,15 @@ messages:
|
|||||||
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
|
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
|
||||||
<aqua><click:open_url:'{url}'>{url}</click></aqua>
|
<aqua><click:open_url:'{url}'>{url}</click></aqua>
|
||||||
<green>Type /<bold>{command} accept</bold> to accept.</green>
|
<green>Type /<bold>{command} accept</bold> to accept.</green>
|
||||||
|
# ── Consent withdrawn (Art. 7(3) GDPR) ───────────────────────
|
||||||
|
# Shown in chat just before the player is kicked after /consent withdraw.
|
||||||
|
withdrawn: "<yellow>Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time.</yellow>"
|
||||||
|
|
||||||
|
# Disconnect screen message shown after /consent withdraw.
|
||||||
|
withdraw-kick-message: |
|
||||||
|
Consent Withdrawn
|
||||||
|
|
||||||
|
You have successfully withdrawn your consent.
|
||||||
|
Your data will be handled according to the Privacy Policy.
|
||||||
|
You may reconnect at any time and accept the Privacy Policy.
|
||||||
|
Visit: {url}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: MCConsent
|
name: MCConsent
|
||||||
version: '1.0.0'
|
version: '1.0.0'
|
||||||
main: de.simolzimol.mclogger.consent.ConsentPlugin
|
main: de.simolzimol.mclogger.consent.ConsentPlugin
|
||||||
api-version: '1.20'
|
api-version: '1.17'
|
||||||
description: Privacy Policy consent enforcement for Minecraft servers
|
description: Privacy Policy consent enforcement for Minecraft servers
|
||||||
author: SimolZimol
|
author: SimolZimol
|
||||||
|
|
||||||
|
|||||||
19
web/app.py
19
web/app.py
@@ -148,14 +148,19 @@ def create_app() -> Flask:
|
|||||||
policy_version=Config.PRIVACY_POLICY_VERSION,
|
policy_version=Config.PRIVACY_POLICY_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/policy/<int:group_id>")
|
@app.route("/policy/<token>")
|
||||||
def public_group_policy(group_id):
|
def public_group_policy(token):
|
||||||
"""Public, unauthenticated URL for a group's server privacy policy."""
|
"""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
|
import panel_db as db
|
||||||
policy = db.get_group_policy(group_id)
|
row = db.get_group_policy_by_token(token)
|
||||||
group = db.get_group_by_id(group_id)
|
if not row:
|
||||||
if not group:
|
return render_template("404.html"), 404
|
||||||
return "Group not found", 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)
|
return render_template("group_policy.html", policy=policy, group=group)
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
|
|||||||
@@ -572,7 +572,8 @@ def privacy_policy():
|
|||||||
return redirect(url_for("group_admin.privacy_policy"))
|
return redirect(url_for("group_admin.privacy_policy"))
|
||||||
|
|
||||||
group = db.get_group_by_id(group_id)
|
group = db.get_group_by_id(group_id)
|
||||||
public_url = url_for("public_group_policy", group_id=group_id, _external=True)
|
token = policy["public_token"] if policy else None
|
||||||
|
public_url = url_for("public_group_policy", token=token, _external=True) if token else None
|
||||||
return render_template("group_admin/privacy_policy.html",
|
return render_template("group_admin/privacy_policy.html",
|
||||||
policy=policy, group=group, public_url=public_url)
|
policy=policy, group=group, public_url=public_url)
|
||||||
|
|
||||||
|
|||||||
@@ -177,13 +177,22 @@ PANEL_MIGRATIONS = [
|
|||||||
"Add users.consented_at for GDPR consent timestamp"),
|
"Add users.consented_at for GDPR consent timestamp"),
|
||||||
(9,
|
(9,
|
||||||
"""CREATE TABLE IF NOT EXISTS group_privacy_policy (
|
"""CREATE TABLE IF NOT EXISTS group_privacy_policy (
|
||||||
group_id INT PRIMARY KEY,
|
group_id INT PRIMARY KEY,
|
||||||
policy_text LONGTEXT,
|
policy_text LONGTEXT,
|
||||||
policy_url VARCHAR(500),
|
policy_url VARCHAR(500),
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
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
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||||
"Add group_privacy_policy table for group-hosted privacy policies"),
|
"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 = [
|
CREDS_SCHEMA = [
|
||||||
@@ -769,20 +778,40 @@ def set_user_consent(user_id: int, policy_version: str) -> None:
|
|||||||
def get_group_policy(group_id: int):
|
def get_group_policy(group_id: int):
|
||||||
"""Returns the group_privacy_policy row for *group_id*, or None if not set."""
|
"""Returns the group_privacy_policy row for *group_id*, or None if not set."""
|
||||||
rows = _panel_query(
|
rows = _panel_query(
|
||||||
"SELECT group_id, policy_text, policy_url, updated_at "
|
"SELECT group_id, policy_text, policy_url, public_token, updated_at "
|
||||||
"FROM group_privacy_policy WHERE group_id = %s",
|
"FROM group_privacy_policy WHERE group_id = %s",
|
||||||
(group_id,),
|
(group_id,),
|
||||||
)
|
)
|
||||||
return rows[0] if rows else None
|
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:
|
def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None:
|
||||||
"""Upserts the privacy policy for a group."""
|
"""Upserts the privacy policy for a group.
|
||||||
|
|
||||||
|
The *public_token* (opaque UUID) is generated once on first INSERT and
|
||||||
|
never changed on subsequent updates, so existing URLs stay valid.
|
||||||
|
"""
|
||||||
_panel_query(
|
_panel_query(
|
||||||
"INSERT INTO group_privacy_policy (group_id, policy_text, policy_url) "
|
"INSERT INTO group_privacy_policy (group_id, policy_text, policy_url, public_token) "
|
||||||
"VALUES (%s, %s, %s) "
|
"VALUES (%s, %s, %s, UUID()) "
|
||||||
"ON DUPLICATE KEY UPDATE policy_text = VALUES(policy_text), "
|
"ON DUPLICATE KEY UPDATE "
|
||||||
"policy_url = VALUES(policy_url), updated_at = UTC_TIMESTAMP()",
|
"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),
|
(group_id, policy_text, policy_url),
|
||||||
write=True,
|
write=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
<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>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
<footer class="text-center py-2 border-top border-secondary">
|
<footer class="text-center py-2 border-top border-secondary">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
<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>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
<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>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{# ── Public URL banner ────────────────────────────────────── #}
|
{# ── Public URL banner ────────────────────────────────────── #}
|
||||||
|
{% if public_url %}
|
||||||
<div class="alert alert-info d-flex align-items-center gap-2 mb-4">
|
<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>
|
<i class="bi bi-link-45deg fs-5 flex-shrink-0"></i>
|
||||||
<div>
|
<div>
|
||||||
@@ -23,6 +24,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 ─────────────────────────────────────────── #}
|
{# ── Last updated ─────────────────────────────────────────── #}
|
||||||
{% if policy and policy.updated_at %}
|
{% if policy and policy.updated_at %}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<i class="bi bi-file-earmark-lock2 me-2 text-warning"></i>
|
<i class="bi bi-file-earmark-lock2 me-2 text-warning"></i>
|
||||||
<strong>{{ group.name }}</strong> — Privacy Policy
|
<strong>{{ group.name }}</strong> — Privacy Policy
|
||||||
</span>
|
</span>
|
||||||
<span class="text-muted small">Powered by MCLogger</span>
|
<span class="text-muted small">Powered by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">MCLogger</a> — Made by Devanturas</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<hr class="border-secondary mt-5">
|
<hr class="border-secondary mt-5">
|
||||||
<p class="text-muted small text-center mb-4">
|
<p class="text-muted small text-center mb-4">
|
||||||
This page is hosted by <a href="https://github.com/simolzimol/MCLogger" class="text-secondary">MCLogger</a>
|
This page is hosted by <a href="https://log.devanturas.net" class="text-secondary" target="_blank" rel="noopener noreferrer">MCLogger</a>
|
||||||
on behalf of <em>{{ group.name }}</em>.
|
on behalf of <em>{{ group.name }}</em>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@
|
|||||||
<a href="{{ url_for('auth.login') }}" class="text-muted me-3">
|
<a href="{{ url_for('auth.login') }}" class="text-muted me-3">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<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