Compare commits

...

26 Commits

Author SHA1 Message Date
simon
8df8230111 modified: consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java 2026-04-20 11:50:52 +02:00
simon
408933106b modified: consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	modified:   consent-plugin/src/main/resources/config.yml
2026-04-20 11:50:36 +02:00
SimolZimol
c2b645ed58 modified: consent-plugin/pom.xml
modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
	modified:   consent-plugin/src/main/resources/config.yml
	modified:   consent-plugin/src/main/resources/plugin.yml
2026-04-17 14:18:51 +02:00
SimolZimol
aa8dfdcdbf modified: web/templates/admin/base.html
modified:   web/templates/base.html
	modified:   web/templates/group_admin/base.html
	modified:   web/templates/group_policy.html
	modified:   web/templates/privacy_policy.html
2026-04-17 13:51:28 +02:00
simon
83af428435 modified: web/panel_db.py 2026-04-17 12:07:38 +02:00
simon
cd9a46b403 modified: web/app.py
modified:   web/blueprints/group_admin.py
	modified:   web/panel_db.py
	modified:   web/templates/group_admin/privacy_policy.html
2026-04-17 12:04:00 +02:00
simon
2dbd5340a8 modified: consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	modified:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
2026-04-17 11:42:17 +02:00
simon
17a782b487 new file: consent-plugin/pom.xml
new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentConfig.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/ConsentPlugin.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/commands/ConsentCommand.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/database/ConsentDatabase.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/listeners/ConsentListener.java
	new file:   consent-plugin/src/main/java/de/simolzimol/mclogger/consent/util/MessageUtil.java
	new file:   consent-plugin/src/main/resources/config.yml
	new file:   consent-plugin/src/main/resources/plugin.yml
	modified:   web/app.py
	modified:   web/blueprints/group_admin.py
	modified:   web/panel_db.py
	modified:   web/templates/group_admin/base.html
	new file:   web/templates/group_admin/privacy_policy.html
	new file:   web/templates/group_policy.html
2026-04-17 11:41:35 +02:00
simon
aa0544a4a5 modified: web/blueprints/group_admin.py
modified:   web/templates/admin/audit_log.html
	new file:   web/templates/group_admin/player_delete_confirm.html
	modified:   web/templates/panel/player_detail.html
2026-04-15 12:42:37 +02:00
simon
52674fee29 modified: web/app.py
modified:   web/config.py
	modified:   web/templates/privacy_policy.html
2026-04-15 12:09:11 +02:00
simon
e4727ba561 modified: web/config.py 2026-04-15 12:06:11 +02:00
simon
2f13b0a5c6 modified: web/app.py
modified:   web/templates/privacy_policy.html
2026-04-15 11:55:22 +02:00
simon
a45dd74083 modified: web/templates/panel/player_detail.html
modified:   web/templates/player_detail.html
2026-04-15 11:46:54 +02:00
simon
3f660533fc modified: web/blueprints/panel.py
modified:   web/templates/admin/audit_log.html
	modified:   web/templates/admin/dashboard.html
2026-04-15 11:41:02 +02:00
simon
6bac132a32 modified: web/app.py 2026-04-15 11:20:54 +02:00
simon
bdf83bd275 modified: web/app.py
modified:   web/blueprints/auth.py
	modified:   web/blueprints/site_admin.py
	modified:   web/config.py
	modified:   web/panel_db.py
	modified:   web/templates/admin/audit_log.html
	modified:   web/templates/admin/dashboard.html
	new file:   web/templates/auth/consent.html
2026-04-15 11:05:21 +02:00
simon
179a0e1042 modified: web/blueprints/auth.py
modified:   web/blueprints/group_admin.py
	modified:   web/blueprints/site_admin.py
	modified:   web/config.py
	modified:   web/panel_db.py
	modified:   web/templates/admin/audit_log.html
2026-04-15 10:48:37 +02:00
simon
6a6e0fc4b3 modified: web/blueprints/site_admin.py 2026-04-14 13:14:56 +02:00
simon
3b78f5dfb1 modified: web/app.py
modified:   web/blueprints/auth.py
	modified:   web/blueprints/group_admin.py
	modified:   web/blueprints/site_admin.py
	new file:   web/limiter.py
	modified:   web/panel_db.py
	modified:   web/requirements.txt
	new file:   web/templates/429.html
	new file:   web/templates/admin/audit_log.html
	modified:   web/templates/admin/base.html
