Compare commits
24 Commits
be26484606
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df8230111 | ||
|
|
408933106b | ||
|
|
c2b645ed58 | ||
|
|
aa8dfdcdbf | ||
|
|
83af428435 | ||
|
|
cd9a46b403 | ||
|
|
2dbd5340a8 | ||
|
|
17a782b487 | ||
|
|
aa0544a4a5 | ||
|
|
52674fee29 | ||
|
|
e4727ba561 | ||
|
|
2f13b0a5c6 | ||
|
|
a45dd74083 | ||
|
|
3f660533fc | ||
|
|
6bac132a32 | ||
|
|
bdf83bd275 | ||
|
|
179a0e1042 | ||
|
|
6a6e0fc4b3 | ||
|
|
3b78f5dfb1 | ||
|
|
452d50e5b5 | ||
|
|
8f614a08cc | ||
|
|
ee66a04cb2 | ||
|
|
fe2e5e3c9c | ||
|
|
31b45d4db4 |
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
|
||||
101
web/app.py
101
web/app.py
@@ -3,12 +3,15 @@ MCLogger – Flask Web-Panel
|
||||
Multi-Tenant mit Gruppen, Rollen & verschlüsselten DB-Zugangsdaten.
|
||||
Coolify-kompatibel: alle Einstellungen via ENV.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from flask import Flask, abort, render_template, request, session, url_for
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from config import Config
|
||||
from panel_db import init_databases, get_user_groups
|
||||
from panel_db import init_databases, get_user_groups, get_group_member
|
||||
from roles import can_manage_group
|
||||
from limiter import limiter
|
||||
|
||||
from blueprints.auth import auth
|
||||
from blueprints.site_admin import site_admin
|
||||
@@ -18,6 +21,21 @@ from blueprints.panel import panel
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── Datenschutz-Version automatisch aus Template-Hash berechnen ──────────
|
||||
# Wenn PRIVACY_POLICY_VERSION nicht per ENV gesetzt ist, wird der SHA-256
|
||||
# des Template-Inhalts berechnet und die ersten 6 Zeichen als Version
|
||||
# verwendet. Ändert sich der Seiteninhalt, ändert sich der Hash →
|
||||
# alle Nutzer müssen beim nächsten Login erneut zustimmen.
|
||||
if not os.getenv("PRIVACY_POLICY_VERSION"):
|
||||
import hashlib
|
||||
_policy_path = os.path.join(app.root_path, "templates", "privacy_policy.html")
|
||||
try:
|
||||
with open(_policy_path, "rb") as _f:
|
||||
Config.PRIVACY_POLICY_VERSION = hashlib.sha256(_f.read()).hexdigest()[:6].upper()
|
||||
except OSError:
|
||||
pass # Fallback auf den Config-Default
|
||||
|
||||
app.secret_key = Config.SECRET_KEY
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=Config.SESSION_COOKIE_HTTPONLY,
|
||||
@@ -27,12 +45,32 @@ def create_app() -> Flask:
|
||||
|
||||
Config.validate_security()
|
||||
|
||||
# Reverse-Proxy: echte Client-IP aus X-Forwarded-For lesen
|
||||
if Config.PROXY_COUNT > 0:
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=Config.PROXY_COUNT,
|
||||
x_proto=Config.PROXY_COUNT,
|
||||
x_host=Config.PROXY_COUNT,
|
||||
)
|
||||
|
||||
# Blueprints registrieren
|
||||
app.register_blueprint(auth)
|
||||
app.register_blueprint(site_admin)
|
||||
app.register_blueprint(group_admin)
|
||||
app.register_blueprint(panel)
|
||||
|
||||
# Rate limiter
|
||||
limiter.init_app(app)
|
||||
|
||||
@app.errorhandler(429)
|
||||
def rate_limit_exceeded(e):
|
||||
retry_after = getattr(e, "retry_after", None)
|
||||
return render_template(
|
||||
"429.html",
|
||||
retry_after=int(retry_after) if retry_after else 60,
|
||||
), 429
|
||||
|
||||
# Panel-Datenbank-Tabellen anlegen
|
||||
try:
|
||||
init_databases()
|
||||
@@ -58,14 +96,73 @@ def create_app() -> Flask:
|
||||
if not session_token or not request_token or session_token != request_token:
|
||||
abort(400)
|
||||
|
||||
@app.before_request
|
||||
def refresh_session_role():
|
||||
"""Keeps session role/permissions in sync with the DB.
|
||||
Runs on every request so role changes by an admin take effect
|
||||
immediately without requiring the affected user to re-login."""
|
||||
user_id = session.get("user_id")
|
||||
group_id = session.get("group_id")
|
||||
# Only for regular panel users (not site-admin-only sessions,
|
||||
# not admin-viewing-group sessions, not unauthenticated requests).
|
||||
if not user_id or session.get("is_site_admin") or session.get("admin_viewing"):
|
||||
return
|
||||
if not group_id:
|
||||
return
|
||||
try:
|
||||
member = get_group_member(user_id, group_id)
|
||||
if not member:
|
||||
# User was removed from the group — clear their group context
|
||||
session.pop("group_id", None)
|
||||
session.pop("group_name", None)
|
||||
session.pop("role", None)
|
||||
session.pop("permissions", None)
|
||||
return
|
||||
import json as _json
|
||||
raw = member.get("permissions")
|
||||
perms = (
|
||||
raw if isinstance(raw, dict)
|
||||
else (_json.loads(raw) if isinstance(raw, str) else {})
|
||||
)
|
||||
session["role"] = member["role"]
|
||||
session["permissions"] = perms
|
||||
except Exception:
|
||||
pass # DB unavailable — keep existing session as-is
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(resp):
|
||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
resp.headers.setdefault("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none';")
|
||||
resp.headers.setdefault("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https://minotar.net; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none';")
|
||||
return resp
|
||||
|
||||
@app.route("/privacy-policy")
|
||||
def privacy_policy():
|
||||
from config import Config
|
||||
return render_template(
|
||||
"privacy_policy.html",
|
||||
last_updated="April 15, 2026",
|
||||
invite_expiry_hours=Config.INVITE_EXPIRY_HOURS,
|
||||
audit_retention_days=Config.AUDIT_LOG_RETENTION_DAYS,
|
||||
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
|
||||
|
||||
@@ -5,18 +5,84 @@ Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins.
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from panel_db import accept_group_invite, check_login, get_invite_by_token, get_user_groups
|
||||
from panel_db import (
|
||||
accept_group_invite, check_login, get_invite_by_token, get_user_groups,
|
||||
log_audit_event, get_user_consent_version, set_user_consent,
|
||||
)
|
||||
from config import Config
|
||||
from limiter import limiter
|
||||
|
||||
auth = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
# ── DSGVO-Einwilligungs-Check ─────────────────────────────────
|
||||
# Routen, die ohne Zustimmung erreichbar sein müssen:
|
||||
_CONSENT_EXEMPT = frozenset({
|
||||
"auth.consent", "auth.logout", "auth.login", "auth.admin_login",
|
||||
"auth.accept_invite", "privacy_policy", "static",
|
||||
})
|
||||
|
||||
|
||||
@auth.before_app_request
|
||||
def require_consent():
|
||||
"""Leitet angemeldete Nutzer auf die Zustimmungsseite, solange sie der
|
||||
aktuellen Datenschutzerklärung noch nicht zugestimmt haben."""
|
||||
if request.endpoint in _CONSENT_EXEMPT:
|
||||
return
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return
|
||||
# Site-Admins sind ebenfalls einwilligungspflichtig
|
||||
if session.get("needs_consent"):
|
||||
return redirect(url_for("auth.consent"))
|
||||
|
||||
|
||||
@auth.route("/consent", methods=["GET", "POST"])
|
||||
def consent():
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
if action == "accept":
|
||||
set_user_consent(user_id, Config.PRIVACY_POLICY_VERSION)
|
||||
log_audit_event(
|
||||
user_id, session.get("username"), "consent.given",
|
||||
details={"policy_version": Config.PRIVACY_POLICY_VERSION},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
session.pop("needs_consent", None)
|
||||
# Nach Zustimmung weiterleiten
|
||||
if session.get("is_site_admin"):
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
else:
|
||||
# Ablehnen → ausloggen
|
||||
log_audit_event(
|
||||
user_id, session.get("username"), "consent.declined",
|
||||
details={"policy_version": Config.PRIVACY_POLICY_VERSION},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
session.clear()
|
||||
flash("You must accept the Privacy Policy to use this service.", "warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return render_template(
|
||||
"auth/consent.html",
|
||||
policy_version=Config.PRIVACY_POLICY_VERSION,
|
||||
)
|
||||
|
||||
|
||||
@auth.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("15 per minute", methods=["POST"])
|
||||
def login():
|
||||
if session.get("user_id"):
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
|
||||
username = request.form.get("username", "")
|
||||
user = check_login(username, request.form.get("password", ""))
|
||||
if user and user["is_site_admin"]:
|
||||
flash("Please use the Site Admin login.", "warning")
|
||||
return redirect(url_for("auth.admin_login"))
|
||||
@@ -26,35 +92,74 @@ def login():
|
||||
error = "You are not assigned to any group. Please contact an admin."
|
||||
else:
|
||||
_set_user_session(user, groups)
|
||||
log_audit_event(
|
||||
user["id"], user["username"], "user.login",
|
||||
entity_type="user", entity_id=user["id"],
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
# DSGVO: Zustimmung prüfen
|
||||
if get_user_consent_version(user["id"]) != Config.PRIVACY_POLICY_VERSION:
|
||||
session["needs_consent"] = True
|
||||
return redirect(url_for("auth.consent"))
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
else:
|
||||
log_audit_event(
|
||||
None, None, "user.login_failed",
|
||||
details={"username": username},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
error = "Incorrect username or password."
|
||||
return render_template("auth/login.html", error=error)
|
||||
|
||||
|
||||
@auth.route("/admin/login", methods=["GET", "POST"])
|
||||
@limiter.limit("10 per minute", methods=["POST"])
|
||||
def admin_login():
|
||||
if session.get("is_site_admin"):
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
|
||||
username = request.form.get("username", "")
|
||||
user = check_login(username, request.form.get("password", ""))
|
||||
if user and user["is_site_admin"]:
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
session["is_site_admin"] = True
|
||||
session["group_id"] = None
|
||||
session["permissions"] = {}
|
||||
log_audit_event(
|
||||
user["id"], user["username"], "admin.login",
|
||||
entity_type="user", entity_id=user["id"],
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
# DSGVO: Zustimmung prüfen
|
||||
if get_user_consent_version(user["id"]) != Config.PRIVACY_POLICY_VERSION:
|
||||
session["needs_consent"] = True
|
||||
return redirect(url_for("auth.consent"))
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
elif user:
|
||||
log_audit_event(
|
||||
user["id"], user["username"], "admin.login_failed",
|
||||
details={"reason": "no_admin_privileges"},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
error = "No Site Admin privileges."
|
||||
else:
|
||||
log_audit_event(
|
||||
None, None, "admin.login_failed",
|
||||
details={"username": username},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
error = "Incorrect username or password."
|
||||
return render_template("auth/admin_login.html", error=error)
|
||||
|
||||
|
||||
@auth.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
user_id = session.get("user_id")
|
||||
username = session.get("username")
|
||||
if user_id:
|
||||
log_audit_event(user_id, username, "session.logout", ip_address=request.remote_addr)
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
@@ -74,6 +179,7 @@ def switch_group(group_id):
|
||||
|
||||
|
||||
@auth.route("/invite/<token>", methods=["GET", "POST"])
|
||||
@limiter.limit("20 per minute", methods=["POST"])
|
||||
def accept_invite(token):
|
||||
if session.get("user_id"):
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
@@ -103,6 +209,14 @@ def accept_invite(token):
|
||||
if result.get("error") == "username_or_email_taken":
|
||||
error = "The invited username or email is already in use. Please contact your administrator."
|
||||
else:
|
||||
log_audit_event(
|
||||
result.get("user_id"), invite["invited_username"],
|
||||
"invite.accepted",
|
||||
entity_type="invite", entity_id=invite["id"],
|
||||
details={"group_id": invite.get("group_id"), "role": invite.get("role")},
|
||||
group_id=invite.get("group_id"),
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Your account has been created. You can now sign in.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@@ -2,17 +2,26 @@
|
||||
MCLogger – Gruppen-Admin-Bereich
|
||||
Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
import pymysql
|
||||
import pymysql.cursors
|
||||
from flask import Blueprint, Response, abort, render_template, request, redirect, url_for, session, flash
|
||||
from config import Config
|
||||
from mailer import send_mail
|
||||
from mailer import send_mail, build_invite_email, force_https_url
|
||||
import panel_db as db
|
||||
from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, role_label
|
||||
from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, OWNER_ONLY_ROLES, role_label
|
||||
from limiter import limiter
|
||||
|
||||
group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin")
|
||||
|
||||
# Role options that group admins are allowed to assign (owner excluded)
|
||||
_NON_OWNER_ROLE_OPTIONS = [(r, l) for r, l in GROUP_ROLE_OPTIONS if r not in OWNER_ONLY_ROLES]
|
||||
|
||||
ALL_PERMISSIONS = [
|
||||
("view_dashboard", "Dashboard"),
|
||||
("view_players", "Players"),
|
||||
@@ -74,7 +83,7 @@ def members():
|
||||
return render_template("group_admin/members.html",
|
||||
group=group, members=members, non_members=non_members, pending_invites=pending_invites,
|
||||
all_permissions=ALL_PERMISSIONS,
|
||||
role_options=GROUP_ROLE_OPTIONS,
|
||||
role_options=_NON_OWNER_ROLE_OPTIONS,
|
||||
role_label=role_label)
|
||||
|
||||
|
||||
@@ -84,17 +93,28 @@ def member_add():
|
||||
group_id = session["group_id"]
|
||||
user_id = request.form.get("user_id", type=int)
|
||||
role = request.form.get("role", "viewer")
|
||||
if role in OWNER_ONLY_ROLES:
|
||||
flash("The Group Owner role can only be assigned by a Site Admin.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
if role not in GROUP_ROLE_SET:
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
if user_id:
|
||||
db.add_group_member(user_id, group_id, role)
|
||||
target_user = db.get_user_by_id(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.added",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"role": role, "target": target_user["username"] if target_user else str(user_id)},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member added.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
|
||||
@group_admin.route("/members/invite", methods=["POST"])
|
||||
@group_admin_required
|
||||
@limiter.limit("30 per hour", methods=["POST"])
|
||||
def member_invite():
|
||||
group_id = session["group_id"]
|
||||
username = request.form.get("username", "").strip()
|
||||
@@ -113,6 +133,10 @@ def member_invite():
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
if role in OWNER_ONLY_ROLES:
|
||||
flash("The Group Owner role can only be assigned by a Site Admin.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
if db.count_active_group_invites(group_id) >= Config.INVITE_MAX_ACTIVE_PER_GROUP:
|
||||
flash("Active invite limit reached for this group. Revoke old invites or wait for expiry.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
@@ -135,19 +159,26 @@ def member_invite():
|
||||
|
||||
token = db.create_group_invite(group_id, username, email, role, session["user_id"])
|
||||
invite = db.get_invite_by_token(token)
|
||||
invite_url = url_for("auth.accept_invite", token=token, _external=True)
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=token, _external=True))
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.created",
|
||||
entity_type="invite", entity_id=invite["id"] if invite else None,
|
||||
details={"username": username, "email": email, "role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
mail_settings = db.get_site_mail_settings()
|
||||
|
||||
if mail_settings:
|
||||
subject = f"Invitation to join {session.get('group_name', 'your group')}"
|
||||
text_body = (
|
||||
f"Hello {username},\n\n"
|
||||
f"You have been invited to join the group '{session.get('group_name', 'your group')}' on MCLogger as {role_label(role)}.\n"
|
||||
f"Open this link to create your account:\n\n{invite_url}\n\n"
|
||||
f"This invite expires in {Config.INVITE_EXPIRY_HOURS} hours.\n"
|
||||
text_body, html_body = build_invite_email(
|
||||
username=username,
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"in {Config.INVITE_EXPIRY_HOURS} hours",
|
||||
group_name=session.get("group_name", "your group"),
|
||||
role_name=role_label(role),
|
||||
)
|
||||
try:
|
||||
send_mail(mail_settings, email, subject, text_body)
|
||||
send_mail(mail_settings, email, subject, text_body, html_body=html_body)
|
||||
if invite:
|
||||
db.mark_group_invite_sent(invite["id"], group_id)
|
||||
flash(f"Invitation email sent to '{email}'.", "success")
|
||||
@@ -160,6 +191,7 @@ def member_invite():
|
||||
|
||||
@group_admin.route("/invites/<int:invite_id>/resend", methods=["POST"])
|
||||
@group_admin_required
|
||||
@limiter.limit("20 per hour", methods=["POST"])
|
||||
def resend_invite(invite_id):
|
||||
group_id = session["group_id"]
|
||||
invite = db.get_group_invite_by_id(invite_id, group_id)
|
||||
@@ -181,17 +213,24 @@ def resend_invite(invite_id):
|
||||
flash("No SMTP settings configured by Site Admin.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
invite_url = url_for("auth.accept_invite", token=invite["token"], _external=True)
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=invite["token"], _external=True))
|
||||
subject = f"Invitation to join {session.get('group_name', 'your group')}"
|
||||
text_body = (
|
||||
f"Hello {invite['invited_username']},\n\n"
|
||||
f"You have been invited to join the group '{session.get('group_name', 'your group')}' on MCLogger as {role_label(invite['role'])}.\n"
|
||||
f"Open this link to create your account:\n\n{invite_url}\n\n"
|
||||
f"This invite expires on {invite['expires_at']}.\n"
|
||||
text_body, html_body = build_invite_email(
|
||||
username=invite["invited_username"],
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"on {invite['expires_at']}",
|
||||
group_name=session.get("group_name", "your group"),
|
||||
role_name=role_label(invite["role"]),
|
||||
)
|
||||
try:
|
||||
send_mail(mail_settings, invite["invited_email"], subject, text_body)
|
||||
send_mail(mail_settings, invite["invited_email"], subject, text_body, html_body=html_body)
|
||||
db.mark_group_invite_sent(invite_id, group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.resent",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"to": invite["invited_email"], "username": invite["invited_username"]},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation email resent.", "success")
|
||||
except Exception:
|
||||
flash("Resend failed. Please verify SMTP settings and try again.", "danger")
|
||||
@@ -201,7 +240,14 @@ def resend_invite(invite_id):
|
||||
@group_admin.route("/invites/<int:invite_id>/revoke", methods=["POST"])
|
||||
@group_admin_required
|
||||
def revoke_invite(invite_id):
|
||||
invite = db.get_group_invite_by_id(invite_id, session["group_id"])
|
||||
db.revoke_group_invite(invite_id, session["group_id"])
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.revoked",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"username": invite["invited_username"] if invite else None},
|
||||
group_id=session["group_id"], ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation revoked.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
@@ -222,18 +268,28 @@ def member_edit(user_id):
|
||||
|
||||
if request.method == "POST":
|
||||
role = request.form.get("role", "viewer")
|
||||
if role in OWNER_ONLY_ROLES:
|
||||
flash("The Group Owner role can only be assigned by a Site Admin.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
if role not in GROUP_ROLE_SET:
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
new_perms = {key: bool(request.form.get(f"perm_{key}")) for key, _ in ALL_PERMISSIONS}
|
||||
old_role = member.get("role")
|
||||
db.update_member(user_id, group_id, role, new_perms)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.updated",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": user["username"], "old_role": old_role, "new_role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Permissions updated.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
return render_template("group_admin/member_edit.html",
|
||||
group=group, user=user, member=member,
|
||||
current_perms=current_perms, all_permissions=ALL_PERMISSIONS,
|
||||
role_options=GROUP_ROLE_OPTIONS,
|
||||
role_options=_NON_OWNER_ROLE_OPTIONS,
|
||||
role_label=role_label)
|
||||
|
||||
|
||||
@@ -243,7 +299,14 @@ def member_remove(user_id):
|
||||
if user_id == session["user_id"]:
|
||||
flash("You cannot remove yourself.", "danger")
|
||||
else:
|
||||
target_user = db.get_user_by_id(user_id)
|
||||
db.remove_group_member(user_id, session["group_id"])
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.removed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target_user["username"] if target_user else str(user_id)},
|
||||
group_id=session["group_id"], ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member removed.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
@@ -279,7 +342,6 @@ def database():
|
||||
error = "Password is required."
|
||||
else:
|
||||
try:
|
||||
import pymysql
|
||||
test_conn = pymysql.connect(
|
||||
host=host, port=int(port), user=user,
|
||||
password=password, database=database_name,
|
||||
@@ -287,6 +349,12 @@ def database():
|
||||
)
|
||||
test_conn.close()
|
||||
db.set_group_db_creds(group_id, host, int(port), user, password, database_name)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "db.credentials_changed",
|
||||
entity_type="group", entity_id=group_id,
|
||||
details={"host": host, "port": port, "database": database_name},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Database connection saved and tested ✓", "success")
|
||||
return redirect(url_for("group_admin.database"))
|
||||
except Exception as e:
|
||||
@@ -299,6 +367,213 @@ def database():
|
||||
@group_admin.route("/database/delete", methods=["POST"])
|
||||
@group_admin_required
|
||||
def database_delete():
|
||||
db.delete_group_db_creds(session["group_id"])
|
||||
group_id = session["group_id"]
|
||||
db.delete_group_db_creds(group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "db.credentials_deleted",
|
||||
entity_type="group", entity_id=group_id,
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Database connection removed.", "success")
|
||||
return redirect(url_for("group_admin.database"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GDPR: Spielerdaten – Export & Löschung
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Tables and the column name that holds the player UUID
|
||||
_PLAYER_TABLES = [
|
||||
("player_sessions", "player_uuid"),
|
||||
("player_chat", "player_uuid"),
|
||||
("player_commands", "player_uuid"),
|
||||
("player_deaths", "player_uuid"),
|
||||
("player_teleports", "player_uuid"),
|
||||
("player_stats", "player_uuid"),
|
||||
("block_events", "player_uuid"),
|
||||
("proxy_events", "player_uuid"),
|
||||
("inventory_events", "player_uuid"),
|
||||
("entity_events", "player_uuid"),
|
||||
]
|
||||
|
||||
|
||||
def _get_mc_db(group_id, autocommit: bool = True):
|
||||
"""Open a connection to the group's Minecraft database."""
|
||||
creds = db.get_group_db_creds(group_id)
|
||||
if not creds:
|
||||
abort(503)
|
||||
return pymysql.connect(
|
||||
host=creds["host"],
|
||||
port=creds["port"],
|
||||
user=creds["user"],
|
||||
password=creds["password"],
|
||||
database=creds["database"],
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=autocommit,
|
||||
connect_timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@group_admin.route("/players/<uuid>/export")
|
||||
@group_admin_required
|
||||
def player_export(uuid):
|
||||
"""Export all MC data for a player as a ZIP archive (Art. 20 DSGVO)."""
|
||||
group_id = session["group_id"]
|
||||
if not db.has_db_configured(group_id):
|
||||
flash("No database configured for this group.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
try:
|
||||
conn = _get_mc_db(group_id)
|
||||
except Exception:
|
||||
flash("Could not connect to the group database.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM players WHERE uuid = %s", (uuid,))
|
||||
player = cur.fetchone()
|
||||
if not player:
|
||||
flash("Player not found.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# players table (keyed by uuid directly)
|
||||
csv_buf = io.StringIO()
|
||||
writer = csv.DictWriter(csv_buf, fieldnames=player.keys())
|
||||
writer.writeheader()
|
||||
writer.writerow(player)
|
||||
zf.writestr("players.csv", csv_buf.getvalue())
|
||||
|
||||
for table, col in _PLAYER_TABLES:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT * FROM `{table}` WHERE `{col}` = %s", (uuid,))
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
csv_buf = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(csv_buf, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
zf.writestr(f"{table}.csv", csv_buf.getvalue())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "player.data_exported",
|
||||
entity_type="mc_player", entity_id=uuid,
|
||||
details={"player_name": player["username"], "uuid": uuid},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
|
||||
safe_name = "".join(c for c in player["username"] if c.isalnum() or c in "-_")
|
||||
filename = f"player_{safe_name}_{uuid[:8]}.zip"
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
@group_admin.route("/players/<uuid>/delete", methods=["GET", "POST"])
|
||||
@group_admin_required
|
||||
def player_delete(uuid):
|
||||
"""Permanently delete all MC data for a player (Art. 17 DSGVO). Owner only."""
|
||||
if session.get("role") not in OWNER_ONLY_ROLES:
|
||||
flash("Only the Group Owner can permanently delete player data.", "danger")
|
||||
return redirect(url_for("panel.player_detail", uuid=uuid))
|
||||
|
||||
group_id = session["group_id"]
|
||||
if not db.has_db_configured(group_id):
|
||||
flash("No database configured for this group.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
try:
|
||||
conn = _get_mc_db(group_id, autocommit=False)
|
||||
except Exception:
|
||||
flash("Could not connect to the group database.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT uuid, username FROM players WHERE uuid = %s", (uuid,))
|
||||
player = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not player:
|
||||
flash("Player not found.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
group = db.get_group_by_id(group_id)
|
||||
|
||||
if request.method == "POST":
|
||||
confirm_name = request.form.get("confirm_name", "").strip()
|
||||
if confirm_name != player["username"]:
|
||||
flash("Username confirmation did not match. No data was deleted.", "danger")
|
||||
return redirect(url_for("group_admin.player_delete", uuid=uuid))
|
||||
|
||||
try:
|
||||
conn = _get_mc_db(group_id, autocommit=False)
|
||||
with conn.cursor() as cur:
|
||||
for table, col in _PLAYER_TABLES:
|
||||
cur.execute(f"DELETE FROM `{table}` WHERE `{col}` = %s", (uuid,))
|
||||
cur.execute("DELETE FROM `players` WHERE `uuid` = %s", (uuid,))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
flash(f"Database error during deletion: {e}", "danger")
|
||||
return redirect(url_for("group_admin.player_delete", uuid=uuid))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "player.data_deleted",
|
||||
entity_type="mc_player", entity_id=uuid,
|
||||
details={"player_name": player["username"], "uuid": uuid},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash(f"All data for '{player['username']}' has been permanently deleted.", "success")
|
||||
return redirect(url_for("panel.players"))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -14,6 +14,20 @@ from roles import can_manage_group
|
||||
panel = Blueprint("panel", __name__)
|
||||
|
||||
|
||||
def _audit(action: str, details: dict | None = None, entity_type: str | None = None, entity_id=None):
|
||||
"""Fire-and-forget audit event for panel data access (never raises)."""
|
||||
pdb.log_audit_event(
|
||||
session.get("user_id"),
|
||||
session.get("username"),
|
||||
action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
details=details,
|
||||
group_id=session.get("group_id"),
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Hilfsfunktionen
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -170,6 +184,7 @@ def dashboard():
|
||||
def players():
|
||||
search = request.args.get("q", "")
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
_audit("panel.view_players", {"search": search, "page": page} if search else {"page": page})
|
||||
if search:
|
||||
base = "FROM players WHERE username LIKE %s"
|
||||
args = (f"%{search}%",)
|
||||
@@ -191,6 +206,8 @@ def player_detail(uuid):
|
||||
if not player:
|
||||
flash("Player not found.", "danger")
|
||||
return redirect(url_for("panel.players"))
|
||||
_audit("panel.view_player", {"player_name": player.get("username"), "uuid": uuid},
|
||||
entity_type="mc_player", entity_id=uuid)
|
||||
perms = session.get("permissions", {})
|
||||
is_admin = session.get("is_site_admin") or can_manage_group(session.get("role"))
|
||||
return render_template("panel/player_detail.html",
|
||||
@@ -216,6 +233,7 @@ def sessions():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", "")
|
||||
server = request.args.get("server", "")
|
||||
_audit("panel.view_sessions", {"page": page})
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
@@ -239,6 +257,7 @@ def sessions():
|
||||
def chat():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
search = request.args.get("q", ""); server = request.args.get("server", "")
|
||||
_audit("panel.view_chat", {"page": page})
|
||||
date_from = request.args.get("from", ""); date_to = request.args.get("to", "")
|
||||
conditions, args = [], []
|
||||
if search: conditions.append("message LIKE %s"); args.append(f"%{search}%")
|
||||
@@ -265,6 +284,7 @@ def chat():
|
||||
def commands():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", ""); search = request.args.get("q", ""); server = request.args.get("server", "")
|
||||
_audit("panel.view_commands", {"page": page})
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if search: conditions.append("command LIKE %s"); args.append(f"%{search}%")
|
||||
@@ -289,6 +309,7 @@ def commands():
|
||||
def deaths():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", ""); cause = request.args.get("cause", "")
|
||||
_audit("panel.view_deaths", {"page": page})
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if cause: conditions.append("cause = %s"); args.append(cause)
|
||||
@@ -311,6 +332,7 @@ def deaths():
|
||||
def blocks():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
event_type = request.args.get("type", ""); player = request.args.get("player", "")
|
||||
_audit("panel.view_blocks", {"page": page})
|
||||
world = request.args.get("world", ""); server = request.args.get("server", ""); block = request.args.get("block", "")
|
||||
conditions, args = [], []
|
||||
if event_type: conditions.append("event_type = %s"); args.append(event_type)
|
||||
@@ -340,6 +362,7 @@ def blocks():
|
||||
def proxy():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
event_type = request.args.get("type", ""); player = request.args.get("player", "")
|
||||
_audit("panel.view_proxy", {"page": page})
|
||||
conditions, args = [], []
|
||||
if event_type: conditions.append("event_type = %s"); args.append(event_type)
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
@@ -361,6 +384,7 @@ def proxy():
|
||||
def server_events():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
server = request.args.get("server", ""); etype = request.args.get("type", "")
|
||||
_audit("panel.view_server_events", {"page": page})
|
||||
conditions, args = [], []
|
||||
if server: conditions.append("server_name = %s"); args.append(server)
|
||||
if etype: conditions.append("event_type = %s"); args.append(etype)
|
||||
@@ -385,6 +409,7 @@ def server_events():
|
||||
def perms():
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
player = request.args.get("player", ""); plugin_filter = request.args.get("plugin", ""); etype = request.args.get("type", "")
|
||||
_audit("panel.view_perms", {"page": page})
|
||||
conditions, args = [], []
|
||||
if player: conditions.append("player_name LIKE %s"); args.append(f"%{player}%")
|
||||
if plugin_filter: conditions.append("plugin_name = %s"); args.append(plugin_filter)
|
||||
|
||||
@@ -3,10 +3,13 @@ MCLogger – Site-Admin-Bereich
|
||||
Verwaltet alle Gruppen und Nutzer global.
|
||||
"""
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from mailer import send_mail
|
||||
from config import Config
|
||||
from mailer import send_mail, build_invite_email, force_https_url
|
||||
import panel_db as db
|
||||
from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, role_label
|
||||
from limiter import limiter
|
||||
|
||||
site_admin = Blueprint("site_admin", __name__, url_prefix="/admin")
|
||||
|
||||
@@ -45,11 +48,19 @@ def dashboard():
|
||||
"admin_count": sum(1 for u in users if u.get("is_site_admin")),
|
||||
"mail_configured": int(has_mail),
|
||||
}
|
||||
return render_template("admin/dashboard.html", groups=groups, users=users, stats=stats)
|
||||
# Letzte 10 Audit-Einträge für das Dashboard-Widget
|
||||
try:
|
||||
recent_audit, _ = db.get_audit_log(page=1, per_page=10)
|
||||
except Exception:
|
||||
recent_audit = []
|
||||
return render_template("admin/dashboard.html", groups=groups, users=users,
|
||||
stats=stats, recent_audit=recent_audit,
|
||||
retention_days=Config.AUDIT_LOG_RETENTION_DAYS)
|
||||
|
||||
|
||||
@site_admin.route("/mail", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
@limiter.limit("20 per hour", methods=["POST"])
|
||||
def mail_settings():
|
||||
settings = db.get_site_mail_settings()
|
||||
error = None
|
||||
@@ -99,6 +110,10 @@ def mail_settings():
|
||||
"Your SMTP settings were verified successfully and have been saved.",
|
||||
)
|
||||
db.set_site_mail_settings(host, port, username, password, from_email, from_name, use_tls)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "mail.settings_saved",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Mail settings saved and verified.", "success")
|
||||
return redirect(url_for("site_admin.mail_settings"))
|
||||
except Exception as exc:
|
||||
@@ -121,6 +136,10 @@ def mail_settings():
|
||||
@admin_required
|
||||
def mail_settings_delete():
|
||||
db.delete_site_mail_settings()
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "mail.settings_deleted",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Mail settings removed.", "success")
|
||||
return redirect(url_for("site_admin.mail_settings"))
|
||||
|
||||
@@ -149,7 +168,13 @@ def group_new():
|
||||
elif db.get_group_by_name(name):
|
||||
flash("A group with that name already exists.", "danger")
|
||||
else:
|
||||
db.create_group(name, desc)
|
||||
gid = db.create_group(name, desc)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "group.created",
|
||||
entity_type="group", entity_id=gid,
|
||||
details={"name": name},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash(f"Group '{name}' created.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
return render_template("admin/group_edit.html", group=None)
|
||||
@@ -169,6 +194,12 @@ def group_edit(group_id):
|
||||
flash("Group name must not be empty.", "danger")
|
||||
else:
|
||||
db.update_group(group_id, name, desc)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "group.updated",
|
||||
entity_type="group", entity_id=group_id,
|
||||
details={"name": name},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Group updated.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
return render_template("admin/group_edit.html", group=group)
|
||||
@@ -177,7 +208,14 @@ def group_edit(group_id):
|
||||
@site_admin.route("/groups/<int:group_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def group_delete(group_id):
|
||||
group = db.get_group_by_id(group_id)
|
||||
db.delete_group(group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "group.deleted",
|
||||
entity_type="group", entity_id=group_id,
|
||||
details={"name": group["name"] if group else None},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Group deleted.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
|
||||
@@ -185,13 +223,20 @@ def group_delete(group_id):
|
||||
@site_admin.route("/groups/<int:group_id>/members")
|
||||
@admin_required
|
||||
def group_members(group_id):
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "admin.view_group_members",
|
||||
entity_type="group", entity_id=group_id,
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
group = db.get_group_by_id(group_id)
|
||||
members = db.get_group_members(group_id)
|
||||
pending_invites = db.list_active_group_invites(group_id)
|
||||
all_users = db.list_all_users()
|
||||
member_ids = {m["id"] for m in members}
|
||||
non_members = [u for u in all_users if u["id"] not in member_ids]
|
||||
return render_template("admin/group_members.html",
|
||||
group=group, members=members, non_members=non_members,
|
||||
pending_invites=pending_invites,
|
||||
role_options=GROUP_ROLE_OPTIONS,
|
||||
role_label=role_label,
|
||||
management_roles=GROUP_MANAGEMENT_ROLES)
|
||||
@@ -207,6 +252,13 @@ def group_member_add(group_id):
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if user_id:
|
||||
db.add_group_member(user_id, group_id, role)
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.added",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target["username"] if target else str(user_id), "role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member added.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
@@ -214,7 +266,14 @@ def group_member_add(group_id):
|
||||
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||
@admin_required
|
||||
def group_member_remove(group_id, user_id):
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.remove_group_member(user_id, group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.removed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target["username"] if target else str(user_id)},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member removed.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
@@ -230,11 +289,146 @@ def group_member_set_role(group_id, user_id):
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
perms = member["permissions"] if isinstance(member["permissions"], dict) else (_json.loads(member["permissions"]) if member["permissions"] else {})
|
||||
old_role = member.get("role")
|
||||
db.update_member(user_id, group_id, new_role, perms)
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.role_changed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target["username"] if target else str(user_id), "old_role": old_role, "new_role": new_role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash(f"Role changed to '{new_role}'.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/members/invite", methods=["POST"])
|
||||
@admin_required
|
||||
def group_member_invite(group_id):
|
||||
group = db.get_group_by_id(group_id)
|
||||
if not group:
|
||||
flash("Group not found.", "danger")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
|
||||
username = request.form.get("username", "").strip()
|
||||
email = request.form.get("email", "").strip()
|
||||
role = request.form.get("role", "viewer")
|
||||
|
||||
if not username or not email:
|
||||
flash("Username and email are required.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if "@" not in email:
|
||||
flash("Please provide a valid email address.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if role not in GROUP_ROLE_SET:
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if db.count_active_group_invites(group_id) >= Config.INVITE_MAX_ACTIVE_PER_GROUP:
|
||||
flash("Active invite limit reached for this group.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if db.get_user_by_username(username):
|
||||
flash("Username already exists.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if db.get_active_invite_by_username(group_id, username):
|
||||
flash("There is already an active invitation for this username.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if db.get_user_by_email(email):
|
||||
flash("Email address is already in use.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if db.get_active_invite_by_email(group_id, email):
|
||||
flash("There is already an active invitation for this email.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
token = db.create_group_invite(group_id, username, email, role, session["user_id"])
|
||||
invite = db.get_invite_by_token(token)
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=token, _external=True))
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.created",
|
||||
entity_type="invite", entity_id=invite["id"] if invite else None,
|
||||
details={"username": username, "email": email, "role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
mail_settings = db.get_site_mail_settings()
|
||||
|
||||
if mail_settings:
|
||||
subject = f"Invitation to join {group['name']}"
|
||||
text_body, html_body = build_invite_email(
|
||||
username=username,
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"in {Config.INVITE_EXPIRY_HOURS} hours",
|
||||
group_name=group["name"],
|
||||
role_name=role_label(role),
|
||||
)
|
||||
try:
|
||||
send_mail(mail_settings, email, subject, text_body, html_body=html_body)
|
||||
if invite:
|
||||
db.mark_group_invite_sent(invite["id"], group_id)
|
||||
flash(f"Invitation email sent to '{email}'.", "success")
|
||||
except Exception:
|
||||
flash(f"Invitation created, but email delivery failed. Share this link manually: {invite_url}", "warning")
|
||||
else:
|
||||
flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/invites/<int:invite_id>/revoke", methods=["POST"])
|
||||
@admin_required
|
||||
def group_invite_revoke(group_id, invite_id):
|
||||
invite = db.get_group_invite_by_id(invite_id, group_id)
|
||||
db.revoke_group_invite(invite_id, group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.revoked",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"username": invite["invited_username"] if invite else None},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation revoked.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
|
||||
@site_admin.route("/groups/<int:group_id>/invites/<int:invite_id>/resend", methods=["POST"])
|
||||
@admin_required
|
||||
def group_invite_resend(group_id, invite_id):
|
||||
group = db.get_group_by_id(group_id)
|
||||
invite = db.get_group_invite_by_id(invite_id, group_id)
|
||||
if not invite:
|
||||
flash("Invitation not found.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if invite.get("accepted_at") or invite.get("revoked_at") or invite["expires_at"] <= datetime.utcnow():
|
||||
flash("Invitation is no longer active.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
last_sent = invite.get("last_sent_at")
|
||||
if last_sent and (datetime.utcnow() - last_sent) < timedelta(seconds=Config.INVITE_RESEND_COOLDOWN_SECONDS):
|
||||
flash("Please wait before resending this invite again.", "warning")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
mail_settings = db.get_site_mail_settings()
|
||||
if not mail_settings:
|
||||
flash("No SMTP settings configured.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=invite["token"], _external=True))
|
||||
subject = f"Invitation to join {group['name']}"
|
||||
text_body, html_body = build_invite_email(
|
||||
username=invite["invited_username"],
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"on {invite['expires_at']}",
|
||||
group_name=group["name"],
|
||||
role_name=role_label(invite["role"]),
|
||||
)
|
||||
try:
|
||||
send_mail(mail_settings, invite["invited_email"], subject, text_body, html_body=html_body)
|
||||
db.mark_group_invite_sent(invite_id, group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.resent",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"to": invite["invited_email"], "username": invite["invited_username"]},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation email resent.", "success")
|
||||
except Exception:
|
||||
flash("Resend failed. Please verify SMTP settings and try again.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Nutzer verwalten
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@@ -242,28 +436,162 @@ def group_member_set_role(group_id, user_id):
|
||||
@site_admin.route("/users")
|
||||
@admin_required
|
||||
def users():
|
||||
return render_template("admin/users.html", users=db.list_all_users())
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "admin.view_users",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
return render_template(
|
||||
"admin/users.html",
|
||||
users=db.list_all_users(),
|
||||
pending_invites=db.list_all_active_invites(),
|
||||
)
|
||||
|
||||
|
||||
@site_admin.route("/users/new", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def user_new():
|
||||
groups = db.list_all_groups()
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
email = request.form.get("email", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
is_site_admin = request.form.get("is_site_admin") == "1"
|
||||
if not username or not email or not password:
|
||||
flash("All fields are required.", "danger")
|
||||
group_id_raw = request.form.get("group_id", "").strip()
|
||||
role = request.form.get("role", "viewer")
|
||||
group_id = int(group_id_raw) if group_id_raw else None
|
||||
|
||||
error = None
|
||||
if not username or not email:
|
||||
error = "Username and email are required."
|
||||
elif db.get_user_by_username(username):
|
||||
flash("Username already taken.", "danger")
|
||||
error = "Username already taken."
|
||||
elif db.get_user_by_email(email):
|
||||
flash("Email address already in use.", "danger")
|
||||
error = "Email address already in use."
|
||||
elif db.get_active_invite_by_username_global(username):
|
||||
error = "There is already an active invitation for this username."
|
||||
elif db.get_active_invite_by_email_global(email):
|
||||
error = "There is already an active invitation for this email."
|
||||
elif group_id and role not in GROUP_ROLE_SET:
|
||||
error = "Invalid role selected."
|
||||
|
||||
if error:
|
||||
flash(error, "danger")
|
||||
return render_template("admin/user_edit.html", user=None, groups=groups)
|
||||
|
||||
effective_role = role if group_id else "member"
|
||||
token = db.create_group_invite(group_id, username, email, effective_role,
|
||||
session["user_id"], is_site_admin=is_site_admin)
|
||||
new_invite = db.get_invite_by_token(token)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.created",
|
||||
entity_type="invite", entity_id=new_invite["id"] if new_invite else None,
|
||||
details={"username": username, "email": email, "role": effective_role, "is_site_admin": is_site_admin},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=token, _external=True))
|
||||
mail_settings = db.get_site_mail_settings()
|
||||
|
||||
if mail_settings:
|
||||
if group_id:
|
||||
group = db.get_group_by_id(group_id)
|
||||
subject = f"Invitation to join {group['name']}"
|
||||
body, html_body = build_invite_email(
|
||||
username=username,
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"in {Config.INVITE_EXPIRY_HOURS} hours",
|
||||
group_name=group["name"],
|
||||
role_name=role_label(effective_role),
|
||||
)
|
||||
else:
|
||||
db.create_user(username, email, password, is_site_admin)
|
||||
flash(f"User '{username}' created.", "success")
|
||||
subject = "You have been invited to MCLogger"
|
||||
body, html_body = build_invite_email(
|
||||
username=username,
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"in {Config.INVITE_EXPIRY_HOURS} hours",
|
||||
)
|
||||
try:
|
||||
send_mail(mail_settings, email, subject, body, html_body=html_body)
|
||||
invite = db.get_invite_by_token(token)
|
||||
if invite:
|
||||
db.mark_invite_sent_global(invite["id"])
|
||||
flash(f"Invitation email sent to '{email}'.", "success")
|
||||
except Exception:
|
||||
flash(
|
||||
f"Invitation created, but email delivery failed. "
|
||||
f"Share this link manually: {invite_url}",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(f"Invitation created for '{username}'. Share this link: {invite_url}", "success")
|
||||
|
||||
return redirect(url_for("site_admin.users"))
|
||||
return render_template("admin/user_edit.html", user=None, groups=groups)
|
||||
|
||||
|
||||
@site_admin.route("/users/invites/<int:invite_id>/revoke", methods=["POST"])
|
||||
@admin_required
|
||||
def user_invite_revoke(invite_id):
|
||||
invite = db.get_invite_by_id_global(invite_id)
|
||||
db.revoke_invite_global(invite_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.revoked",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"username": invite["invited_username"] if invite else None},
|
||||
group_id=invite["group_id"] if invite else None,
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation revoked.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
|
||||
|
||||
@site_admin.route("/users/invites/<int:invite_id>/resend", methods=["POST"])
|
||||
@admin_required
|
||||
def user_invite_resend(invite_id):
|
||||
invite = db.get_invite_by_id_global(invite_id)
|
||||
if not invite:
|
||||
flash("Invitation not found.", "danger")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
if invite.get("accepted_at") or invite.get("revoked_at") or invite["expires_at"] <= datetime.utcnow():
|
||||
flash("Invitation is no longer active.", "danger")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
last_sent = invite.get("last_sent_at")
|
||||
if last_sent and (datetime.utcnow() - last_sent) < timedelta(seconds=Config.INVITE_RESEND_COOLDOWN_SECONDS):
|
||||
flash("Please wait before resending this invite again.", "warning")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
mail_settings = db.get_site_mail_settings()
|
||||
if not mail_settings:
|
||||
flash("No SMTP settings configured.", "danger")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=invite["token"], _external=True))
|
||||
if invite["group_id"]:
|
||||
group = db.get_group_by_id(invite["group_id"])
|
||||
subject = f"Invitation to join {group['name']}"
|
||||
body, html_body = build_invite_email(
|
||||
username=invite["invited_username"],
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"on {invite['expires_at']}",
|
||||
group_name=group["name"],
|
||||
role_name=role_label(invite["role"]),
|
||||
)
|
||||
else:
|
||||
subject = "You have been invited to MCLogger"
|
||||
body, html_body = build_invite_email(
|
||||
username=invite["invited_username"],
|
||||
invite_url=invite_url,
|
||||
expiry_text=f"on {invite['expires_at']}",
|
||||
)
|
||||
try:
|
||||
send_mail(mail_settings, invite["invited_email"], subject, body, html_body=html_body)
|
||||
db.mark_invite_sent_global(invite_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.resent",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"to": invite["invited_email"], "username": invite["invited_username"]},
|
||||
group_id=invite.get("group_id"), ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation email resent.", "success")
|
||||
except Exception:
|
||||
flash("Resend failed. Please verify SMTP settings and try again.", "danger")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
return render_template("admin/user_edit.html", user=None)
|
||||
|
||||
|
||||
@site_admin.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||
@@ -273,6 +601,13 @@ def user_edit(user_id):
|
||||
if not user:
|
||||
flash("User not found.", "danger")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
if request.method == "GET":
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "admin.view_user",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": user["username"]},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
email = request.form.get("email", "").strip()
|
||||
@@ -285,7 +620,19 @@ def user_edit(user_id):
|
||||
db.update_user(user_id, username, email, is_site_admin)
|
||||
if new_password:
|
||||
db.change_password(user_id, new_password)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "user.password_changed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": username},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Password changed.", "info")
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "user.updated",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"username": username, "is_site_admin": is_site_admin},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("User updated.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
return render_template("admin/user_edit.html", user=user)
|
||||
@@ -297,7 +644,14 @@ def user_delete(user_id):
|
||||
if user_id == session.get("user_id"):
|
||||
flash("You cannot delete yourself.", "danger")
|
||||
else:
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.delete_user(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "user.deleted",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"username": target["username"] if target else str(user_id)},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("User deleted.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
|
||||
@@ -330,6 +684,12 @@ def view_group(group_id):
|
||||
session["role"] = "group_owner"
|
||||
session["permissions"] = all_perms
|
||||
session["admin_viewing"] = True
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "admin.view_group",
|
||||
entity_type="group", entity_id=group_id,
|
||||
details={"group_name": group["name"]},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
|
||||
|
||||
@@ -347,3 +707,59 @@ def stop_view():
|
||||
session.pop("permissions", None)
|
||||
session.pop("admin_viewing", None)
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Audit-Log
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@site_admin.route("/audit")
|
||||
@admin_required
|
||||
def audit_log():
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "admin.view_audit_log",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
page = request.args.get("page", 1, type=int)
|
||||
action_f = request.args.get("action", "").strip() or None
|
||||
group_f = request.args.get("group_id", None, type=int)
|
||||
actor_f = request.args.get("actor", "").strip() or None
|
||||
per_page = 50
|
||||
|
||||
rows, total = db.get_audit_log(
|
||||
page=page, per_page=per_page,
|
||||
action_filter=action_f,
|
||||
group_id_filter=group_f,
|
||||
actor_filter=actor_f,
|
||||
)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
all_groups = db.list_all_groups() or []
|
||||
actions = db.get_audit_log_distinct_actions()
|
||||
|
||||
return render_template(
|
||||
"admin/audit_log.html",
|
||||
rows=rows,
|
||||
total=total,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
per_page=per_page,
|
||||
action_filter=action_f or "",
|
||||
group_filter=group_f,
|
||||
actor_filter=actor_f or "",
|
||||
all_groups=all_groups,
|
||||
actions=actions,
|
||||
retention_days=Config.AUDIT_LOG_RETENTION_DAYS,
|
||||
)
|
||||
|
||||
|
||||
@site_admin.route("/audit/purge", methods=["POST"])
|
||||
@admin_required
|
||||
def audit_purge():
|
||||
deleted = db.purge_old_audit_events(Config.AUDIT_LOG_RETENTION_DAYS)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "audit.purged",
|
||||
details={"deleted_count": deleted, "retention_days": Config.AUDIT_LOG_RETENTION_DAYS},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash(f"Purged {deleted} audit log entries older than {Config.AUDIT_LOG_RETENTION_DAYS} days.", "success")
|
||||
return redirect(url_for("site_admin.audit_log"))
|
||||
|
||||
@@ -53,11 +53,31 @@ class Config:
|
||||
MAIL_USE_TLS = _as_bool(os.getenv("MAIL_USE_TLS"), default=True)
|
||||
MAIL_TIMEOUT = int(os.getenv("MAIL_TIMEOUT") or "15")
|
||||
|
||||
# ── Reverse-Proxy ─────────────────────────────────────────
|
||||
# Anzahl der vorgelagerten Proxy-Ebenen (z.B. Nginx + Coolify-Traefik).
|
||||
# ProxyFix liest X-Forwarded-For entsprechend aus und liefert die echte Client-IP.
|
||||
# Auf 0 setzen, wenn Flask direkt erreichbar ist (kein Proxy).
|
||||
PROXY_COUNT = int(os.getenv("PROXY_COUNT") or "1")
|
||||
|
||||
# ── Datenschutz / DSGVO ───────────────────────────────────
|
||||
# Version der Datenschutzerklärung. Wird in create_app() automatisch als
|
||||
# SHA-256-Hash der privacy_policy.html berechnet (erste 6 Zeichen, Großbuchstaben).
|
||||
# Ändert sich der Seiteninhalt, ändert sich der Hash → alle Nutzer müssen
|
||||
# beim nächsten Login erneut zustimmen.
|
||||
# Kann über die ENV-Variable PRIVACY_POLICY_VERSION manuell überschrieben werden.
|
||||
PRIVACY_POLICY_VERSION = os.getenv("PRIVACY_POLICY_VERSION") or "INIT00"
|
||||
|
||||
# ── Standard-Berechtigungen neuer Gruppenmitglieder ───────
|
||||
INVITE_EXPIRY_HOURS = int(os.getenv("INVITE_EXPIRY_HOURS") or "72")
|
||||
INVITE_MAX_ACTIVE_PER_GROUP = int(os.getenv("INVITE_MAX_ACTIVE_PER_GROUP") or "200")
|
||||
INVITE_RESEND_COOLDOWN_SECONDS = int(os.getenv("INVITE_RESEND_COOLDOWN_SECONDS") or "120")
|
||||
|
||||
# ── Audit-Log-Aufbewahrung ────────────────────────────────
|
||||
# Audit-Log-Einträge, die älter als dieser Wert (in Tagen) sind, werden automatisch gelöscht.
|
||||
# IP-Adressen gelten als personenbezogene Daten (DSGVO Art. 4 Nr. 1); nach 90 Tagen sollten
|
||||
# diese nicht mehr benötigt werden. Auf 0 setzen, um automatisches Löschen zu deaktivieren.
|
||||
AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS") or "90")
|
||||
|
||||
DEFAULT_PERMISSIONS = {
|
||||
"view_dashboard": True,
|
||||
"view_players": True,
|
||||
|
||||
14
web/limiter.py
Normal file
14
web/limiter.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
MCLogger – Rate-Limiter Singleton
|
||||
Shared across app.py and all blueprints to avoid circular imports.
|
||||
"""
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
# In-memory storage is fine for single-process / single-worker deployments.
|
||||
# For multi-worker gunicorn, set RATELIMIT_STORAGE_URI=redis://... in ENV.
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri="memory://",
|
||||
default_limits=[],
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import smtplib
|
||||
from html import escape
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
|
||||
from config import Config
|
||||
|
||||
@@ -10,13 +12,92 @@ def build_from_header(from_email: str, from_name: str | None = None) -> str:
|
||||
return from_email
|
||||
|
||||
|
||||
def force_https_url(url: str) -> str:
|
||||
if url.startswith("http://"):
|
||||
return "https://" + url[len("http://"):]
|
||||
return url
|
||||
|
||||
def send_mail(settings: dict, recipient: str, subject: str, text_body: str):
|
||||
|
||||
def build_invite_email(
|
||||
username: str,
|
||||
invite_url: str,
|
||||
expiry_text: str,
|
||||
group_name: str | None = None,
|
||||
role_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
safe_user = escape(username)
|
||||
safe_url = escape(invite_url)
|
||||
safe_expiry = escape(expiry_text)
|
||||
safe_group = escape(group_name) if group_name else None
|
||||
safe_role = escape(role_name) if role_name else None
|
||||
|
||||
if safe_group:
|
||||
role_part = f" as <strong>{safe_role}</strong>" if safe_role else ""
|
||||
intro_html = (
|
||||
f"You have been invited to join the group <strong>{safe_group}</strong> "
|
||||
f"on MCLogger{role_part}."
|
||||
)
|
||||
role_text = f" as {role_name}" if role_name else ""
|
||||
intro_text = f"You have been invited to join the group '{group_name}' on MCLogger{role_text}."
|
||||
else:
|
||||
intro_html = "You have been invited to create an account on <strong>MCLogger</strong>."
|
||||
intro_text = "You have been invited to create an account on MCLogger."
|
||||
|
||||
text_body = (
|
||||
f"Hello {username},\n\n"
|
||||
f"{intro_text}\n"
|
||||
f"Open this link to create your account:\n\n{invite_url}\n\n"
|
||||
f"This invite expires {expiry_text}.\n"
|
||||
)
|
||||
|
||||
html_body = f"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f6f8fb;font-family:Arial,Helvetica,sans-serif;color:#1f2937;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#111827;color:#ffffff;padding:16px 20px;font-size:18px;font-weight:700;">
|
||||
MCLogger Invitation
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 20px 20px 20px;font-size:14px;line-height:1.6;">
|
||||
<p style="margin:0 0 12px 0;">Hello <strong>{safe_user}</strong>,</p>
|
||||
<p style="margin:0 0 12px 0;">{intro_html}</p>
|
||||
<p style="margin:0 0 20px 0;">Click the button below to create your account:</p>
|
||||
<p style="margin:0 0 20px 0;">
|
||||
<a href="{safe_url}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:700;padding:11px 16px;border-radius:8px;">Create Account</a>
|
||||
</p>
|
||||
<p style="margin:0 0 12px 0;">This invite expires <strong>{safe_expiry}</strong>.</p>
|
||||
<p style="margin:16px 0 0 0;font-size:12px;color:#6b7280;word-break:break-all;">If the button does not work, use this link:<br>{safe_url}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
|
||||
return text_body, html_body
|
||||
|
||||
|
||||
|
||||
def send_mail(settings: dict, recipient: str, subject: str, text_body: str, html_body: str | None = None):
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = build_from_header(settings["from_email"], settings.get("from_name"))
|
||||
msg["To"] = recipient
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
sender_domain = (settings.get("from_email", "noreply@example.com").split("@")[-1] or "example.com")
|
||||
msg["Message-ID"] = make_msgid(domain=sender_domain)
|
||||
msg.set_content(text_body)
|
||||
if html_body:
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
|
||||
with smtplib.SMTP(settings["host"], settings["port"], timeout=Config.MAIL_TIMEOUT) as smtp:
|
||||
smtp.ehlo()
|
||||
|
||||
374
web/panel_db.py
374
web/panel_db.py
@@ -103,7 +103,8 @@ PANEL_SCHEMA = [
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS group_invites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
group_id INT NOT NULL,
|
||||
group_id INT NULL,
|
||||
is_site_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
invited_username VARCHAR(50) NOT NULL,
|
||||
invited_email VARCHAR(255) NOT NULL,
|
||||
role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer',
|
||||
@@ -115,10 +116,83 @@ PANEL_SCHEMA = [
|
||||
send_count INT NOT NULL DEFAULT 0,
|
||||
accepted_at DATETIME NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
UNIQUE KEY uq_group_pending_invite_email (group_id, invited_email, revoked_at, accepted_at),
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(50) NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50) NULL,
|
||||
entity_id VARCHAR(100) NULL,
|
||||
details JSON NULL,
|
||||
group_id INT NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_audit_actor (actor_user_id),
|
||||
INDEX idx_audit_group (group_id),
|
||||
INDEX idx_audit_action (action),
|
||||
INDEX idx_audit_ts (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
|
||||
"""CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
note VARCHAR(255) NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Versioned migrations (applied once, tracked in schema_migrations)
|
||||
# Each entry: (version_int, sql_statement, human_readable_note)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PANEL_MIGRATIONS = [
|
||||
(1,
|
||||
"ALTER TABLE group_members MODIFY COLUMN role "
|
||||
"ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'",
|
||||
"Extend group_members.role ENUM"),
|
||||
(2,
|
||||
"ALTER TABLE group_invites MODIFY COLUMN role "
|
||||
"ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'",
|
||||
"Extend group_invites.role ENUM"),
|
||||
(3,
|
||||
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS last_sent_at DATETIME NULL",
|
||||
"Add group_invites.last_sent_at"),
|
||||
(4,
|
||||
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS send_count INT NOT NULL DEFAULT 0",
|
||||
"Add group_invites.send_count"),
|
||||
(5,
|
||||
"ALTER TABLE group_invites MODIFY COLUMN group_id INT NULL",
|
||||
"Allow group_invites.group_id to be NULL"),
|
||||
(6,
|
||||
"ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS is_site_admin TINYINT(1) NOT NULL DEFAULT 0",
|
||||
"Add group_invites.is_site_admin"),
|
||||
(7,
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS consent_version VARCHAR(20) NULL",
|
||||
"Add users.consent_version for GDPR consent tracking"),
|
||||
(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 = [
|
||||
@@ -149,33 +223,33 @@ CREDS_SCHEMA = [
|
||||
|
||||
|
||||
def init_databases():
|
||||
"""Erstellt alle benötigten Tabellen falls nicht vorhanden."""
|
||||
"""Creates all required tables and applies pending schema migrations."""
|
||||
import logging
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
panel = get_panel_db()
|
||||
try:
|
||||
with panel.cursor() as cur:
|
||||
# Create tables (idempotent)
|
||||
for stmt in PANEL_SCHEMA:
|
||||
cur.execute(stmt)
|
||||
# Best-effort migrations for existing installs.
|
||||
|
||||
# Determine already-applied migration versions
|
||||
cur.execute("SELECT version FROM schema_migrations")
|
||||
applied = {row["version"] for row in cur.fetchall()}
|
||||
|
||||
for version, sql, note in PANEL_MIGRATIONS:
|
||||
if version in applied:
|
||||
continue
|
||||
try:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"ALTER TABLE group_members MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
|
||||
"INSERT IGNORE INTO schema_migrations (version, note) VALUES (%s, %s)",
|
||||
(version, note),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute(
|
||||
"ALTER TABLE group_invites MODIFY role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer'"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE group_invites ADD COLUMN last_sent_at DATETIME NULL")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE group_invites ADD COLUMN send_count INT NOT NULL DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
_log.info("Migration %d applied: %s", version, note)
|
||||
except Exception as exc:
|
||||
_log.warning("Migration %d skipped (%s): %s", version, note, exc)
|
||||
finally:
|
||||
panel.close()
|
||||
|
||||
@@ -187,6 +261,9 @@ def init_databases():
|
||||
finally:
|
||||
creds.close()
|
||||
|
||||
# Auto-Bereinigung: Audit-Log-Einträge älter als Retention-Tage löschen
|
||||
purge_old_audit_events(Config.AUDIT_LOG_RETENTION_DAYS)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Nutzer
|
||||
@@ -229,13 +306,13 @@ def create_user_for_group(username: str, email: str, password: str, group_id: in
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_group_invite(group_id: int, username: str, email: str, role: str, created_by_user_id: int) -> str:
|
||||
def create_group_invite(group_id, username: str, email: str, role: str, created_by_user_id: int, is_site_admin: bool = False) -> str:
|
||||
expires_at = datetime.utcnow() + timedelta(hours=Config.INVITE_EXPIRY_HOURS)
|
||||
token = secrets.token_urlsafe(32)
|
||||
_panel_query(
|
||||
"INSERT INTO group_invites (group_id, invited_username, invited_email, role, token, created_by_user_id, expires_at, last_sent_at, send_count) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,NULL,0)",
|
||||
(group_id, username, email, role, token, created_by_user_id, expires_at),
|
||||
"INSERT INTO group_invites (group_id, invited_username, invited_email, role, token, created_by_user_id, expires_at, last_sent_at, send_count, is_site_admin) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,NULL,0,%s)",
|
||||
(group_id, username, email, role, token, created_by_user_id, expires_at, int(is_site_admin)),
|
||||
write=True,
|
||||
)
|
||||
return token
|
||||
@@ -291,7 +368,7 @@ def get_invite_by_token(token: str):
|
||||
return _panel_query(
|
||||
"SELECT gi.*, g.name AS group_name, u.username AS created_by_username "
|
||||
"FROM group_invites gi "
|
||||
"JOIN user_groups g ON g.id = gi.group_id "
|
||||
"LEFT JOIN user_groups g ON g.id = gi.group_id "
|
||||
"JOIN users u ON u.id = gi.created_by_user_id "
|
||||
"WHERE gi.token=%s",
|
||||
(token,),
|
||||
@@ -307,6 +384,64 @@ def revoke_group_invite(invite_id: int, group_id: int):
|
||||
)
|
||||
|
||||
|
||||
def list_all_active_invites():
|
||||
"""All pending invites across every group (for site admin users page)."""
|
||||
return _panel_query(
|
||||
"SELECT gi.*, g.name AS group_name, u.username AS created_by_username "
|
||||
"FROM group_invites gi "
|
||||
"LEFT JOIN user_groups g ON g.id = gi.group_id "
|
||||
"JOIN users u ON u.id = gi.created_by_user_id "
|
||||
"WHERE gi.accepted_at IS NULL AND gi.revoked_at IS NULL AND gi.expires_at > UTC_TIMESTAMP() "
|
||||
"ORDER BY gi.created_at DESC"
|
||||
)
|
||||
|
||||
|
||||
def get_invite_by_id_global(invite_id: int):
|
||||
return _panel_query(
|
||||
"SELECT gi.*, g.name AS group_name, u.username AS created_by_username "
|
||||
"FROM group_invites gi "
|
||||
"LEFT JOIN user_groups g ON g.id = gi.group_id "
|
||||
"JOIN users u ON u.id = gi.created_by_user_id "
|
||||
"WHERE gi.id=%s",
|
||||
(invite_id,),
|
||||
fetchone=True,
|
||||
)
|
||||
|
||||
|
||||
def revoke_invite_global(invite_id: int):
|
||||
_panel_query(
|
||||
"UPDATE group_invites SET revoked_at=UTC_TIMESTAMP() WHERE id=%s AND accepted_at IS NULL AND revoked_at IS NULL",
|
||||
(invite_id,),
|
||||
write=True,
|
||||
)
|
||||
|
||||
|
||||
def mark_invite_sent_global(invite_id: int):
|
||||
_panel_query(
|
||||
"UPDATE group_invites SET last_sent_at=UTC_TIMESTAMP(), send_count=send_count+1 WHERE id=%s",
|
||||
(invite_id,),
|
||||
write=True,
|
||||
)
|
||||
|
||||
|
||||
def get_active_invite_by_email_global(email: str):
|
||||
return _panel_query(
|
||||
"SELECT * FROM group_invites WHERE invited_email=%s "
|
||||
"AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()",
|
||||
(email,),
|
||||
fetchone=True,
|
||||
)
|
||||
|
||||
|
||||
def get_active_invite_by_username_global(username: str):
|
||||
return _panel_query(
|
||||
"SELECT * FROM group_invites WHERE invited_username=%s "
|
||||
"AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()",
|
||||
(username,),
|
||||
fetchone=True,
|
||||
)
|
||||
|
||||
|
||||
def mark_group_invite_sent(invite_id: int, group_id: int):
|
||||
_panel_query(
|
||||
"UPDATE group_invites SET last_sent_at=UTC_TIMESTAMP(), send_count=send_count+1 WHERE id=%s AND group_id=%s",
|
||||
@@ -342,6 +477,12 @@ def accept_group_invite(token: str, password: str) -> dict | None:
|
||||
(invite["invited_username"], invite["invited_email"], pw_hash, salt, 0),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
site_admin_flag = int(bool(invite.get("is_site_admin")))
|
||||
cur.execute(
|
||||
"UPDATE users SET is_site_admin=%s WHERE id=%s",
|
||||
(site_admin_flag, user_id),
|
||||
)
|
||||
if invite["group_id"] is not None:
|
||||
cur.execute(
|
||||
"INSERT INTO group_members (user_id, group_id, role, permissions) VALUES (%s,%s,%s,%s)",
|
||||
(user_id, invite["group_id"], invite["role"], json.dumps(permissions)),
|
||||
@@ -609,3 +750,184 @@ def delete_site_mail_settings():
|
||||
def has_site_mail_settings() -> bool:
|
||||
row = _creds_query("SELECT id FROM site_mail_settings WHERE config_key=%s", ("primary",), fetchone=True)
|
||||
return row is not None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DSGVO-Einwilligung
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_user_consent_version(user_id: int) -> str | None:
|
||||
row = _panel_query(
|
||||
"SELECT consent_version FROM users WHERE id = %s", (user_id,), fetchone=True
|
||||
)
|
||||
return row["consent_version"] if row else None
|
||||
|
||||
|
||||
def set_user_consent(user_id: int, policy_version: str) -> None:
|
||||
_panel_query(
|
||||
"UPDATE users SET consent_version = %s, consented_at = UTC_TIMESTAMP() WHERE id = %s",
|
||||
(policy_version, user_id),
|
||||
write=True,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def log_audit_event(
|
||||
actor_user_id,
|
||||
actor_username: str | None,
|
||||
action: str,
|
||||
entity_type: str | None = None,
|
||||
entity_id: str | None = None,
|
||||
details: dict | None = None,
|
||||
group_id: int | None = None,
|
||||
ip_address: str | None = None,
|
||||
):
|
||||
"""Records an audit event. Never raises — audit log must not break the main flow."""
|
||||
try:
|
||||
_panel_query(
|
||||
"INSERT INTO audit_log "
|
||||
"(actor_user_id, actor_username, action, entity_type, entity_id, details, group_id, ip_address) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
|
||||
(
|
||||
actor_user_id,
|
||||
actor_username,
|
||||
action,
|
||||
entity_type,
|
||||
str(entity_id) if entity_id is not None else None,
|
||||
json.dumps(details) if details else None,
|
||||
group_id,
|
||||
ip_address,
|
||||
),
|
||||
write=True,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to write audit event: %s", action)
|
||||
|
||||
|
||||
def get_audit_log(
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
action_filter: str | None = None,
|
||||
group_id_filter: int | None = None,
|
||||
actor_filter: str | None = None,
|
||||
):
|
||||
offset = (page - 1) * per_page
|
||||
conditions: list[str] = []
|
||||
args: list = []
|
||||
|
||||
if action_filter:
|
||||
conditions.append("al.action LIKE %s")
|
||||
args.append(f"%{action_filter}%")
|
||||
if group_id_filter:
|
||||
conditions.append("al.group_id = %s")
|
||||
args.append(group_id_filter)
|
||||
if actor_filter:
|
||||
conditions.append("al.actor_username LIKE %s")
|
||||
args.append(f"%{actor_filter}%")
|
||||
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
|
||||
count_row = _panel_query(
|
||||
f"SELECT COUNT(*) AS c FROM audit_log al {where}", args, fetchone=True
|
||||
)
|
||||
total = int(count_row["c"]) if count_row else 0
|
||||
|
||||
rows = _panel_query(
|
||||
f"SELECT al.*, g.name AS group_name "
|
||||
f"FROM audit_log al "
|
||||
f"LEFT JOIN user_groups g ON g.id = al.group_id "
|
||||
f"{where} ORDER BY al.created_at DESC LIMIT %s OFFSET %s",
|
||||
args + [per_page, offset],
|
||||
)
|
||||
# Ensure details is always a dict (pymysql may return JSON as string)
|
||||
for row in (rows or []):
|
||||
d = row.get("details")
|
||||
if isinstance(d, str):
|
||||
try:
|
||||
row["details"] = json.loads(d)
|
||||
except Exception:
|
||||
row["details"] = {}
|
||||
return rows, total
|
||||
|
||||
|
||||
def get_audit_log_distinct_actions() -> list[str]:
|
||||
rows = _panel_query("SELECT DISTINCT action FROM audit_log ORDER BY action")
|
||||
return [r["action"] for r in rows] if rows else []
|
||||
|
||||
|
||||
def purge_old_audit_events(retention_days: int) -> int:
|
||||
"""Deletes audit log entries older than *retention_days* days.
|
||||
Returns the number of deleted rows. Skips if retention_days <= 0."""
|
||||
import logging
|
||||
_log = logging.getLogger(__name__)
|
||||
if retention_days <= 0:
|
||||
return 0
|
||||
try:
|
||||
conn = get_panel_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM audit_log WHERE created_at < UTC_TIMESTAMP() - INTERVAL %s DAY",
|
||||
(retention_days,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if deleted:
|
||||
_log.info("Purged %d audit log entries older than %d days", deleted, retention_days)
|
||||
return deleted
|
||||
except Exception as exc:
|
||||
_log.warning("Failed to purge audit log: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ Flask==3.1.0
|
||||
PyMySQL==1.1.1
|
||||
cryptography==42.0.8
|
||||
gunicorn==22.0.0
|
||||
flask-limiter==3.9.0
|
||||
|
||||
@@ -20,6 +20,9 @@ GROUP_ROLE_OPTIONS = [
|
||||
GROUP_ROLE_SET = {role for role, _ in GROUP_ROLE_OPTIONS} | {"admin", "member"}
|
||||
GROUP_MANAGEMENT_ROLES = {"group_owner", "group_admin", "admin"}
|
||||
|
||||
# Roles that only site admins may assign or revoke
|
||||
OWNER_ONLY_ROLES = {"group_owner"}
|
||||
|
||||
|
||||
def can_manage_group(role: str | None) -> bool:
|
||||
return role in GROUP_MANAGEMENT_ROLES
|
||||
|
||||
31
web/templates/429.html
Normal file
31
web/templates/429.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!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>Too Many Requests — MCLogger</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">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="text-center p-4" style="max-width:420px">
|
||||
<i class="bi bi-shield-exclamation text-warning" style="font-size:3rem"></i>
|
||||
<h2 class="fw-bold mt-3">Too Many Requests</h2>
|
||||
<p class="text-muted">You have submitted this form too frequently. Please wait
|
||||
{% if retry_after %}
|
||||
<strong>{{ retry_after }} second{{ 's' if retry_after != 1 }}</strong>
|
||||
{% else %}
|
||||
a moment
|
||||
{% endif %}
|
||||
before trying again.
|
||||
</p>
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary mt-2">
|
||||
<i class="bi bi-arrow-left me-1"></i>Go back
|
||||
</a>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
196
web/templates/admin/audit_log.html
Normal file
196
web/templates/admin/audit_log.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Audit Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h4>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted small">{{ total }} event{{ 's' if total != 1 }}</span>
|
||||
{% if retention_days > 0 %}
|
||||
<span class="badge bg-secondary" title="Entries older than {{ retention_days }} days are deleted automatically on startup">
|
||||
<i class="bi bi-clock-history me-1"></i>Retention: {{ retention_days }}d
|
||||
</span>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('site_admin.audit_purge') }}"
|
||||
onsubmit="return confirm('Delete all audit entries older than {{ retention_days }} days?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
{% if retention_days <= 0 %}disabled title="Retention disabled (AUDIT_LOG_RETENTION_DAYS=0)"{% endif %}>
|
||||
<i class="bi bi-trash3 me-1"></i>Purge now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" action="{{ url_for('site_admin.audit_log') }}" class="card border-secondary mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm mb-1">Action</label>
|
||||
<select name="action" class="form-select form-select-sm bg-dark text-white border-secondary">
|
||||
<option value="">— All actions —</option>
|
||||
{% for a in actions %}
|
||||
<option value="{{ a }}" {{ 'selected' if action_filter == a }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm mb-1">Group</label>
|
||||
<select name="group_id" class="form-select form-select-sm bg-dark text-white border-secondary">
|
||||
<option value="">— All groups —</option>
|
||||
{% for g in all_groups %}
|
||||
<option value="{{ g.id }}" {{ 'selected' if group_filter == g.id }}>{{ g.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm mb-1">Actor</label>
|
||||
<input type="text" name="actor" class="form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Username…" value="{{ actor_filter }}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">
|
||||
<i class="bi bi-funnel-fill me-1"></i>Filter
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.audit_log') }}" class="btn btn-sm btn-outline-secondary w-100">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border-secondary">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-secondary text-dark">
|
||||
<tr>
|
||||
<th style="width:155px">Timestamp (UTC)</th>
|
||||
<th style="width:130px">Actor</th>
|
||||
<th style="width:180px">Action</th>
|
||||
<th style="width:90px">Entity</th>
|
||||
<th style="width:80px">Entity ID</th>
|
||||
<th style="width:120px">Group</th>
|
||||
<th>Details</th>
|
||||
<th style="width:110px">IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ row.created_at | fmt_dt }}</td>
|
||||
<td>
|
||||
{% if row.actor_username %}
|
||||
<span class="text-info">{{ row.actor_username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set action_class = {
|
||||
'user.login': 'badge bg-success',
|
||||
'user.login_failed': 'badge bg-danger',
|
||||
'user.password_changed': 'badge bg-warning text-dark',
|
||||
'session.logout': 'badge bg-secondary',
|
||||
'admin.login': 'badge bg-warning text-dark',
|
||||
'admin.login_failed': 'badge bg-danger',
|
||||
'admin.view_users': 'badge bg-dark border border-secondary',
|
||||
'admin.view_user': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group_members': 'badge bg-dark border border-secondary',
|
||||
'admin.view_audit_log': 'badge bg-dark border border-secondary',
|
||||
'invite.created': 'badge bg-primary',
|
||||
'invite.accepted': 'badge bg-success',
|
||||
'invite.revoked': 'badge bg-secondary',
|
||||
'invite.resent': 'badge bg-info text-dark',
|
||||
'member.added': 'badge bg-primary',
|
||||
'member.removed': 'badge bg-danger',
|
||||
'member.role_changed': 'badge bg-warning text-dark',
|
||||
'member.updated': 'badge bg-warning text-dark',
|
||||
'group.created': 'badge bg-success',
|
||||
'group.updated': 'badge bg-secondary',
|
||||
'group.deleted': 'badge bg-danger',
|
||||
'db.credentials_changed': 'badge bg-warning text-dark',
|
||||
'db.credentials_deleted': 'badge bg-danger',
|
||||
'user.updated': 'badge bg-secondary',
|
||||
'user.deleted': 'badge bg-danger',
|
||||
'mail.settings_saved': 'badge bg-info text-dark',
|
||||
'mail.settings_deleted': 'badge bg-danger',
|
||||
'consent.given': 'badge bg-success',
|
||||
'consent.declined': 'badge bg-warning text-dark',
|
||||
'audit.purged': 'badge bg-danger',
|
||||
'panel.view_players': 'badge bg-dark border border-info',
|
||||
'panel.view_player': 'badge bg-info text-dark',
|
||||
'panel.view_sessions': 'badge bg-dark border border-info',
|
||||
'panel.view_chat': 'badge bg-dark border border-info',
|
||||
'panel.view_commands': 'badge bg-dark border border-info',
|
||||
'panel.view_deaths': 'badge bg-dark border border-info',
|
||||
'panel.view_blocks': 'badge bg-dark border border-info',
|
||||
'panel.view_proxy': 'badge bg-dark border border-info',
|
||||
'panel.view_server_events': 'badge bg-dark border border-info',
|
||||
'panel.view_perms': 'badge bg-dark border border-info',
|
||||
'player.data_exported': 'badge bg-info text-dark',
|
||||
'player.data_deleted': 'badge bg-danger',
|
||||
} %}
|
||||
<span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">
|
||||
{{ row.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ row.entity_type or '—' }}</td>
|
||||
<td class="text-muted small font-monospace">{{ row.entity_id or '—' }}</td>
|
||||
<td class="small">
|
||||
{% if row.group_name %}
|
||||
<span class="badge bg-dark border border-secondary">{{ row.group_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted font-monospace">
|
||||
{% if row.details %}
|
||||
{% set d = row.details if row.details is mapping else {} %}
|
||||
{% for k, v in d.items() %}
|
||||
<span class="me-2"><strong>{{ k }}:</strong> {{ v }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small font-monospace">{{ row.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<i class="bi bi-journal-x me-2"></i>No audit events found.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li class="page-item {{ 'disabled' if page <= 1 }}">
|
||||
<a class="page-link" href="{{ url_for('site_admin.audit_log', page=page-1, action=action_filter, group_id=group_filter, actor=actor_filter) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% for p in range([1, page-2]|max, [total_pages+1, page+3]|min) %}
|
||||
<li class="page-item {{ 'active' if p == page }}">
|
||||
<a class="page-link" href="{{ url_for('site_admin.audit_log', page=p, action=action_filter, group_id=group_filter, actor=actor_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {{ 'disabled' if page >= total_pages }}">
|
||||
<a class="page-link" href="{{ url_for('site_admin.audit_log', page=page+1, action=action_filter, group_id=group_filter, actor=actor_filter) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.groups' }}">Groups</a>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.users' }}">Users</a>
|
||||
<a href="{{ url_for('site_admin.mail_settings') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.mail_settings' }}">Mail</a>
|
||||
<a href="{{ url_for('site_admin.audit_log') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.audit_log' }}">Audit Log</a>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="d-inline">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">
|
||||
@@ -40,6 +41,12 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
<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 — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -129,4 +129,120 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Recent Audit Activity ──────────────────────────────── -->
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-journal-text me-2"></i>Recent Audit Activity</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if retention_days > 0 %}
|
||||
<span class="badge bg-secondary small">
|
||||
<i class="bi bi-clock-history me-1"></i>Retention: {{ retention_days }}d
|
||||
</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('site_admin.audit_log') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-right-circle me-1"></i>Full log
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-secondary text-dark">
|
||||
<tr>
|
||||
<th style="width:155px">Timestamp (UTC)</th>
|
||||
<th style="width:130px">Actor</th>
|
||||
<th style="width:180px">Action</th>
|
||||
<th style="width:120px">Group</th>
|
||||
<th>Details</th>
|
||||
<th style="width:110px">IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in recent_audit %}
|
||||
{% set action_class = {
|
||||
'user.login': 'badge bg-success',
|
||||
'user.login_failed': 'badge bg-danger',
|
||||
'user.password_changed': 'badge bg-warning text-dark',
|
||||
'session.logout': 'badge bg-secondary',
|
||||
'admin.login': 'badge bg-warning text-dark',
|
||||
'admin.login_failed': 'badge bg-danger',
|
||||
'admin.view_users': 'badge bg-dark border border-secondary',
|
||||
'admin.view_user': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group_members': 'badge bg-dark border border-secondary',
|
||||
'admin.view_audit_log': 'badge bg-dark border border-secondary',
|
||||
'invite.created': 'badge bg-primary',
|
||||
'invite.accepted': 'badge bg-success',
|
||||
'invite.revoked': 'badge bg-secondary',
|
||||
'invite.resent': 'badge bg-info text-dark',
|
||||
'member.added': 'badge bg-primary',
|
||||
'member.removed': 'badge bg-danger',
|
||||
'member.role_changed': 'badge bg-warning text-dark',
|
||||
'group.created': 'badge bg-success',
|
||||
'group.updated': 'badge bg-secondary',
|
||||
'group.deleted': 'badge bg-danger',
|
||||
'db.credentials_changed': 'badge bg-warning text-dark',
|
||||
'db.credentials_deleted': 'badge bg-danger',
|
||||
'user.updated': 'badge bg-secondary',
|
||||
'user.deleted': 'badge bg-danger',
|
||||
'mail.settings_saved': 'badge bg-info text-dark',
|
||||
'mail.settings_deleted': 'badge bg-danger',
|
||||
'consent.given': 'badge bg-success',
|
||||
'consent.declined': 'badge bg-warning text-dark',
|
||||
'audit.purged': 'badge bg-danger', 'panel.view_players': 'badge bg-dark border border-info',
|
||||
'panel.view_player': 'badge bg-info text-dark',
|
||||
'panel.view_sessions': 'badge bg-dark border border-info',
|
||||
'panel.view_chat': 'badge bg-dark border border-info',
|
||||
'panel.view_commands': 'badge bg-dark border border-info',
|
||||
'panel.view_deaths': 'badge bg-dark border border-info',
|
||||
'panel.view_blocks': 'badge bg-dark border border-info',
|
||||
'panel.view_proxy': 'badge bg-dark border border-info',
|
||||
'panel.view_server_events': 'badge bg-dark border border-info',
|
||||
'panel.view_perms': 'badge bg-dark border border-info', } %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ row.created_at | fmt_dt }}</td>
|
||||
<td>
|
||||
{% if row.actor_username %}
|
||||
<span class="text-info">{{ row.actor_username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">
|
||||
{{ row.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">
|
||||
{% if row.group_name %}
|
||||
<span class="badge bg-dark border border-secondary">{{ row.group_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted font-monospace">
|
||||
{% if row.details %}
|
||||
{% set d = row.details if row.details is mapping else {} %}
|
||||
{% for k, v in d.items() %}
|
||||
<span class="me-2"><strong>{{ k }}:</strong> {{ v }}</span>
|
||||
{% endfor %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small font-monospace">{{ row.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-3">
|
||||
<i class="bi bi-journal-x me-2"></i>No audit events yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="row g-3">
|
||||
<!-- Current members -->
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card border-secondary mb-3">
|
||||
<div class="card-header"><i class="bi bi-people-fill me-2"></i>Current Members ({{ members|length }})</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
@@ -55,12 +55,64 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending invitations -->
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-envelope-paper-fill me-2"></i>Pending Invitations ({{ pending_invites|length }})</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>User</th><th>Role</th><th>Expires</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for invite in pending_invites %}
|
||||
{% set invite_url = url_for('auth.accept_invite', token=invite.token, _external=True) %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>{{ invite.invited_username }}</div>
|
||||
<div class="small text-muted">{{ invite.invited_email }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if invite.role in management_roles %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(invite.role) }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ role_label(invite.role) }}</span>
|
||||
{% endif %}
|
||||
<div class="small text-muted mt-1">Sent: {{ invite.send_count or 0 }}</div>
|
||||
</td>
|
||||
<td class="small text-muted">{{ invite.expires_at | fmt_dt }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-none" id="invite-url-{{ invite.id }}">{{ invite_url }}</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary copy-btn" data-target="#invite-url-{{ invite.id }}" title="Copy invite link">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('site_admin.group_invite_resend', group_id=group.id, invite_id=invite.id) }}" class="d-inline">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info" title="Resend">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('site_admin.group_invite_revoke', group_id=group.id, invite_id=invite.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Revoke invitation for {{ invite.invited_username }}?')">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Revoke">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-muted text-center py-3">No pending invitations</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add user -->
|
||||
<!-- Right column: add + invite -->
|
||||
<div class="col-md-5">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add User</div>
|
||||
<!-- Add existing user -->
|
||||
<div class="card border-secondary mb-3">
|
||||
<div class="card-header"><i class="bi bi-person-plus-fill me-2"></i>Add Existing User</div>
|
||||
<div class="card-body">
|
||||
{% if non_members %}
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_add', group_id=group.id) }}">
|
||||
@@ -90,6 +142,40 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite new user (site admin can assign any role including group_owner) -->
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header"><i class="bi bi-envelope-plus-fill me-2"></i>Invite New User</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('site_admin.group_member_invite', group_id=group.id) }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" maxlength="50" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-control" maxlength="255" required>
|
||||
<div class="form-text">The user will receive a link and set their own password.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select name="role" class="form-select">
|
||||
{% for role, label in role_options %}
|
||||
<option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-warning">
|
||||
<i class="bi bi-shield-fill me-1"></i>As Site Admin you can assign <strong>Group Owner</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-envelope-plus-fill me-1"></i>Create Invitation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ 'Edit User' if user else 'New User' }}{% endblock %}
|
||||
{% block title %}{{ 'Edit User' if user else 'Invite New User' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">{{ 'Edit User: ' ~ user.username if user else 'New User' }}</h2>
|
||||
<h2 class="mb-0">{{ 'Edit User: ' ~ user.username if user else 'Invite New User' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
@@ -24,14 +24,42 @@
|
||||
<input type="email" name="email" class="form-control" required
|
||||
value="{{ user.email if user else request.form.get('email', '') }}">
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ 'New Password (leave blank = unchanged)' if user else 'Password *' }}</label>
|
||||
<input type="password" name="{{ 'new_password' if user else 'password' }}" class="form-control"
|
||||
{{ '' if user else 'required' }}>
|
||||
{% if not user %}
|
||||
<div class="form-text">Minimum 8 characters recommended.</div>
|
||||
{% endif %}
|
||||
<label class="form-label">New Password <span class="text-muted">(leave blank = unchanged)</span></label>
|
||||
<input type="password" name="new_password" class="form-control">
|
||||
</div>
|
||||
{% else %}
|
||||
{# ── Invite form: group + role (optional) ── #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Group <span class="text-muted">(optional)</span></label>
|
||||
<select name="group_id" id="invite_group" class="form-select" onchange="toggleRoleField()">
|
||||
<option value="">— No group —</option>
|
||||
{% for g in (groups or []) %}
|
||||
<option value="{{ g.id }}"
|
||||
{% if request.form.get('group_id')|string == g.id|string %}selected{% endif %}>
|
||||
{{ g.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">If selected, the user will be added to this group upon accepting the invite.</div>
|
||||
</div>
|
||||
<div class="mb-3" id="role_field" style="display:none;">
|
||||
<label class="form-label">Group Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="auditor">Auditor</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="group_admin">Group Admin</option>
|
||||
<option value="group_owner">Group Owner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-info py-2 mb-3">
|
||||
<i class="bi bi-envelope-check me-1"></i>
|
||||
The user will receive an email with a link to set their own password.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
|
||||
@@ -44,7 +72,11 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ 'Save' if user else 'Create' }}
|
||||
{% if user %}
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
{% else %}
|
||||
<i class="bi bi-envelope-fill me-1"></i>Send Invitation
|
||||
{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
@@ -53,4 +85,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not user %}
|
||||
<script>
|
||||
function toggleRoleField() {
|
||||
var gid = document.getElementById('invite_group').value;
|
||||
document.getElementById('role_field').style.display = gid ? 'block' : 'none';
|
||||
}
|
||||
// Show role field if a group was pre-selected (e.g. after validation error)
|
||||
document.addEventListener('DOMContentLoaded', toggleRoleField);
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,11 +4,77 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-people-fill me-2"></i>Users</h2>
|
||||
<a href="{{ url_for('site_admin.user_new') }}" class="btn btn-success">
|
||||
<i class="bi bi-person-plus-fill me-1"></i>New User
|
||||
<i class="bi bi-envelope-plus-fill me-1"></i>Invite User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Pending Invitations ── #}
|
||||
{% if pending_invites %}
|
||||
<h5 class="text-muted mb-2"><i class="bi bi-envelope-open me-1"></i>Pending Invitations</h5>
|
||||
<div class="card border-warning mb-4">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0 small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Group</th>
|
||||
<th>Role</th>
|
||||
<th>Expires</th>
|
||||
<th>Sent</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inv in pending_invites %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ inv.invited_username }}</td>
|
||||
<td class="text-muted">{{ inv.invited_email }}</td>
|
||||
<td>
|
||||
{% if inv.group_name %}
|
||||
<span class="badge bg-secondary">{{ inv.group_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ inv.role }}</td>
|
||||
<td class="text-muted">{{ inv.expires_at | fmt_dt }}</td>
|
||||
<td class="text-muted">{{ inv.send_count }}×</td>
|
||||
<td class="text-end">
|
||||
{# Copy link #}
|
||||
{% set invite_url = url_for('auth.accept_invite', token=inv.token, _external=True) %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
title="Copy invite link"
|
||||
onclick="navigator.clipboard.writeText('{{ invite_url }}').then(()=>this.title='Copied!')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
{# Resend #}
|
||||
<form method="post" action="{{ url_for('site_admin.user_invite_resend', invite_id=inv.id) }}" class="d-inline">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info" title="Resend email">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</form>
|
||||
{# Revoke #}
|
||||
<form method="post" action="{{ url_for('site_admin.user_invite_revoke', invite_id=inv.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Revoke invitation for {{ inv.invited_username }}?')">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Revoke">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card border-secondary">
|
||||
<h5 class="text-muted mb-2"><i class="bi bi-people me-1"></i>Registered Users</h5>
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to regular login
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted small">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
84
web/templates/auth/consent.html
Normal file
84
web/templates/auth/consent.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!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>MCLogger – Privacy Consent</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">
|
||||
<style>
|
||||
body { background: #0d1117; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.consent-card { width: 100%; max-width: 600px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="consent-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-check fs-1 text-warning"></i>
|
||||
<h3 class="fw-bold mt-2">Privacy Policy Consent</h3>
|
||||
<p class="text-muted small">Version {{ policy_version }}</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card border-warning mb-4">
|
||||
<div class="card-header bg-transparent border-warning text-warning fw-semibold">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>What data we process
|
||||
</div>
|
||||
<div class="card-body small text-secondary">
|
||||
<p>To operate MCLogger we process the following personal data:</p>
|
||||
<ul class="mb-2">
|
||||
<li><strong>Account data</strong> — username, e-mail address, hashed password (no plain-text storage)</li>
|
||||
<li><strong>Session & security data</strong> — login timestamps, IP addresses (stored for up to 90 days in the audit log)</li>
|
||||
<li><strong>Minecraft server data</strong> — player names, UUIDs, chat messages, commands & block interactions logged by the Minecraft plugin</li>
|
||||
<li><strong>Audit events</strong> — records of actions you perform in the panel (logins, member changes, configuration edits)</li>
|
||||
</ul>
|
||||
<p class="mb-0">
|
||||
<strong>Legal basis:</strong> Art. 6 (1)(b) GDPR — performance of a contract / provision of the service.<br>
|
||||
<strong>Retention:</strong> Audit log entries containing IP addresses are automatically deleted after 90 days.
|
||||
Account data is retained for as long as your account exists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-body small text-secondary">
|
||||
<p class="mb-1">
|
||||
<strong>Your rights (GDPR Art. 15–21):</strong> You may request access to, rectification or deletion of your
|
||||
personal data, as well as data portability, at any time by contacting
|
||||
<a href="mailto:simon@devanturas.net" class="text-warning">simon@devanturas.net</a>.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Read the full <a href="{{ url_for('privacy_policy') }}" target="_blank" class="text-warning">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<button type="submit" name="action" value="accept" class="btn btn-warning w-100 fw-semibold">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>I accept the Privacy Policy
|
||||
</button>
|
||||
<button type="submit" name="action" value="decline"
|
||||
class="btn btn-outline-secondary w-100"
|
||||
onclick="return confirm('Declining will log you out. Are you sure?')">
|
||||
<i class="bi bi-x-circle me-1"></i>Decline & Logout
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted text-center mt-3 small">
|
||||
By accepting you confirm that you have read and understood the Privacy Policy
|
||||
(version {{ policy_version }}).
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,6 +58,9 @@
|
||||
<i class="bi bi-shield-fill me-1"></i>Site Admin Login
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted small">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -171,6 +171,12 @@
|
||||
{% endwith %}
|
||||
|
||||
<main class="px-4 py-3">{% block content %}{% endblock %}</main>
|
||||
<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 — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
</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>
|
||||
@@ -42,6 +43,12 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
<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 — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
66
web/templates/group_admin/player_delete_confirm.html
Normal file
66
web/templates/group_admin/player_delete_confirm.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Delete Player Data{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-7 col-lg-6">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger bg-opacity-75 fw-bold">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Permanently Delete Player Data
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-3">
|
||||
You are about to <strong>permanently delete all logged data</strong> for:
|
||||
</p>
|
||||
|
||||
<div class="alert alert-secondary d-flex align-items-center gap-3 py-2">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/48"
|
||||
class="rounded" alt="{{ player.username }}" width="48" height="48"
|
||||
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'48\' height=\'48\' viewBox=\'0 0 48 48\'%3E%3Crect width=\'48\' height=\'48\' rx=\'6\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'22\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
|
||||
<div>
|
||||
<div class="fw-bold">{{ player.username }}</div>
|
||||
<div class="text-muted small font-monospace">{{ player.uuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-danger fw-semibold mt-3 mb-1">
|
||||
<i class="bi bi-exclamation-circle-fill me-1"></i>This action cannot be undone.
|
||||
</p>
|
||||
<ul class="text-muted small mb-4">
|
||||
<li>Sessions, chat, commands, deaths, teleports</li>
|
||||
<li>Block events, proxy events, inventory events</li>
|
||||
<li>Player stats, entity interactions</li>
|
||||
<li>The player's base record (UUID, username, IP, playtime)</li>
|
||||
</ul>
|
||||
|
||||
<form method="post" action="{{ url_for('group_admin.player_delete', uuid=player.uuid) }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="confirm_name" class="form-label fw-semibold">
|
||||
Type <span class="text-danger font-monospace">{{ player.username }}</span> to confirm:
|
||||
</label>
|
||||
<input type="text" id="confirm_name" name="confirm_name"
|
||||
class="form-control bg-dark text-white border-danger"
|
||||
placeholder="{{ player.username }}" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill me-1"></i>Delete All Data
|
||||
</button>
|
||||
<a href="{{ url_for('panel.player_detail', uuid=player.uuid) }}"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This deletion is logged in the audit log as required by Art. 5(2) GDPR (accountability).
|
||||
The export function (Art. 20 GDPR) is available on the player detail page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
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>
|
||||
@@ -8,7 +8,8 @@
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
class="rounded mb-3" alt="{{ player.username }}"
|
||||
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'80\' height=\'80\' viewBox=\'0 0 80 80\'%3E%3Crect width=\'80\' height=\'80\' rx=\'8\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'36\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
@@ -139,4 +140,30 @@
|
||||
<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Overview
|
||||
</a>
|
||||
|
||||
{% if is_admin and not session.get('is_site_admin') %}
|
||||
<div class="card border-warning mt-4">
|
||||
<div class="card-header bg-warning bg-opacity-10 text-warning fw-semibold">
|
||||
<i class="bi bi-shield-lock me-2"></i>GDPR Actions
|
||||
</div>
|
||||
<div class="card-body d-flex flex-wrap gap-3 align-items-center">
|
||||
<div>
|
||||
<a href="{{ url_for('group_admin.player_export', uuid=player.uuid) }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-download me-1"></i>Export Data (Art. 20 GDPR)
|
||||
</a>
|
||||
<div class="form-text text-muted mt-1">Download all logged data as ZIP (group admins & owners)</div>
|
||||
</div>
|
||||
{% if session.get('role') == 'group_owner' %}
|
||||
<div>
|
||||
<a href="{{ url_for('group_admin.player_delete', uuid=player.uuid) }}"
|
||||
class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash3 me-1"></i>Delete All Data (Art. 17 GDPR)
|
||||
</a>
|
||||
<div class="form-text text-danger mt-1">Permanently erase all player data (owner only)</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
class="rounded mb-3" alt="{{ player.username }}"
|
||||
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'80\' height=\'80\' viewBox=\'0 0 80 80\'%3E%3Crect width=\'80\' height=\'80\' rx=\'8\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'36\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
|
||||
246
web/templates/privacy_policy.html
Normal file
246
web/templates/privacy_policy.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<!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 — MCLogger</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">
|
||||
<style>
|
||||
body { background: #0d1117; }
|
||||
.policy-card { max-width: 820px; margin: 0 auto; }
|
||||
h2 { font-size: 1.15rem; margin-top: 2rem; }
|
||||
h3 { font-size: 1rem; margin-top: 1.25rem; }
|
||||
p, li { color: #c9d1d9; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="py-5 px-3">
|
||||
<div class="policy-card">
|
||||
<div class="text-center mb-5">
|
||||
<i class="bi bi-database-fill-gear fs-1 text-success"></i>
|
||||
<h1 class="fw-bold mt-2 h3">MCLogger — Privacy Policy</h1>
|
||||
<p class="text-muted small">Last updated: {{ last_updated }}</p>
|
||||
<p class="text-muted small">
|
||||
<i class="bi bi-fingerprint me-1"></i>Document ID:
|
||||
<span class="font-monospace fw-bold text-secondary">{{ policy_version }}</span>
|
||||
<span class="text-muted"> — this ID changes automatically when the policy content changes.
|
||||
Your consent is tied to this ID.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">1. Controller & Contact</h2>
|
||||
<p>
|
||||
The controller responsible for data processing within this service is:
|
||||
</p>
|
||||
<p>
|
||||
<strong>Simon</strong><br>
|
||||
E-Mail: <a href="mailto:simon@devanturas.net">simon@devanturas.net</a>
|
||||
</p>
|
||||
<p>
|
||||
For any questions, requests, or concerns regarding your personal data, please contact the
|
||||
address above.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">2. What Is MCLogger?</h2>
|
||||
<p>
|
||||
MCLogger is a self-hosted logging and analytics panel for Minecraft server operators.
|
||||
It collects and displays in-game activity data (sessions, chat, commands, deaths, block
|
||||
events, proxy events) and provides a multi-tenant web interface for authorised server
|
||||
administrators and group members.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">3. Data We Collect</h2>
|
||||
|
||||
<h3 class="fw-semibold">3.1 Minecraft Player Data</h3>
|
||||
<p>When players connect to a Minecraft server that uses the MCLogger plugin, the
|
||||
following data is automatically recorded:</p>
|
||||
<ul>
|
||||
<li><strong>Player identity:</strong> Minecraft username, UUID</li>
|
||||
<li><strong>Sessions:</strong> join time, leave time, session duration, server name</li>
|
||||
<li><strong>IP addresses:</strong> the IP address used at connection time</li>
|
||||
<li><strong>Chat messages:</strong> message content, timestamp, username</li>
|
||||
<li><strong>Commands:</strong> command text, timestamp, username</li>
|
||||
<li><strong>Deaths:</strong> death message/cause, timestamp, location, username</li>
|
||||
<li><strong>Block events:</strong> block type, action (place/break), coordinates, username, timestamp</li>
|
||||
<li><strong>Proxy events:</strong> connect/disconnect events on the proxy network, timestamp</li>
|
||||
</ul>
|
||||
<p>
|
||||
This data is stored in a MariaDB database operated by the server operator. Players should
|
||||
be informed about this logging by the Minecraft server's own rules or MOTD.
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.2 Panel User Accounts</h3>
|
||||
<p>When a user account is created for the web panel, the following data is stored:</p>
|
||||
<ul>
|
||||
<li><strong>Username</strong></li>
|
||||
<li><strong>Password</strong> (stored as a salted PBKDF2-HMAC-SHA256 hash; the plain-text password is never stored)</li>
|
||||
<li><strong>E-mail address</strong> (when provided via the invite flow)</li>
|
||||
<li><strong>Group membership and role</strong></li>
|
||||
<li><strong>Account creation date</strong></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="fw-semibold">3.3 Invite Tokens</h3>
|
||||
<p>
|
||||
When a group administrator invites a user by e-mail, a time-limited invite token is
|
||||
generated and stored together with the recipient's e-mail address. The token expires
|
||||
after {{ invite_expiry_hours }} hours. Accepted and revoked tokens are retained in the database for
|
||||
audit purposes.
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.4 Session Data</h3>
|
||||
<p>
|
||||
MCLogger uses server-side sessions (Flask session cookie) to keep you logged in.
|
||||
The session cookie is HTTP-only, SameSite-protected, and expires when your browser
|
||||
session ends.
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.5 Panel Audit Log</h3>
|
||||
<p>
|
||||
MCLogger maintains an <strong>internal audit log</strong> in the panel database that records
|
||||
security-relevant and data-access events. Each entry contains:
|
||||
</p>
|
||||
<ul>
|
||||
<li>The panel user who performed the action (username and internal ID)</li>
|
||||
<li>The action taken (e.g. login, logout, member role change, viewing player data)</li>
|
||||
<li>The affected entity (e.g. a Minecraft player's UUID when player profile pages are accessed)</li>
|
||||
<li>The IP address of the panel user at the time of the action</li>
|
||||
<li>A UTC timestamp</li>
|
||||
</ul>
|
||||
<p>
|
||||
This includes access to pages that display Minecraft player data (player list, player detail,
|
||||
chat history, commands, deaths, block events, sessions, proxy events). The log therefore
|
||||
records <em>who</em> in the panel team accessed <em>which</em> player's data and <em>when</em>,
|
||||
providing an accountable audit trail as required by Art. 32 GDPR.
|
||||
Audit log entries are automatically deleted after {{ audit_retention_days }} days
|
||||
(configurable by the operator).
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.6 Server Log Files</h3>
|
||||
<p>
|
||||
The web server (gunicorn) may write standard HTTP access logs containing IP
|
||||
addresses, request paths, and timestamps. These logs are used for operational
|
||||
security monitoring and are not shared with third parties.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">4. Purpose & Legal Basis of Processing</h2>
|
||||
<table class="table table-sm table-dark table-bordered">
|
||||
<thead class="table-secondary">
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Purpose</th>
|
||||
<th>Legal Basis (GDPR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Minecraft player activity data</td>
|
||||
<td>Server administration, moderation, abuse prevention</td>
|
||||
<td>Art. 6(1)(f) — legitimate interest of the server operator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Panel user accounts</td>
|
||||
<td>Authentication and authorisation for the web panel</td>
|
||||
<td>Art. 6(1)(b) — performance of a contract / access service</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>E-mail addresses (invites)</td>
|
||||
<td>Sending one-time panel invitation links</td>
|
||||
<td>Art. 6(1)(a) — consent (the invite was requested by a group admin)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Server access logs</td>
|
||||
<td>Security monitoring and error diagnosis</td>
|
||||
<td>Art. 6(1)(f) — legitimate interest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Panel audit log (incl. IP addresses of panel users)</td>
|
||||
<td>Accountability for access to personal data; security incident traceability</td>
|
||||
<td>Art. 6(1)(c) — legal obligation / Art. 32 GDPR (security of processing)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">5. Data Retention</h2>
|
||||
<ul>
|
||||
<li><strong>Minecraft logs</strong> are retained as long as the server operator deems necessary for moderation purposes.</li>
|
||||
<li><strong>Panel accounts</strong> are retained until manually deleted by a site administrator.</li>
|
||||
<li><strong>Invite tokens</strong> expire after {{ invite_expiry_hours }} hours and are never sent to third parties beyond the intended recipient.</li>
|
||||
<li><strong>Panel audit log entries</strong> are automatically deleted after {{ audit_retention_days }} days. This includes IP address data logged on data-access events.</li>
|
||||
<li><strong>Server access logs</strong> are typically rotated within 30 days.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">6. Data Sharing & Third Parties</h2>
|
||||
<p>
|
||||
Data collected by MCLogger is <strong>not sold, rented, or shared</strong> with third
|
||||
parties. All data remains within the infrastructure controlled by the server operator.
|
||||
No third-party analytics services, advertising networks, or tracking pixels are used.
|
||||
</p>
|
||||
<p>
|
||||
Player head images are loaded from <strong>minotar.net</strong>, a public Minecraft avatar service.
|
||||
Minotar may process the Minecraft username and your IP address as part of serving the image.
|
||||
Please consult <a href="https://minotar.net" target="_blank" rel="noopener noreferrer">minotar.net</a>
|
||||
for their privacy practices. If the image cannot be loaded, a local fallback placeholder is displayed.
|
||||
</p>
|
||||
<p>
|
||||
External resources loaded by the web interface (Bootstrap CSS/JS and Bootstrap Icons)
|
||||
are served from the jsDelivr CDN (<code>cdn.jsdelivr.net</code>). jsDelivr may process
|
||||
your IP address as part of delivering these static files. Please consult
|
||||
<a href="https://www.jsdelivr.com/privacy-policy-jsdelivr-net" target="_blank" rel="noopener noreferrer">jsDelivr's privacy policy</a>
|
||||
for details.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">7. Security</h2>
|
||||
<p>
|
||||
MCLogger applies the following technical safeguards:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Passwords are hashed with PBKDF2-HMAC-SHA256 (per-user salt + server pepper).</li>
|
||||
<li>Stored database credentials and SMTP credentials are encrypted with Fernet symmetric encryption before being written to the database.</li>
|
||||
<li>CSRF tokens are enforced on all state-changing requests.</li>
|
||||
<li>Security response headers (X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, Referrer-Policy) are set on every response.</li>
|
||||
<li>SMTP connections use STARTTLS.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">8. Your Rights (GDPR)</h2>
|
||||
<p>If you are subject to the GDPR you have the following rights:</p>
|
||||
<ul>
|
||||
<li><strong>Right of access</strong> (Art. 15) — request a copy of your personal data.</li>
|
||||
<li><strong>Right to rectification</strong> (Art. 16) — request correction of inaccurate data.</li>
|
||||
<li><strong>Right to erasure</strong> (Art. 17) — request deletion of your data ("right to be forgotten").</li>
|
||||
<li><strong>Right to restriction of processing</strong> (Art. 18) — request that processing is restricted while a dispute is resolved.</li>
|
||||
<li><strong>Right to data portability</strong> (Art. 20) — receive your data in a structured, machine-readable format.</li>
|
||||
<li><strong>Right to object</strong> (Art. 21) — object to processing based on legitimate interest.</li>
|
||||
<li><strong>Right to withdraw consent</strong> (Art. 7(3)) — withdraw any consent you have given at any time.</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise any of these rights, please contact:
|
||||
<a href="mailto:simon@devanturas.net">simon@devanturas.net</a>
|
||||
</p>
|
||||
<p>
|
||||
You also have the right to lodge a complaint with your national data protection
|
||||
supervisory authority.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">9. Changes to This Policy</h2>
|
||||
<p>
|
||||
This privacy policy may be updated to reflect changes in the software or applicable law.
|
||||
The "Last updated" date at the top of this page indicates when the most recent revision
|
||||
was made.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-muted small pb-5">
|
||||
<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 — 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>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user