2026-04-14 13:02:41 +02:00
simon
452d50e5b5 modified: web/app.py
modified:   web/templates/admin/base.html
	modified:   web/templates/auth/admin_login.html
	modified:   web/templates/auth/login.html
	modified:   web/templates/base.html
	modified:   web/templates/group_admin/base.html
	new file:   web/templates/privacy_policy.html
2026-04-14 12:33:51 +02:00
SimolZimol
8f614a08cc modified: web/blueprints/group_admin.py
modified:   web/blueprints/site_admin.py
	modified:   web/mailer.py
2026-04-13 19:10:21 +02:00
SimolZimol
ee66a04cb2 modified: web/blueprints/group_admin.py
modified:   web/blueprints/site_admin.py
	modified:   web/mailer.py
2026-04-13 19:00:46 +02:00
SimolZimol
fe2e5e3c9c modified: web/blueprints/site_admin.py
modified:   web/panel_db.py
	modified:   web/templates/admin/user_edit.html
	modified:   web/templates/admin/users.html
2026-04-13 18:25:09 +02:00
SimolZimol
31b45d4db4 modified: web/blueprints/group_admin.py
modified:   web/blueprints/site_admin.py
	modified:   web/roles.py
	modified:   web/templates/admin/group_members.html
2026-04-13 18:02:55 +02:00
simon
be26484606 modified: .gitignore 2026-04-13 11:45:06 +02:00
simon
d25536e9c4 modified: .gitignore 2026-04-13 11:44:30 +02:00
68 changed files with 4561 additions and 122 deletions

2
.gitignore vendored
View File

@@ -61,3 +61,5 @@ logs/
# OS specific
Thumbs.db
.DS_Store
target/

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

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.simolzimol</groupId>
<artifactId>mcconsent-paper</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>MCConsent-Paper</name>
<description>Privacy Policy consent enforcement for Minecraft servers</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<!-- Paper API 1.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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)));
}
}

View File

@@ -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
}
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View 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}

View 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

View File

@@ -1,12 +1,12 @@
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\commands\MCLoggerCommand.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\database\DatabaseManager.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\BlockListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\EntityListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\InventoryListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\LuckPermsListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\WorldListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\PaperLoggerPlugin.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\commands\MCLoggerCommand.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\database\DatabaseManager.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\BlockListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\EntityListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\InventoryListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\LuckPermsListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerChatCommandListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerDeathListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerMiscListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\PlayerSessionListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\listeners\WorldListener.java
C:\Users\Menuette\Documents\Programme\MClogger\paper-plugin\src\main\java\de\simolzimol\mclogger\paper\PaperLoggerPlugin.java

Binary file not shown.

View File

@@ -1,3 +1,3 @@
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.java
C:\Users\Simon.Speedy\Documents\dev projekte\Minecarft\Tools\Log\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.java
C:\Users\Menuette\Documents\Programme\MClogger\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\database\VelocityDatabaseManager.java
C:\Users\Menuette\Documents\Programme\MClogger\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\listeners\VelocityEventListener.java
C:\Users\Menuette\Documents\Programme\MClogger\velocity-plugin\src\main\java\de\simolzimol\mclogger\velocity\VelocityLoggerPlugin.java

View File

@@ -3,11 +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
@@ -17,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,
@@ -26,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()
@@ -57,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
@@ -89,7 +187,7 @@ def create_app() -> Flask:
links.append({"label": "Panel Dashboard", "href": url_for("panel.dashboard"), "btn": "btn-success"})
if is_site_admin:
links.append({"label": "Site Admin", "href": url_for("site_admin.dashboard"), "btn": "btn-outline-danger"})
if role == "admin" and not is_site_admin:
if can_manage_group(role) and not is_site_admin:
links.append({"label": "Group Admin", "href": url_for("group_admin.dashboard"), "btn": "btn-outline-warning"})
return render_template(

View File

@@ -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"))
@@ -126,5 +240,5 @@ def _apply_group(group):
perms = {}
session["group_id"] = group["id"]
session["group_name"] = group["name"]
session["role"] = group.get("role", "member")
session["role"] = group.get("role", "viewer")
session["permissions"] = perms

View File

@@ -2,15 +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, 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"),
@@ -32,7 +43,7 @@ def group_admin_required(f):
return redirect(url_for("auth.login"))
if session.get("is_site_admin"):
return redirect(url_for("site_admin.dashboard"))
if session.get("role") != "admin":
if session.get("role") not in GROUP_MANAGEMENT_ROLES:
flash("You do not have group admin permission.", "danger")
return redirect(url_for("panel.dashboard"))
return f(*args, **kwargs)
@@ -48,7 +59,7 @@ def dashboard():
has_db = db.has_db_configured(group_id)
stats = {
"member_count": len(members),
"admin_count": sum(1 for m in members if m.get("role") == "admin"),
"admin_count": sum(1 for m in members if m.get("role") in GROUP_MANAGEMENT_ROLES),
"db_configured": bool(has_db),
}
return render_template("group_admin/dashboard.html",
@@ -71,7 +82,9 @@ def members():
non_members = [u for u in all_users if u["id"] not in member_ids and not u["is_site_admin"]]
return render_template("group_admin/members.html",
group=group, members=members, non_members=non_members, pending_invites=pending_invites,
all_permissions=ALL_PERMISSIONS)
all_permissions=ALL_PERMISSIONS,
role_options=_NON_OWNER_ROLE_OPTIONS,
role_label=role_label)
@group_admin.route("/members/add", methods=["POST"])
@@ -79,20 +92,34 @@ def members():
def member_add():
group_id = session["group_id"]
user_id = request.form.get("user_id", type=int)
role = request.form.get("role", "member")
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()
email = request.form.get("email", "").strip()
role = request.form.get("role", "member")
role = request.form.get("role", "viewer")
if not username or not email:
flash("Username and email are required.", "danger")
@@ -102,14 +129,26 @@ def member_invite():
flash("Please provide a valid email address.", "danger")
return redirect(url_for("group_admin.members"))
if role not in {"member", "admin"}:
if role not in GROUP_ROLE_SET:
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"))
if db.get_user_by_username(username):
flash("Username already exists.", "danger")
return redirect(url_for("group_admin.members"))
if db.get_active_invite_by_username(group_id, username):
flash("There is already an active invitation for this username in the group.", "danger")
return redirect(url_for("group_admin.members"))
if db.get_user_by_email(email):
flash("Email address is already in use.", "danger")
return redirect(url_for("group_admin.members"))
@@ -119,19 +158,29 @@ def member_invite():
return redirect(url_for("group_admin.members"))
token = db.create_group_invite(group_id, username, email, role, session["user_id"])
invite_url = url_for("auth.accept_invite", token=token, _external=True)
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 {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}.\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")
except Exception:
flash(f"Invitation created, but email delivery failed. Share this link manually: {invite_url}", "warning")
@@ -140,10 +189,65 @@ def member_invite():
return redirect(url_for("group_admin.members"))
@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)
if not invite:
flash("Invitation not found.", "danger")
return redirect(url_for("group_admin.members"))
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("group_admin.members"))
last_sent_at = invite.get("last_sent_at")
if last_sent_at and (datetime.utcnow() - last_sent_at) < timedelta(seconds=Config.INVITE_RESEND_COOLDOWN_SECONDS):
flash("Please wait before resending this invite again.", "warning")
return redirect(url_for("group_admin.members"))
mail_settings = db.get_site_mail_settings()
if not mail_settings:
flash("No SMTP settings configured by Site Admin.", "danger")
return redirect(url_for("group_admin.members"))
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, 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, 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("group_admin.members"))
@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"))
@@ -163,15 +267,30 @@ def member_edit(user_id):
current_perms = json.loads(raw_perms) if isinstance(raw_perms, str) else (raw_perms or {})
if request.method == "POST":
role = request.form.get("role", "member")
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)
current_perms=current_perms, all_permissions=ALL_PERMISSIONS,
role_options=_NON_OWNER_ROLE_OPTIONS,
role_label=role_label)
@group_admin.route("/members/<int:user_id>/remove", methods=["POST"])
@@ -180,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"))
@@ -216,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,
@@ -224,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:
@@ -236,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)

View File

@@ -9,10 +9,25 @@ from flask import Blueprint, render_template, request, redirect, url_for, sessio
import pymysql
import pymysql.cursors
import panel_db as pdb
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
# ─────────────────────────────────────────────────────────────
@@ -34,7 +49,7 @@ def perm_required(perm):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if session.get("is_site_admin") or session.get("role") == "admin":
if session.get("is_site_admin") or can_manage_group(session.get("role")):
return f(*args, **kwargs)
perms = session.get("permissions", {})
if not perms.get(perm, False):
@@ -169,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}%",)
@@ -190,8 +206,10 @@ 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 session.get("role") == "admin"
is_admin = session.get("is_site_admin") or can_manage_group(session.get("role"))
return render_template("panel/player_detail.html",
player=player,
sessions = query("SELECT * FROM player_sessions WHERE player_uuid=%s ORDER BY login_time DESC LIMIT 20", (uuid,)),
@@ -215,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)
@@ -238,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}%")
@@ -264,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}%")
@@ -288,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)
@@ -310,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)
@@ -339,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}%")
@@ -360,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)
@@ -384,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)

View File

@@ -3,9 +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")
@@ -44,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
@@ -98,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:
@@ -120,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"))
@@ -148,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)
@@ -168,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)
@@ -176,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"))
@@ -184,22 +223,42 @@ 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)
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)
@site_admin.route("/groups/<int:group_id>/members/add", methods=["POST"])
@admin_required
def group_member_add(group_id):
user_id = request.form.get("user_id", type=int)
role = request.form.get("role", "member")
role = request.form.get("role", "viewer")
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 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))
@@ -207,24 +266,169 @@ 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))
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/toggle-role", methods=["POST"])
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/set-role", methods=["POST"])
@admin_required
def group_member_toggle_role(group_id, user_id):
def group_member_set_role(group_id, user_id):
member = db.get_group_member(user_id, group_id)
if member:
import json as _json
new_role = "member" if member["role"] == "admin" else "admin"
new_role = request.form.get("role", "viewer")
if new_role not in GROUP_ROLE_SET:
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
# ──────────────────────────────────────────────────────────────
@@ -232,28 +436,162 @@ def group_member_toggle_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:
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:
db.create_user(username, email, password, is_site_admin)
flash(f"User '{username}' created.", "success")
return redirect(url_for("site_admin.users"))
return render_template("admin/user_edit.html", user=None)
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"))
@site_admin.route("/users/<int:user_id>/edit", methods=["GET", "POST"])
@@ -263,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()
@@ -275,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)
@@ -287,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"))
@@ -317,9 +681,15 @@ def view_group(group_id):
"view_proxy","view_server_events","view_perms"]}
session["group_id"] = group_id
session["group_name"] = group["name"]
session["role"] = "admin"
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"))
@@ -337,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"))

View File

@@ -53,8 +53,30 @@ 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,

14
web/limiter.py Normal file
View 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=[],
)

View File

@@ -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()

View File

@@ -93,7 +93,7 @@ PANEL_SCHEMA = [
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
group_id INT NOT NULL,
role ENUM('admin','member') DEFAULT 'member',
role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer',
permissions JSON,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_group (user_id, group_id),
@@ -103,20 +103,96 @@ 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('admin','member') DEFAULT 'member',
role ENUM('group_owner','group_admin','moderator','viewer','auditor','admin','member') DEFAULT 'viewer',
token VARCHAR(128) UNIQUE NOT NULL,
created_by_user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
last_sent_at DATETIME NULL,
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 = [
@@ -147,12 +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)
# 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(
"INSERT IGNORE INTO schema_migrations (version, note) VALUES (%s, %s)",
(version, note),
)
_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()
@@ -164,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
@@ -178,7 +278,7 @@ def create_user(username: str, email: str, password: str, is_site_admin: bool =
)
def create_user_for_group(username: str, email: str, password: str, group_id: int, role: str = "member") -> int:
def create_user_for_group(username: str, email: str, password: str, group_id: int, role: str = "viewer") -> int:
"""Create a non-site-admin user and assign them to a group atomically."""
permissions = Config.DEFAULT_PERMISSIONS
salt = generate_salt()
@@ -206,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) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(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
@@ -229,6 +329,15 @@ def list_active_group_invites(group_id: int):
)
def count_active_group_invites(group_id: int) -> int:
row = _panel_query(
"SELECT COUNT(*) AS c FROM group_invites WHERE group_id=%s AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()",
(group_id,),
fetchone=True,
)
return int(row["c"]) if row else 0
def get_active_invite_by_email(group_id: int, email: str):
return _panel_query(
"SELECT * FROM group_invites WHERE group_id=%s AND invited_email=%s "
@@ -238,11 +347,28 @@ def get_active_invite_by_email(group_id: int, email: str):
)
def get_active_invite_by_username(group_id: int, username: str):
return _panel_query(
"SELECT * FROM group_invites WHERE group_id=%s AND invited_username=%s "
"AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP()",
(group_id, username),
fetchone=True,
)
def get_group_invite_by_id(invite_id: int, group_id: int):
return _panel_query(
"SELECT * FROM group_invites WHERE id=%s AND group_id=%s",
(invite_id, group_id),
fetchone=True,
)
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,),
@@ -258,6 +384,72 @@ 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",
(invite_id, group_id),
write=True,
)
def accept_group_invite(token: str, password: str) -> dict | None:
invite = get_invite_by_token(token)
if not invite:
@@ -285,10 +477,16 @@ 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(
"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)),
"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)),
)
cur.execute(
"UPDATE group_invites SET accepted_at=UTC_TIMESTAMP() WHERE id=%s AND accepted_at IS NULL AND revoked_at IS NULL",
(invite["id"],),
@@ -422,7 +620,7 @@ def get_group_members(group_id: int):
)
def add_group_member(user_id: int, group_id: int, role: str = "member", permissions: dict = None):
def add_group_member(user_id: int, group_id: int, role: str = "viewer", permissions: dict = None):
if permissions is None:
permissions = Config.DEFAULT_PERMISSIONS
_panel_query(
@@ -552,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

View File

@@ -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

32
web/roles.py Normal file
View File

@@ -0,0 +1,32 @@
# Legacy values (admin/member) are kept for backward compatibility.
GROUP_ROLE_LABELS = {
"group_owner": "Group Owner",
"group_admin": "Group Admin",
"moderator": "Moderator",
"viewer": "Viewer",
"auditor": "Auditor",
"admin": "Admin",
"member": "Member",
}
GROUP_ROLE_OPTIONS = [
("group_owner", GROUP_ROLE_LABELS["group_owner"]),
("group_admin", GROUP_ROLE_LABELS["group_admin"]),
("moderator", GROUP_ROLE_LABELS["moderator"]),
("viewer", GROUP_ROLE_LABELS["viewer"]),
("auditor", GROUP_ROLE_LABELS["auditor"]),
]
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
def role_label(role: str | None) -> str:
return GROUP_ROLE_LABELS.get(role or "", "Unknown")

View File

@@ -57,7 +57,7 @@
<p class="mb-0 text-secondary">You are currently not signed in. Start from the login page.</p>
{% elif is_site_admin and not session.get('group_id') %}
<p class="mb-0 text-secondary">You are signed in as Site Admin. You can manage groups and users from there.</p>
{% elif role == 'admin' %}
{% elif role in ['group_owner', 'group_admin', 'admin'] %}
<p class="mb-0 text-secondary">You are a group admin. Use Panel or Group Admin to return to valid sections.</p>
{% else %}
<p class="mb-0 text-secondary">Use the dashboard to navigate back to known sections.</p>

31
web/templates/429.html Normal file
View 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>

View 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 %}

View File

@@ -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>
&mdash; MCLogger &mdash; 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 %}

View File

@@ -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 %}

View File

@@ -9,9 +9,9 @@
</div>
<div class="row g-3">
<!-- Aktuelle Mitglieder -->
<!-- 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">
@@ -21,17 +21,22 @@
<tr>
<td>{{ m.username }}</td>
<td>
{% if m.role == 'admin' %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
{% if m.role in management_roles %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(m.role) }}</span>
{% else %}
<span class="badge bg-secondary">Member</span>
<span class="badge bg-secondary">{{ role_label(m.role) }}</span>
{% endif %}
</td>
<td class="text-end">
<form method="post" action="{{ url_for('site_admin.group_member_toggle_role', group_id=group.id, user_id=m.id) }}" class="d-inline">
<form method="post" action="{{ url_for('site_admin.group_member_set_role', group_id=group.id, user_id=m.id) }}" class="d-inline-flex align-items-center gap-1">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning" title="Toggle role">
<i class="bi bi-arrow-left-right"></i>
<select name="role" class="form-select form-select-sm" style="width: 150px;">
{% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if m.role == role }}>{{ label }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-outline-warning" title="Set role">
<i class="bi bi-check2"></i>
</button>
</form>
<form method="post" action="{{ url_for('site_admin.group_member_remove', group_id=group.id, user_id=m.id) }}" class="d-inline"
@@ -50,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>
<!-- Benutzer hinzufügen -->
<!-- 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) }}">
@@ -71,8 +128,9 @@
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" class="form-select">
<option value="member">Member</option>
<option value="admin">Admin</option>
{% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-success w-100">
@@ -84,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 %}

View File

@@ -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 %}

View File

@@ -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>
@@ -21,7 +87,7 @@
<td>
{% for g in u.groups %}
<span class="badge bg-secondary me-1">{{ g.name }}
{% if g.role == 'admin' %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %}
{% if g.role in ['group_owner', 'group_admin', 'admin'] %}<i class="bi bi-star-fill ms-1 text-warning"></i>{% endif %}
</span>
{% else %}<span class="text-muted small">None</span>{% endfor %}
</td>

View File

@@ -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>

View 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 &amp; 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 &amp; 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. 1521):</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 &amp; 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>

View File

@@ -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>

View File

@@ -21,7 +21,7 @@
</div>
{% set perms = session.get('permissions', {}) %}
{% set is_admin = session.get('is_site_admin') or session.get('role') == 'admin' %}
{% set is_admin = session.get('is_site_admin') or session.get('role') in ['group_owner', 'group_admin', 'admin'] %}
<ul class="nav flex-column gap-1">
{% if perms.get('view_dashboard', True) or is_admin %}
@@ -115,7 +115,7 @@
{% endif %}
<!-- Admin-Links -->
{% if session.get('role') == 'admin' and not session.get('is_site_admin') %}
{% if session.get('role') in ['group_owner', 'group_admin', 'admin'] and not session.get('is_site_admin') %}
<a href="{{ url_for('group_admin.dashboard') }}" class="btn btn-outline-warning btn-sm mb-1">
<i class="bi bi-gear-fill"></i> <span>Manage Group</span>
</a>
@@ -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>
&mdash; MCLogger &mdash; 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>

View File

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

View File

@@ -20,10 +20,11 @@
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" class="form-select">
<option value="member" {{ 'selected' if member.role == 'member' }}>Member</option>
<option value="admin" {{ 'selected' if member.role == 'admin' }}>Admin</option>
{% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if member.role == role }}>{{ label }}</option>
{% endfor %}
</select>
<div class="form-text">Admins can manage members and the DB connection.</div>
<div class="form-text">Group Owner and Group Admin can manage members and database settings.</div>
</div>
<hr>

View File

@@ -16,10 +16,10 @@
<tr>
<td>{{ m.username }}</td>
<td>
{% if m.role == 'admin' %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
{% if m.role in ['group_owner', 'group_admin', 'admin'] %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>{{ role_label(m.role) }}</span>
{% else %}
<span class="badge bg-secondary">Member</span>
<span class="badge bg-secondary">{{ role_label(m.role) }}</span>
{% endif %}
</td>
<td class="text-end">
@@ -61,17 +61,24 @@
<div class="small text-muted" id="invite-link-{{ invite.id }}">{{ invite.invited_email }}</div>
</td>
<td>
{% if invite.role == 'admin' %}
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>Admin</span>
{% if invite.role in ['group_owner', 'group_admin', 'admin'] %}
<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">Member</span>
<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">
<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('group_admin.resend_invite', 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('group_admin.revoke_invite', 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() }}">
@@ -110,8 +117,9 @@
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" class="form-select">
<option value="member">Member</option>
<option value="admin">Admin</option>
{% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-outline-success w-100">
@@ -141,8 +149,9 @@
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" class="form-select">
<option value="member">Member</option>
<option value="admin">Admin</option>
{% for role, label in role_options %}
<option value="{{ role }}" {{ 'selected' if role == 'viewer' }}>{{ label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-success w-100">

View 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 %}

View 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 %}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy — {{ group.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body class="bg-dark text-light">
<nav class="navbar navbar-dark bg-secondary bg-opacity-25 border-bottom border-secondary mb-4">
<div class="container">
<span class="navbar-brand">
<i class="bi bi-file-earmark-lock2 me-2 text-warning"></i>
<strong>{{ group.name }}</strong> — Privacy Policy
</span>
<span class="text-muted small">Powered by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">MCLogger</a> &mdash; 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>

View File

@@ -8,13 +8,13 @@
<h3 class="mb-3">No database configured</h3>
<p class="text-muted mb-4">
No MC database has been set up for this group.
{% if session.get('role') == 'admin' %}
{% if session.get('role') in ['group_owner', 'group_admin', 'admin'] %}
You can configure the connection as group admin.
{% else %}
Please contact your group admin.
{% endif %}
</p>
{% if session.get('role') == 'admin' %}
{% if session.get('role') in ['group_owner', 'group_admin', 'admin'] %}
<a href="{{ url_for('group_admin.database') }}" class="btn btn-success btn-lg">
<i class="bi bi-database-fill-gear me-2"></i>Configure Database
</a>

View File

@@ -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 &amp; 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 %}

View File

@@ -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>

View 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 &amp; 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 &amp; 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 &amp; 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 &mdash; Made by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">Devanturas</a> &mdash; <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>