Compare commits
26 Commits
63ce0f9c5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df8230111 | ||
|
|
408933106b | ||
|
|
c2b645ed58 | ||
|
|
aa8dfdcdbf | ||
|
|
83af428435 | ||
|
|
cd9a46b403 | ||
|
|
2dbd5340a8 | ||
|
|
17a782b487 | ||
|
|
aa0544a4a5 | ||
|
|
52674fee29 | ||
|
|
e4727ba561 | ||
|
|
2f13b0a5c6 | ||
|
|
a45dd74083 | ||
|
|
3f660533fc | ||
|
|
6bac132a32 | ||
|
|
bdf83bd275 | ||
|
|
179a0e1042 | ||
|
|
6a6e0fc4b3 | ||
|
|
3b78f5dfb1 | ||
|
|
452d50e5b5 | ||
|
|
8f614a08cc | ||
|
|
ee66a04cb2 | ||
|
|
fe2e5e3c9c | ||
|
|
31b45d4db4 | ||
|
|
be26484606 | ||
|
|
d25536e9c4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -61,3 +61,5 @@ logs/
|
||||
# OS specific
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
target/
|
||||
106
consent-plugin/pom.xml
Normal file
106
consent-plugin/pom.xml
Normal file
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>de.simolzimol</groupId>
|
||||
<artifactId>mcconsent-paper</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>MCConsent-Paper</name>
|
||||
<description>Privacy Policy consent enforcement for Minecraft servers</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>papermc</id>
|
||||
<url>https://repo.papermc.io/repository/maven-public/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- Paper API 1.17+ (lowest supported; ensures no 1.18+-only APIs are used) -->
|
||||
<dependency>
|
||||
<groupId>io.papermc.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>1.17.1-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- HikariCP – Connection Pooling -->
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MariaDB JDBC Driver -->
|
||||
<dependency>
|
||||
<groupId>org.mariadb.jdbc</groupId>
|
||||
<artifactId>mariadb-java-client</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||
<plugins>
|
||||
<!-- Shade: package all dependencies into a single JAR -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals><goal>shade</goal></goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<!-- Relocate shaded libs to avoid collisions with MCLogger plugin -->
|
||||
<relocations>
|
||||
<relocation>
|
||||
<pattern>com.zaxxer.hikari</pattern>
|
||||
<shadedPattern>de.simolzimol.mcconsent.lib.hikari</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>org.mariadb</pattern>
|
||||
<shadedPattern>de.simolzimol.mcconsent.lib.mariadb</shadedPattern>
|
||||
</relocation>
|
||||
</relocations>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
<exclude>META-INF/MANIFEST.MF</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<source>17</source>
|
||||
<target>17</target>
|
||||
<encoding>UTF-8</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,193 @@
|
||||
package de.simolzimol.mclogger.consent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
|
||||
/**
|
||||
* Strongly-typed wrapper around the plugin's {@code config.yml}.
|
||||
* Call {@link #reload()} after {@link ConsentPlugin#reloadConfig()} to
|
||||
* pick up any changes.
|
||||
*
|
||||
* @author SimolZimol
|
||||
*/
|
||||
public class ConsentConfig {
|
||||
|
||||
/** Enforcement modes for the plugin. */
|
||||
public enum EnforcementMode {
|
||||
/** Kick the player at login if they have no stored consent. */
|
||||
KICK,
|
||||
/**
|
||||
* Let the player join but freeze them until they accept.
|
||||
* Kicks after the grace period if still pending.
|
||||
*/
|
||||
HOLD,
|
||||
/** Allow full access; send periodic reminders but never kick. */
|
||||
REMIND
|
||||
}
|
||||
|
||||
private final ConsentPlugin plugin;
|
||||
private FileConfiguration cfg;
|
||||
|
||||
public ConsentConfig(ConsentPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
reload();
|
||||
}
|
||||
|
||||
/** Re-reads the YAML file into memory. */
|
||||
public void reload() {
|
||||
plugin.reloadConfig();
|
||||
cfg = plugin.getConfig();
|
||||
}
|
||||
|
||||
// ── Global ───────────────────────────────────────────────
|
||||
|
||||
public boolean isEnabled() {
|
||||
return cfg.getBoolean("enabled", true);
|
||||
}
|
||||
|
||||
// ── Database ─────────────────────────────────────────────
|
||||
|
||||
public String getDbHost() { return cfg.getString("database.host", "localhost"); }
|
||||
public int getDbPort() { return cfg.getInt ("database.port", 3306); }
|
||||
public String getDbDatabase() { return cfg.getString("database.database", "mclogger"); }
|
||||
public String getDbUsername() { return cfg.getString("database.username", "mclogger"); }
|
||||
public String getDbPassword() { return cfg.getString("database.password", ""); }
|
||||
public boolean isDbSsl() { return cfg.getBoolean("database.ssl", false); }
|
||||
public int getDbPoolSize() { return cfg.getInt ("database.pool-size", 5); }
|
||||
|
||||
// ── Consent settings ─────────────────────────────────────
|
||||
|
||||
public String getPolicyVersion() {
|
||||
return cfg.getString("consent.policy-version", "1.0");
|
||||
}
|
||||
|
||||
public String getPolicyUrl() {
|
||||
return cfg.getString("consent.policy-url", "https://example.com/privacy-policy");
|
||||
}
|
||||
|
||||
public EnforcementMode getEnforcementMode() {
|
||||
String raw = cfg.getString("consent.enforcement-mode", "HOLD");
|
||||
try {
|
||||
return EnforcementMode.valueOf(raw.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
plugin.getLogger().warning(
|
||||
"[MCConsent] Unknown enforcement-mode '" + raw + "' – defaulting to HOLD.");
|
||||
return EnforcementMode.HOLD;
|
||||
}
|
||||
}
|
||||
|
||||
/** Display name for the consent command used in prompt text. */
|
||||
public String getCommand() {
|
||||
return cfg.getString("consent.command", "consent");
|
||||
}
|
||||
|
||||
/** Seconds before a HOLD-mode player is kicked for not consenting (0 = infinite). */
|
||||
public int getGracePeriodSeconds() {
|
||||
return cfg.getInt("consent.grace-period-seconds", 120);
|
||||
}
|
||||
|
||||
/** Seconds between reminder messages in REMIND mode (0 = once on join only). */
|
||||
public int getReminderIntervalSeconds() {
|
||||
return cfg.getInt("consent.reminder-interval-seconds", 300);
|
||||
}
|
||||
|
||||
/** Minimum seconds between move-blocked messages (avoids chat spam). */
|
||||
public int getMoveMessageCooldownSeconds() {
|
||||
return cfg.getInt("consent.move-message-cooldown-seconds", 5);
|
||||
}
|
||||
|
||||
/** Worlds listed here are exempt from consent enforcement. */
|
||||
public List<String> getExemptWorlds() {
|
||||
return cfg.getStringList("consent.exempt-worlds");
|
||||
}
|
||||
|
||||
public String getBypassPermission() {
|
||||
return cfg.getString("consent.bypass-permission", "consent.bypass");
|
||||
}
|
||||
|
||||
// ── Admin settings ───────────────────────────────────────
|
||||
|
||||
public boolean isLogToMCLoggerDb() {
|
||||
return cfg.getBoolean("admin.log-to-mclogger-db", true);
|
||||
}
|
||||
|
||||
public boolean isNotifyAdmins() {
|
||||
return cfg.getBoolean("admin.notify-admins", false);
|
||||
}
|
||||
|
||||
public String getAdminPermission() {
|
||||
return cfg.getString("admin.admin-permission", "consent.admin");
|
||||
}
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────
|
||||
|
||||
public String getPrefix() { return cfg.getString("messages.prefix", "<gold>[Privacy]</gold> "); }
|
||||
public String getMsgJoinPrompt() { return cfg.getString("messages.join-prompt", "Please accept our Privacy Policy. Type /{command} accept"); }
|
||||
public String getMsgAccepted() { return cfg.getString("messages.accepted", "<green>Thank you for accepting the Privacy Policy!</green>"); }
|
||||
public String getMsgDeclined() { return cfg.getString("messages.declined", "<red>You declined the Privacy Policy.</red>"); }
|
||||
public String getMsgKick() { return cfg.getString("messages.kick-message", "Privacy Policy consent required.\nVisit: {url}\nReconnect and type /{command} accept."); }
|
||||
public String getMsgDeclineKick() { return cfg.getString("messages.decline-kick-message", "You declined the Privacy Policy."); }
|
||||
public String getMsgGraceKick() { return cfg.getString("messages.grace-kick-message", "Consent timeout.\nVisit: {url}\nReconnect and type /{command} accept."); }
|
||||
public String getMsgHoldMoveBlocked() { return cfg.getString("messages.hold-move-blocked", "<red>Accept the Privacy Policy first! Type /{command} accept</red>"); }
|
||||
public String getMsgHoldChatBlocked() { return cfg.getString("messages.hold-chat-blocked", "<red>You cannot chat until you accept the Privacy Policy.</red>"); }
|
||||
public String getMsgHoldCmdBlocked() { return cfg.getString("messages.hold-command-blocked", "<red>Commands disabled until you accept the Privacy Policy.</red>"); }
|
||||
public String getMsgAlreadyAccepted() { return cfg.getString("messages.already-accepted", "<green>You have already accepted the current Privacy Policy.</green>"); }
|
||||
public String getMsgNoConsentNeeded() { return cfg.getString("messages.no-consent-required", "<gray>No consent is currently required.</gray>"); }
|
||||
public String getMsgGraceWarning() { return cfg.getString("messages.grace-warning", "<yellow>You have {seconds}s left to accept or you will be disconnected.</yellow>"); }
|
||||
public String getMsgAdminAccept() { return cfg.getString("messages.admin-notify-accept", "<dark_gray>[Consent] {player} accepted (v{version}).</dark_gray>"); }
|
||||
public String getMsgAdminDecline() { return cfg.getString("messages.admin-notify-decline", "<dark_gray>[Consent] {player} declined (v{version}).</dark_gray>"); }
|
||||
public String getMsgHelpHeader() { return cfg.getString("messages.help-header", "<gold><bold>===== MCConsent Help =====</bold></gold>"); }
|
||||
public String getMsgRemind() { return cfg.getString("messages.remind-message", "<yellow>Please accept our Privacy Policy. Type /{command} accept</yellow>"); }
|
||||
public String getMsgStatusAccepted() { return cfg.getString("messages.status-accepted", "<green>You have accepted the Privacy Policy (v{version}).</green>"); }
|
||||
public String getMsgStatusPending() { return cfg.getString("messages.status-pending", "<yellow>You have NOT yet accepted the Privacy Policy (v{version}).</yellow>"); }
|
||||
public String getMsgNoPermission() { return cfg.getString("messages.no-permission", "<red>You don't have permission to use this command.</red>"); }
|
||||
public String getMsgAdminNotifyConsent() { return cfg.getString("messages.admin-notify-consent", "<dark_gray>[Consent] {player} accepted the Privacy Policy (v{version}).</dark_gray>"); }
|
||||
public String getMsgWithdrawn() { return cfg.getString("messages.withdrawn", "<yellow>Your consent has been withdrawn. You will be disconnected.</yellow>"); }
|
||||
public String getMsgWithdrawKick() { return cfg.getString("messages.withdraw-kick-message", "Consent withdrawn.\nYou may reconnect at any time and accept the Privacy Policy.\nVisit: {policy_url}"); }
|
||||
|
||||
/** Whether a message should be sent to online admins when a player accepts. */
|
||||
public boolean isNotifyAdminsOnConsent() {
|
||||
return cfg.getBoolean("admin.notify-admins-on-consent", false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days to keep accepted consent records (0 = keep forever).
|
||||
* GDPR Art. 5(1)(e) – storage limitation.
|
||||
*/
|
||||
public int getConsentRetentionDays() {
|
||||
return cfg.getInt("consent.retention-days-consent", 1095); // 3 years default
|
||||
}
|
||||
|
||||
/**
|
||||
* How many days to keep decline records (0 = keep forever).
|
||||
*/
|
||||
public int getDeclineRetentionDays() {
|
||||
return cfg.getInt("consent.retention-days-declines", 1095);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the help text as a list of MiniMessage lines.
|
||||
* Falls back to a built-in list if not configured.
|
||||
*/
|
||||
public List<String> getHelpLines() {
|
||||
List<String> lines = cfg.getStringList("messages.help-lines");
|
||||
if (!lines.isEmpty()) return lines;
|
||||
return List.of(
|
||||
getMsgHelpHeader(),
|
||||
"<gray>/consent accept</gray> <white>Accept the Privacy Policy</white>",
|
||||
"<gray>/consent decline</gray> <white>Decline and disconnect</white>",
|
||||
"<gray>/consent status</gray> <white>Check your consent status</white>",
|
||||
"<gray>Policy: <aqua>{policy_url}</aqua>"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When {@code true}, kick messages are forwarded to the Velocity / BungeeCord
|
||||
* proxy via the {@code BungeeCord} plugin-messaging channel so the player is
|
||||
* removed from the entire network rather than just the current server.
|
||||
*/
|
||||
public boolean isVelocityKick() {
|
||||
return cfg.getBoolean("consent.velocity-kick", false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.simolzimol.mclogger.consent;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import com.google.common.io.ByteArrayDataOutput;
|
||||
import com.google.common.io.ByteStreams;
|
||||
|
||||
import de.simolzimol.mclogger.consent.commands.ConsentCommand;
|
||||
import de.simolzimol.mclogger.consent.database.ConsentDatabase;
|
||||
import de.simolzimol.mclogger.consent.listeners.ConsentListener;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
|
||||
/**
|
||||
* MCConsent – Privacy Policy consent enforcement for Paper servers.
|
||||
*
|
||||
* <p>Key components:
|
||||
* <ul>
|
||||
* <li>{@link ConsentConfig} – Strongly-typed configuration wrapper</li>
|
||||
* <li>{@link ConsentDatabase} – HikariCP-backed MariaDB/MySQL client</li>
|
||||
* <li>{@link ConsentListener} – Enforces consent on login/join/move/chat</li>
|
||||
* <li>{@link ConsentCommand} – /consent accept|decline|status|admin …</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author SimolZimol
|
||||
* @version 1.0.0
|
||||
*/
|
||||
public class ConsentPlugin extends JavaPlugin {
|
||||
|
||||
private static ConsentPlugin instance;
|
||||
|
||||
private ConsentConfig consentConfig;
|
||||
private ConsentDatabase consentDatabase;
|
||||
|
||||
/**
|
||||
* UUIDs of players who have joined but not yet consented (HOLD mode).
|
||||
* Thread-safe because both the main thread and async events touch this set.
|
||||
*/
|
||||
private final Set<UUID> pendingConsent = ConcurrentHashMap.newKeySet();
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@Override
|
||||
public void onEnable() {
|
||||
instance = this;
|
||||
|
||||
saveDefaultConfig();
|
||||
consentConfig = new ConsentConfig(this);
|
||||
|
||||
if (!consentConfig.isEnabled()) {
|
||||
getLogger().info("[MCConsent] Plugin is disabled via config (enabled: false).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
consentDatabase = new ConsentDatabase(this);
|
||||
if (!consentDatabase.connect()) {
|
||||
getLogger().severe("[MCConsent] Could not connect to the database – disabling plugin.");
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register plugin-messaging channel for Velocity / BungeeCord network kick
|
||||
getServer().getMessenger().registerOutgoingPluginChannel(this, "BungeeCord");
|
||||
|
||||
// Register event listeners
|
||||
ConsentListener listener = new ConsentListener(this);
|
||||
getServer().getPluginManager().registerEvents(listener, this);
|
||||
|
||||
// Register /consent command
|
||||
ConsentCommand cmdHandler = new ConsentCommand(this);
|
||||
PluginCommand cmd = getCommand("consent");
|
||||
if (cmd != null) {
|
||||
cmd.setExecutor(cmdHandler);
|
||||
cmd.setTabCompleter(cmdHandler);
|
||||
} else {
|
||||
getLogger().warning("[MCConsent] Could not find 'consent' command in plugin.yml!");
|
||||
}
|
||||
|
||||
getLogger().info("[MCConsent] Started. Mode=" + consentConfig.getEnforcementMode()
|
||||
+ " PolicyVersion=" + consentConfig.getPolicyVersion());
|
||||
|
||||
// GDPR Art. 5(1)(e) – automatic retention purge, runs once every 24 h
|
||||
int consentDays = consentConfig.getConsentRetentionDays();
|
||||
int declineDays = consentConfig.getDeclineRetentionDays();
|
||||
if (consentDays > 0 || declineDays > 0) {
|
||||
long dayTicks = 20L * 60 * 60 * 24;
|
||||
getServer().getScheduler().runTaskTimerAsynchronously(this,
|
||||
() -> consentDatabase.purgeOldConsents(consentDays, declineDays),
|
||||
dayTicks, dayTicks);
|
||||
getLogger().info("[MCConsent] GDPR retention purge scheduled (consent=" + consentDays
|
||||
+ "d, declines=" + declineDays + "d).");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@Override
|
||||
public void onDisable() {
|
||||
pendingConsent.clear();
|
||||
if (consentDatabase != null) {
|
||||
consentDatabase.disconnect();
|
||||
}
|
||||
getLogger().info("[MCConsent] Disabled.");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Accessors
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
public static ConsentPlugin getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ConsentConfig getConsentConfig() {
|
||||
return consentConfig;
|
||||
}
|
||||
|
||||
public ConsentDatabase getConsentDatabase() {
|
||||
return consentDatabase;
|
||||
}
|
||||
|
||||
/** Live set of players currently waiting for consent (HOLD mode). */
|
||||
public Set<UUID> getPendingConsent() {
|
||||
return pendingConsent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a player from the current server, or from the entire
|
||||
* Velocity / BungeeCord network when {@code velocity-kick: true} is set
|
||||
* in the config. Falls back to a normal kick if the proxy message fails.
|
||||
*/
|
||||
public void networkKick(Player player, Component reason) {
|
||||
if (consentConfig.isVelocityKick()) {
|
||||
try {
|
||||
ByteArrayDataOutput out = ByteStreams.newDataOutput();
|
||||
out.writeUTF("KickPlayer");
|
||||
out.writeUTF(player.getName());
|
||||
out.writeUTF(LegacyComponentSerializer.legacySection().serialize(reason));
|
||||
player.sendPluginMessage(this, "BungeeCord", out.toByteArray());
|
||||
} catch (Exception e) {
|
||||
getLogger().warning("[MCConsent] Velocity/BungeeCord network kick failed: " + e.getMessage());
|
||||
player.kick(reason);
|
||||
return;
|
||||
}
|
||||
// Fallback: if the proxy never kicks, remove the player after 1 s
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (player.isOnline()) player.kick(reason);
|
||||
}, 20L);
|
||||
} else {
|
||||
player.kick(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package de.simolzimol.mclogger.consent.commands;
|
||||
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig;
|
||||
import de.simolzimol.mclogger.consent.ConsentPlugin;
|
||||
import de.simolzimol.mclogger.consent.util.MessageUtil;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.command.*;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Handles the {@code /consent} command and its sub-commands.
|
||||
*
|
||||
* <p><b>Player sub-commands:</b>
|
||||
* <ul>
|
||||
* <li>{@code /consent accept} – record consent for the current policy version</li>
|
||||
* <li>{@code /consent decline} – record decline and kick the player</li>
|
||||
* <li>{@code /consent withdraw} – withdraw previously given consent (Art. 7(3) GDPR)</li>
|
||||
* <li>{@code /consent status} – show current consent status</li>
|
||||
* <li>{@code /consent help} – show help text</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Admin sub-commands} (require {@code mclogger.consent.admin}):</b>
|
||||
* <ul>
|
||||
* <li>{@code /consent admin reload} – reload plugin config</li>
|
||||
* <li>{@code /consent admin list} – list online players who haven't consented</li>
|
||||
* <li>{@code /consent admin revoke <player>} – revoke all consents for a player</li>
|
||||
* <li>{@code /consent admin forceaccept <player>} – record consent on behalf of a player</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class ConsentCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private static final MiniMessage MM = MiniMessage.miniMessage();
|
||||
|
||||
private final ConsentPlugin plugin;
|
||||
|
||||
public ConsentCommand(ConsentPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Command dispatch
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
ConsentConfig cfg = plugin.getConsentConfig();
|
||||
|
||||
if (args.length == 0) {
|
||||
sendHelp(sender, cfg);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (args[0].toLowerCase(Locale.ROOT)) {
|
||||
case "accept" -> handleAccept(sender, cfg);
|
||||
case "decline" -> handleDecline(sender, cfg);
|
||||
case "withdraw" -> handleWithdraw(sender, cfg);
|
||||
case "status" -> handleStatus(sender, cfg);
|
||||
case "help" -> sendHelp(sender, cfg);
|
||||
case "admin" -> {
|
||||
if (!sender.hasPermission("mclogger.consent.admin")) {
|
||||
sender.sendMessage(MM.deserialize(cfg.getMsgNoPermission()));
|
||||
return true;
|
||||
}
|
||||
handleAdmin(sender, cfg, args);
|
||||
}
|
||||
default -> sendHelp(sender, cfg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Player sub-commands
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private void handleAccept(CommandSender sender, ConsentConfig cfg) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
return;
|
||||
}
|
||||
|
||||
String uuid = player.getUniqueId().toString();
|
||||
String version = cfg.getPolicyVersion();
|
||||
String ip = player.getAddress() != null
|
||||
? player.getAddress().getAddress().getHostAddress() : "unknown";
|
||||
|
||||
plugin.getConsentDatabase().recordConsent(uuid, player.getName(), version, ip);
|
||||
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||
|
||||
String msg = MessageUtil.replace(cfg.getMsgAccepted(), player, cfg);
|
||||
player.sendMessage(MM.deserialize(msg));
|
||||
|
||||
// Notify admins if configured
|
||||
if (cfg.isNotifyAdminsOnConsent()) {
|
||||
String notify = MessageUtil.replace(cfg.getMsgAdminNotifyConsent(), player, cfg);
|
||||
broadcastAdmins(notify);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDecline(CommandSender sender, ConsentConfig cfg) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
return;
|
||||
}
|
||||
|
||||
String uuid = player.getUniqueId().toString();
|
||||
String version = cfg.getPolicyVersion();
|
||||
String ip = player.getAddress() != null
|
||||
? player.getAddress().getAddress().getHostAddress() : "unknown";
|
||||
|
||||
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version, ip);
|
||||
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||
|
||||
String msg = MessageUtil.replace(cfg.getMsgDeclined(), player, cfg);
|
||||
plugin.networkKick(player, MM.deserialize(msg));
|
||||
}
|
||||
|
||||
private void handleWithdraw(CommandSender sender, ConsentConfig cfg) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
return;
|
||||
}
|
||||
|
||||
String uuid = player.getUniqueId().toString();
|
||||
String version = cfg.getPolicyVersion();
|
||||
|
||||
// Revoke all versions so the player must re-accept next time
|
||||
plugin.getConsentDatabase().revokeConsent(uuid, null);
|
||||
plugin.getPendingConsent().remove(player.getUniqueId());
|
||||
|
||||
// Record the withdrawal in the decline table as an audit trail
|
||||
plugin.getConsentDatabase().recordDecline(uuid, player.getName(), version + "_WITHDRAWN",
|
||||
player.getAddress() != null ? player.getAddress().getAddress().getHostAddress() : "unknown");
|
||||
|
||||
String withdrawMsg = MessageUtil.replace(cfg.getMsgWithdrawn(), player, cfg);
|
||||
player.sendMessage(MM.deserialize(withdrawMsg));
|
||||
|
||||
// Kick using the configured kick message
|
||||
String kickMsg = MessageUtil.replace(cfg.getMsgWithdrawKick(), player, cfg);
|
||||
plugin.networkKick(player, MM.deserialize(kickMsg));
|
||||
}
|
||||
|
||||
private void handleStatus(CommandSender sender, ConsentConfig cfg) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(MM.deserialize("<red>This command can only be used in-game."));
|
||||
return;
|
||||
}
|
||||
|
||||
boolean consented = plugin.getConsentDatabase()
|
||||
.hasConsented(player.getUniqueId().toString(), cfg.getPolicyVersion());
|
||||
|
||||
String template = consented ? cfg.getMsgStatusAccepted() : cfg.getMsgStatusPending();
|
||||
player.sendMessage(MM.deserialize(MessageUtil.replace(template, player, cfg)));
|
||||
}
|
||||
|
||||
private void sendHelp(CommandSender sender, ConsentConfig cfg) {
|
||||
for (String line : cfg.getHelpLines()) {
|
||||
if (sender instanceof Player player) {
|
||||
sender.sendMessage(MM.deserialize(MessageUtil.replace(line, player, cfg)));
|
||||
} else {
|
||||
// Strip MiniMessage tags for console
|
||||
sender.sendMessage(MM.stripTags(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Admin sub-commands
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private void handleAdmin(CommandSender sender, ConsentConfig cfg, String[] args) {
|
||||
if (args.length < 2) {
|
||||
sender.sendMessage(MM.deserialize("<yellow>Usage: /consent admin <reload|list|revoke|forceaccept>"));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (args[1].toLowerCase(Locale.ROOT)) {
|
||||
case "reload" -> {
|
||||
plugin.reloadConsentConfig();
|
||||
sender.sendMessage(MM.deserialize("<green>[MCConsent] Config reloaded."));
|
||||
}
|
||||
|
||||
case "list" -> {
|
||||
Set<UUID> pending = plugin.getPendingConsent();
|
||||
if (pending.isEmpty()) {
|
||||
sender.sendMessage(MM.deserialize("<green>[MCConsent] No players are currently awaiting consent."));
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(MM.deserialize("<yellow>[MCConsent] Pending consent (" + pending.size() + "):"));
|
||||
for (UUID uuid : pending) {
|
||||
Player p = Bukkit.getPlayer(uuid);
|
||||
String name = p != null ? p.getName() : uuid.toString();
|
||||
sender.sendMessage(MM.deserialize("<gray> - " + name));
|
||||
}
|
||||
}
|
||||
|
||||
case "revoke" -> {
|
||||
if (args.length < 3) {
|
||||
sender.sendMessage(MM.deserialize("<red>Usage: /consent admin revoke <player>"));
|
||||
return;
|
||||
}
|
||||
String target = args[2];
|
||||
@SuppressWarnings("deprecation")
|
||||
OfflinePlayer op = Bukkit.getOfflinePlayer(target);
|
||||
if (!op.hasPlayedBefore() && !op.isOnline()) {
|
||||
sender.sendMessage(MM.deserialize("<red>Player not found: " + target));
|
||||
return;
|
||||
}
|
||||
plugin.getConsentDatabase().revokeConsent(op.getUniqueId().toString(), null);
|
||||
// Also add to pending if currently online
|
||||
if (op.isOnline()) {
|
||||
plugin.getPendingConsent().add(op.getUniqueId());
|
||||
}
|
||||
sender.sendMessage(MM.deserialize("<green>[MCConsent] Consent revoked for " + op.getName()
|
||||
+ ". They will need to re-accept on next login."));
|
||||
}
|
||||
|
||||
case "forceaccept" -> {
|
||||
if (args.length < 3) {
|
||||
sender.sendMessage(MM.deserialize("<red>Usage: /consent admin forceaccept <player>"));
|
||||
return;
|
||||
}
|
||||
String target = args[2];
|
||||
@SuppressWarnings("deprecation")
|
||||
OfflinePlayer op = Bukkit.getOfflinePlayer(target);
|
||||
if (!op.hasPlayedBefore() && !op.isOnline()) {
|
||||
sender.sendMessage(MM.deserialize("<red>Player not found: " + target));
|
||||
return;
|
||||
}
|
||||
String version = cfg.getPolicyVersion();
|
||||
plugin.getConsentDatabase().recordConsent(
|
||||
op.getUniqueId().toString(),
|
||||
op.getName() != null ? op.getName() : "unknown",
|
||||
version, "admin-forced");
|
||||
plugin.getPendingConsent().remove(op.getUniqueId());
|
||||
sender.sendMessage(MM.deserialize("<green>[MCConsent] Consent recorded for " + op.getName()
|
||||
+ " (version: " + version + ")."));
|
||||
}
|
||||
|
||||
default -> sender.sendMessage(MM.deserialize(
|
||||
"<yellow>Unknown admin sub-command. Use: reload | list | revoke | forceaccept"));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Tab completion
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (args.length == 1) {
|
||||
List<String> base = new ArrayList<>(List.of("accept", "decline", "withdraw", "status", "help"));
|
||||
if (sender.hasPermission("mclogger.consent.admin")) base.add("admin");
|
||||
return filter(base, args[0]);
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("admin")
|
||||
&& sender.hasPermission("mclogger.consent.admin")) {
|
||||
return filter(List.of("reload", "list", "revoke", "forceaccept"), args[1]);
|
||||
}
|
||||
if (args.length == 3
|
||||
&& args[0].equalsIgnoreCase("admin")
|
||||
&& (args[1].equalsIgnoreCase("revoke") || args[1].equalsIgnoreCase("forceaccept"))
|
||||
&& sender.hasPermission("mclogger.consent.admin")) {
|
||||
List<String> names = new ArrayList<>();
|
||||
Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName()));
|
||||
return filter(names, args[2]);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private static List<String> filter(List<String> options, String prefix) {
|
||||
List<String> result = new ArrayList<>();
|
||||
String lower = prefix.toLowerCase(Locale.ROOT);
|
||||
for (String opt : options) {
|
||||
if (opt.toLowerCase(Locale.ROOT).startsWith(lower)) result.add(opt);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void broadcastAdmins(String miniMsg) {
|
||||
Bukkit.getOnlinePlayers().stream()
|
||||
.filter(p -> p.hasPermission("mclogger.consent.admin"))
|
||||
.forEach(p -> p.sendMessage(MM.deserialize(miniMsg)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package de.simolzimol.mclogger.consent.database;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig;
|
||||
import de.simolzimol.mclogger.consent.ConsentPlugin;
|
||||
|
||||
/**
|
||||
* HikariCP-backed database client for MCConsent.
|
||||
*
|
||||
* <p>Tables managed by this class:
|
||||
* <ul>
|
||||
* <li>{@code player_consent} – stores accepted consents per UUID + version</li>
|
||||
* <li>{@code player_consent_declines} – audit trail of declined consents</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Both tables are created automatically on first connection.
|
||||
* If {@code admin.log-to-mclogger-db} is {@code true} consent events are also
|
||||
* inserted into the {@code server_events} table that MCLogger maintains.
|
||||
*
|
||||
* @author SimolZimol
|
||||
*/
|
||||
public class ConsentDatabase {
|
||||
|
||||
private final ConsentPlugin plugin;
|
||||
private HikariDataSource dataSource;
|
||||
|
||||
public ConsentDatabase(ConsentPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens the connection pool and ensures the required tables exist.
|
||||
*
|
||||
* @return {@code true} if the connection was established successfully.
|
||||
*/
|
||||
public boolean connect() {
|
||||
ConsentConfig cfg = plugin.getConsentConfig();
|
||||
|
||||
HikariConfig hk = new HikariConfig();
|
||||
hk.setDriverClassName("de.simolzimol.mcconsent.lib.mariadb.jdbc.Driver");
|
||||
hk.setJdbcUrl(String.format(
|
||||
"jdbc:mariadb://%s:%d/%s?useSSL=%b&autoReconnect=true&characterEncoding=UTF-8",
|
||||
cfg.getDbHost(), cfg.getDbPort(), cfg.getDbDatabase(), cfg.isDbSsl()));
|
||||
hk.setUsername(cfg.getDbUsername());
|
||||
hk.setPassword(cfg.getDbPassword());
|
||||
hk.setMaximumPoolSize(cfg.getDbPoolSize());
|
||||
hk.setMinimumIdle(1);
|
||||
hk.setConnectionTimeout(30_000);
|
||||
hk.setIdleTimeout(600_000);
|
||||
hk.setMaxLifetime(1_800_000);
|
||||
hk.setPoolName("MCConsent");
|
||||
hk.addDataSourceProperty("cachePrepStmts", "true");
|
||||
hk.addDataSourceProperty("prepStmtCacheSize", "100");
|
||||
|
||||
try {
|
||||
dataSource = new HikariDataSource(hk);
|
||||
initTables();
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().log(Level.SEVERE, "[MCConsent] Database connection failed!", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the connection pool. */
|
||||
public void disconnect() {
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Table initialisation
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private void initTables() throws SQLException {
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
|
||||
// Accepted consents – primary record of who agreed to what version.
|
||||
// ip_address is pseudonymized (last octet/80 bits zeroed) at insert time.
|
||||
// policy_hash is the SHA-256 of the policy_version string for accountability.
|
||||
stmt.execute(
|
||||
"CREATE TABLE IF NOT EXISTS player_consent (" +
|
||||
" uuid VARCHAR(36) NOT NULL," +
|
||||
" username VARCHAR(16) NOT NULL," +
|
||||
" policy_version VARCHAR(64) NOT NULL," +
|
||||
" policy_hash VARCHAR(64) NULL," +
|
||||
" consented_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
|
||||
" ip_address VARCHAR(45) NULL," +
|
||||
" PRIMARY KEY (uuid, policy_version)," +
|
||||
" INDEX idx_consent_uuid (uuid)," +
|
||||
" INDEX idx_consent_version (policy_version)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
|
||||
// Migrate existing tables: add policy_hash if not present
|
||||
stmt.execute(
|
||||
"ALTER TABLE player_consent " +
|
||||
"ADD COLUMN IF NOT EXISTS policy_hash VARCHAR(64) NULL");
|
||||
|
||||
// Decline audit trail
|
||||
stmt.execute(
|
||||
"CREATE TABLE IF NOT EXISTS player_consent_declines (" +
|
||||
" id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
||||
" uuid VARCHAR(36) NOT NULL," +
|
||||
" username VARCHAR(16) NOT NULL," +
|
||||
" policy_version VARCHAR(64) NOT NULL," +
|
||||
" declined_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3)," +
|
||||
" ip_address VARCHAR(45) NULL," +
|
||||
" INDEX idx_decline_uuid (uuid)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Query helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the player has accepted the given policy version.
|
||||
* Runs synchronously; should be called from the main thread or a context
|
||||
* where a brief pause is acceptable (e.g. {@code PlayerLoginEvent}).
|
||||
*/
|
||||
public boolean hasConsented(String uuid, String policyVersion) {
|
||||
final String sql =
|
||||
"SELECT 1 FROM player_consent WHERE uuid = ? AND policy_version = ? LIMIT 1";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, uuid);
|
||||
ps.setString(2, policyVersion);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] hasConsented() failed", e);
|
||||
// Fail-open: if we can't check, allow the player in (avoids locking everyone out)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the player has accepted the current policy version.
|
||||
* Uses {@code INSERT IGNORE} so duplicate calls are safe.
|
||||
*
|
||||
* <p>The IP address is pseudonymized before storage (Art. 5(1)(c) GDPR):
|
||||
* last octet zeroed for IPv4, last 80 bits zeroed for IPv6.
|
||||
* A SHA-256 hash of the policy version string is stored for accountability
|
||||
* (Art. 5(2) GDPR).
|
||||
*/
|
||||
public void recordConsent(String uuid, String username, String policyVersion, String ip) {
|
||||
final String sql =
|
||||
"INSERT IGNORE INTO player_consent (uuid, username, policy_version, policy_hash, ip_address)" +
|
||||
" VALUES (?, ?, ?, ?, ?)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, uuid);
|
||||
ps.setString(2, username);
|
||||
ps.setString(3, policyVersion);
|
||||
ps.setString(4, computePolicyHash(policyVersion));
|
||||
ps.setString(5, pseudonymizeIp(ip));
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordConsent() failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the player declined the current policy version.
|
||||
* Declines are appended to an audit table; their IP is pseudonymized.
|
||||
*/
|
||||
public void recordDecline(String uuid, String username, String policyVersion, String ip) {
|
||||
final String sql =
|
||||
"INSERT INTO player_consent_declines (uuid, username, policy_version, ip_address)" +
|
||||
" VALUES (?, ?, ?, ?)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, uuid);
|
||||
ps.setString(2, username);
|
||||
ps.setString(3, policyVersion);
|
||||
ps.setString(4, pseudonymizeIp(ip));
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] recordDecline() failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a player's accepted consent for the given version.
|
||||
* Used by {@code /consent admin revoke <player>} to force re-acceptance.
|
||||
*
|
||||
* @param uuid Player UUID string
|
||||
* @param policyVersion Version to revoke; {@code null} revokes ALL versions
|
||||
*/
|
||||
public void revokeConsent(String uuid, String policyVersion) {
|
||||
final String sql = policyVersion == null
|
||||
? "DELETE FROM player_consent WHERE uuid = ?"
|
||||
: "DELETE FROM player_consent WHERE uuid = ? AND policy_version = ?";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, uuid);
|
||||
if (policyVersion != null) ps.setString(2, policyVersion);
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] revokeConsent() failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of UUIDs (as strings) that have no consent record for
|
||||
* {@code currentVersion}. Only considers players known to the
|
||||
* {@code players} table (MCLogger integration); returns an empty list if
|
||||
* that table does not exist.
|
||||
*/
|
||||
public List<String> getPendingPlayerUuids(String currentVersion) {
|
||||
final String sql =
|
||||
"SELECT p.uuid FROM players p" +
|
||||
" WHERE NOT EXISTS (" +
|
||||
" SELECT 1 FROM player_consent c" +
|
||||
" WHERE c.uuid = p.uuid AND c.policy_version = ?" +
|
||||
" ) LIMIT 100";
|
||||
List<String> result = new ArrayList<>();
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, currentVersion);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) result.add(rs.getString("uuid"));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
// players table may not exist in standalone mode
|
||||
plugin.getLogger().fine("[MCConsent] getPendingPlayerUuids(): " + e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally writes a consent event into the MCLogger {@code server_events}
|
||||
* table so it shows up in the web panel audit stream.
|
||||
* Silently ignored if the table does not exist or the insert fails.
|
||||
*
|
||||
* @param eventType {@code "consent_accepted"} or {@code "consent_declined"}
|
||||
* @param playerName In-game name for the log message
|
||||
* @param uuid Player UUID string
|
||||
* @param version Policy version string
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// GDPR – Art. 5(1)(e) Storage limitation
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deletes consent and decline records older than the configured retention
|
||||
* periods. Pass 0 to skip deletion for that table.
|
||||
*
|
||||
* <p>Intended to be called from an async scheduled task once per day.
|
||||
*/
|
||||
public void purgeOldConsents(int consentRetentionDays, int declineRetentionDays) {
|
||||
if (consentRetentionDays > 0) {
|
||||
final String sql =
|
||||
"DELETE FROM player_consent " +
|
||||
"WHERE consented_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setInt(1, consentRetentionDays);
|
||||
int rows = ps.executeUpdate();
|
||||
if (rows > 0) {
|
||||
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
|
||||
+ " consent record(s) older than " + consentRetentionDays + " days.");
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() consent failed", e);
|
||||
}
|
||||
}
|
||||
if (declineRetentionDays > 0) {
|
||||
final String sql =
|
||||
"DELETE FROM player_consent_declines " +
|
||||
"WHERE declined_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)";
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setInt(1, declineRetentionDays);
|
||||
int rows = ps.executeUpdate();
|
||||
if (rows > 0) {
|
||||
plugin.getLogger().info("[MCConsent] GDPR retention: removed " + rows
|
||||
+ " decline record(s) older than " + declineRetentionDays + " days.");
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.getLogger().log(Level.WARNING, "[MCConsent] purgeOldConsents() declines failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Privacy helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pseudonymizes an IP address for storage (Art. 5(1)(c) GDPR – data minimisation).
|
||||
* <ul>
|
||||
* <li>IPv4: last octet set to 0 (e.g. {@code 192.168.1.100} → {@code 192.168.1.0})</li>
|
||||
* <li>IPv6: last 80 bits set to 0 (keeps the first 48 bits / ISP prefix)</li>
|
||||
* </ul>
|
||||
* Returns {@code "unknown"} if the address cannot be parsed.
|
||||
*/
|
||||
static String pseudonymizeIp(String ip) {
|
||||
if (ip == null || ip.isBlank() || ip.equals("unknown") || ip.equals("admin-forced")) {
|
||||
return ip;
|
||||
}
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(ip);
|
||||
byte[] bytes = addr.getAddress();
|
||||
if (bytes.length == 4) {
|
||||
// IPv4 – zero last octet
|
||||
bytes[3] = 0;
|
||||
} else if (bytes.length == 16) {
|
||||
// IPv6 – zero last 10 bytes (80 bits)
|
||||
for (int i = 6; i < 16; i++) bytes[i] = 0;
|
||||
}
|
||||
return InetAddress.getByAddress(bytes).getHostAddress();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a SHA-256 hex digest of the policy version string.
|
||||
* Stored alongside each consent record so the exact version accepted can
|
||||
* be verified later (Art. 5(2) GDPR – accountability).
|
||||
*/
|
||||
static String computePolicyHash(String policyVersion) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(policyVersion.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
public void logToMCLogger(String eventType, String playerName, String uuid, String version) {
|
||||
if (!plugin.getConsentConfig().isLogToMCLoggerDb()) return;
|
||||
|
||||
final String sql =
|
||||
"INSERT INTO server_events (event_type, server_name, message, details)" +
|
||||
" VALUES (?, ?, ?, ?)";
|
||||
String serverName = plugin.getConsentConfig().getDbDatabase();
|
||||
String message = playerName + " " + eventType.replace('_', ' ') + " (v" + version + ")";
|
||||
// Build minimal JSON manually to avoid a Gson dependency
|
||||
String details = "{\"uuid\":\"" + uuid + "\",\"policy_version\":\"" + version + "\"}";
|
||||
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setString(1, eventType);
|
||||
ps.setString(2, serverName);
|
||||
ps.setString(3, message);
|
||||
ps.setString(4, details);
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException ignored) {
|
||||
// Non-critical; server_events table may not exist in standalone mode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package de.simolzimol.mclogger.consent.listeners;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.player.AsyncPlayerChatEvent;
|
||||
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerLoginEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig;
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig.EnforcementMode;
|
||||
import de.simolzimol.mclogger.consent.ConsentPlugin;
|
||||
import de.simolzimol.mclogger.consent.util.MessageUtil;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
|
||||
/**
|
||||
* Listens to player lifecycle events and enforces the configured consent mode.
|
||||
*
|
||||
* <p>Three modes:
|
||||
* <ul>
|
||||
* <li><b>KICK</b> – player is kicked at {@link PlayerLoginEvent} if no consent.</li>
|
||||
* <li><b>HOLD</b> – player joins but movement/chat/commands are blocked until
|
||||
* they type {@code /consent accept}. The grace-period timer
|
||||
* runs as a scheduled task and kicks after the configured
|
||||
* timeout.</li>
|
||||
* <li><b>REMIND</b>- player joins normally; a repeating task sends reminder
|
||||
* messages until they accept.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author SimolZimol
|
||||
*/
|
||||
public class ConsentListener implements Listener {
|
||||
|
||||
private final ConsentPlugin plugin;
|
||||
/** Last time a move-blocked message was sent to a player (millis). */
|
||||
private final Map<UUID, Long> lastMoveMsgTime = new ConcurrentHashMap<>();
|
||||
|
||||
public ConsentListener(ConsentPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// LoginEvent: KICK mode enforcement
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onLogin(PlayerLoginEvent event) {
|
||||
ConsentConfig cfg = plugin.getConsentConfig();
|
||||
if (!cfg.isEnabled()) return;
|
||||
|
||||
Player player = event.getPlayer();
|
||||
if (player.hasPermission(cfg.getBypassPermission())) return;
|
||||
|
||||
String uuid = player.getUniqueId().toString();
|
||||
String version = cfg.getPolicyVersion();
|
||||
|
||||
// Only kick immediately in KICK mode; other modes handle it on join
|
||||
if (cfg.getEnforcementMode() == EnforcementMode.KICK) {
|
||||
boolean consented = plugin.getConsentDatabase().hasConsented(uuid, version);
|
||||
if (!consented) {
|
||||
String kickText = MessageUtil.replace(cfg.getMsgKick(), player, cfg);
|
||||
event.disallow(PlayerLoginEvent.Result.KICK_OTHER, kickText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// JoinEvent: HOLD / REMIND mode setup
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
ConsentConfig cfg = plugin.getConsentConfig();
|
||||
Player player = event.getPlayer();
|
||||
|
||||
if (!cfg.isEnabled()) return;
|
||||
if (player.hasPermission(cfg.getBypassPermission())) return;
|
||||
if (isExemptWorld(player, cfg)) return;
|
||||
|
||||
String uuid = player.getUniqueId().toString();
|
||||
String version = cfg.getPolicyVersion();
|
||||
|
||||
if (plugin.getConsentDatabase().hasConsented(uuid, version)) return;
|
||||
|
||||
EnforcementMode mode = cfg.getEnforcementMode();
|
||||
|
||||
// Open the policy book 1 tick after join (openBook can't be called during join)
|
||||
String promptMini = MessageUtil.replace(cfg.getMsgJoinPrompt(), player, cfg);
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
if (!player.isOnline()) return;
|
||||
openPolicyBook(player, promptMini);
|
||||
}, 1L);
|
||||
|
||||
if (mode == EnforcementMode.HOLD) {
|
||||
plugin.getPendingConsent().add(player.getUniqueId());
|
||||
scheduleGraceKick(player);
|
||||
} else if (mode == EnforcementMode.REMIND) {
|
||||
int interval = cfg.getReminderIntervalSeconds();
|
||||
if (interval > 0) {
|
||||
scheduleReminder(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// QuitEvent: clean up
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
UUID uuid = event.getPlayer().getUniqueId();
|
||||
plugin.getPendingConsent().remove(uuid);
|
||||
lastMoveMsgTime.remove(uuid);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// HOLD-mode: block movement
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
// Block if the player moved to a different block (ignore head rotation)
|
||||
if (event.getFrom().getBlockX() == event.getTo().getBlockX()
|
||||
&& event.getFrom().getBlockY() == event.getTo().getBlockY()
|
||||
&& event.getFrom().getBlockZ() == event.getTo().getBlockZ()) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
sendThrottled(event.getPlayer(),
|
||||
plugin.getConsentConfig().getMsgHoldMoveBlocked());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// HOLD-mode: block chat
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onChat(AsyncPlayerChatEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
// Must run on main thread; schedule a tick delayed task
|
||||
UUID uuid = event.getPlayer().getUniqueId();
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
Player p = Bukkit.getPlayer(uuid);
|
||||
if (p != null) {
|
||||
String msg = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgHoldChatBlocked(), p,
|
||||
plugin.getConsentConfig());
|
||||
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// HOLD-mode: block commands (except /consent and /pp)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCommand(PlayerCommandPreprocessEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
String cmd = event.getMessage().toLowerCase();
|
||||
// Allow /consent, /privacy, /pp (any of the registered aliases)
|
||||
if (cmd.startsWith("/consent") || cmd.startsWith("/privacy") || cmd.startsWith("/pp")) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
String msg = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgHoldCmdBlocked(),
|
||||
event.getPlayer(), plugin.getConsentConfig());
|
||||
event.getPlayer().sendMessage(MiniMessage.miniMessage().deserialize(msg));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// HOLD-mode: block block-break / place / interact
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onBlockBreak(BlockBreakEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onBlockPlace(BlockPlaceEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
if (!isHoldPending(event.getPlayer())) return;
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private boolean isHoldPending(Player player) {
|
||||
ConsentConfig cfg = plugin.getConsentConfig();
|
||||
if (!cfg.isEnabled()) return false;
|
||||
if (cfg.getEnforcementMode() != EnforcementMode.HOLD) return false;
|
||||
return plugin.getPendingConsent().contains(player.getUniqueId());
|
||||
}
|
||||
|
||||
private boolean isExemptWorld(Player player, ConsentConfig cfg) {
|
||||
String worldName = player.getWorld().getName();
|
||||
return cfg.getExemptWorlds().contains(worldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a written-book GUI with the policy prompt so the player can read
|
||||
* the full text including a clickable URL, without cluttering chat.
|
||||
* A short plain-chat hint is also sent so players know why the book opened.
|
||||
*/
|
||||
private void openPolicyBook(Player player, String promptMini) {
|
||||
MiniMessage mm = MiniMessage.miniMessage();
|
||||
|
||||
ItemStack book = new ItemStack(Material.WRITTEN_BOOK);
|
||||
BookMeta meta = (BookMeta) Objects.requireNonNull(book.getItemMeta());
|
||||
meta.title(mm.deserialize("<bold><blue>Privacy Policy</blue></bold>"));
|
||||
meta.author(Component.text("MCLogger"));
|
||||
|
||||
// First page: the configured join-prompt text (supports MiniMessage incl. click URLs)
|
||||
Component page1 = mm.deserialize(promptMini);
|
||||
meta.pages(List.of(page1));
|
||||
|
||||
book.setItemMeta(meta);
|
||||
player.openBook(book);
|
||||
|
||||
// Also send a brief chat message so the book title isn't the only indicator
|
||||
String hint = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgJoinPrompt(), player, plugin.getConsentConfig());
|
||||
player.sendMessage(mm.deserialize(hint));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kicks a player from the server or – if velocity-kick is enabled – from
|
||||
* the entire network. Delegates to {@link ConsentPlugin#networkKick}.
|
||||
*/
|
||||
void doKick(Player player, Component reason) {
|
||||
plugin.networkKick(player, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a MiniMessage-formatted message to the player with a cooldown to
|
||||
* avoid flooding the chat (e.g. when the player spams movement).
|
||||
*/
|
||||
private void sendThrottled(Player player, String miniMessageText) {
|
||||
long now = System.currentTimeMillis();
|
||||
long cooldown = plugin.getConsentConfig().getMoveMessageCooldownSeconds() * 1000L;
|
||||
Long lastSent = lastMoveMsgTime.get(player.getUniqueId());
|
||||
if (lastSent != null && (now - lastSent) < cooldown) return;
|
||||
|
||||
lastMoveMsgTime.put(player.getUniqueId(), now);
|
||||
String msg = MessageUtil.replace(miniMessageText, player, plugin.getConsentConfig());
|
||||
player.sendMessage(MiniMessage.miniMessage().deserialize(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a task that kicks the player after the grace period if they
|
||||
* still haven't consented. Silently cancelled if the player leaves first
|
||||
* or accepts in time.
|
||||
*/
|
||||
private void scheduleGraceKick(Player player) {
|
||||
int graceSec = plugin.getConsentConfig().getGracePeriodSeconds();
|
||||
if (graceSec <= 0) return; // infinite grace
|
||||
|
||||
UUID uuid = player.getUniqueId();
|
||||
|
||||
// Warning at half of the grace period
|
||||
int warnTick = (graceSec / 2) * 20;
|
||||
if (warnTick > 0) {
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
Player p = Bukkit.getPlayer(uuid);
|
||||
if (p == null || !plugin.getPendingConsent().contains(uuid)) return;
|
||||
String msg = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgGraceWarning()
|
||||
.replace("{seconds}", String.valueOf(graceSec / 2)),
|
||||
p, plugin.getConsentConfig());
|
||||
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
|
||||
}, warnTick);
|
||||
}
|
||||
|
||||
// Final kick
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
Player p = Bukkit.getPlayer(uuid);
|
||||
if (p == null) return;
|
||||
if (!plugin.getPendingConsent().contains(uuid)) return;
|
||||
plugin.getPendingConsent().remove(uuid);
|
||||
String kickMsg = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgGraceKick(), p, plugin.getConsentConfig());
|
||||
doKick(p, MiniMessage.miniMessage().deserialize(kickMsg));
|
||||
}, (long) graceSec * 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a repeating reminder task in REMIND mode.
|
||||
* Cancels automatically when the player has consented or leaves.
|
||||
*/
|
||||
private void scheduleReminder(Player player) {
|
||||
int intervalSec = plugin.getConsentConfig().getReminderIntervalSeconds();
|
||||
if (intervalSec <= 0) return;
|
||||
|
||||
UUID uuid = player.getUniqueId();
|
||||
long intervalTicks = (long) intervalSec * 20;
|
||||
final int[] taskId = {-1};
|
||||
|
||||
taskId[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
|
||||
Player p = Bukkit.getPlayer(uuid);
|
||||
if (p == null) {
|
||||
Bukkit.getScheduler().cancelTask(taskId[0]);
|
||||
return;
|
||||
}
|
||||
// Stop reminding once they've accepted
|
||||
if (plugin.getConsentDatabase().hasConsented(
|
||||
uuid.toString(), plugin.getConsentConfig().getPolicyVersion())) {
|
||||
Bukkit.getScheduler().cancelTask(taskId[0]);
|
||||
return;
|
||||
}
|
||||
String msg = MessageUtil.replace(
|
||||
plugin.getConsentConfig().getMsgRemind(), p, plugin.getConsentConfig());
|
||||
p.sendMessage(MiniMessage.miniMessage().deserialize(msg));
|
||||
}, intervalTicks, intervalTicks).getTaskId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.simolzimol.mclogger.consent.util;
|
||||
|
||||
import de.simolzimol.mclogger.consent.ConsentConfig;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/**
|
||||
* Simple placeholder replacement helper.
|
||||
*/
|
||||
public final class MessageUtil {
|
||||
|
||||
private MessageUtil() {}
|
||||
|
||||
/**
|
||||
* Replaces standard placeholders in a MiniMessage string.
|
||||
*
|
||||
* <p>Supported placeholders:
|
||||
* <ul>
|
||||
* <li>{@code {player}} – player's display name</li>
|
||||
* <li>{@code {url}} – configured policy URL</li>
|
||||
* <li>{@code {policy_url}} – configured policy URL (alias for {url})</li>
|
||||
* <li>{@code {command}} – consent command name (e.g. "consent")</li>
|
||||
* <li>{@code {version}} – policy version string</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static String replace(String template, Player player, ConsentConfig cfg) {
|
||||
if (template == null) return "";
|
||||
return template
|
||||
.replace("{player}", player.getName())
|
||||
.replace("{url}", cfg.getPolicyUrl())
|
||||
.replace("{policy_url}", cfg.getPolicyUrl())
|
||||
.replace("{command}", cfg.getCommand())
|
||||
.replace("{version}", cfg.getPolicyVersion());
|
||||
}
|
||||
}
|
||||
222
consent-plugin/src/main/resources/config.yml
Normal file
222
consent-plugin/src/main/resources/config.yml
Normal file
@@ -0,0 +1,222 @@
|
||||
# ============================================================
|
||||
# MCConsent – Paper Plugin Configuration
|
||||
# Author: SimolZimol
|
||||
# Website: https://log.devanturas.net
|
||||
# ============================================================
|
||||
|
||||
# ── Global switch ────────────────────────────────────────────
|
||||
# Set to false to completely disable all enforcement (plugin stays
|
||||
# loaded but won't block any player).
|
||||
enabled: true
|
||||
|
||||
# ── Database ─────────────────────────────────────────────────
|
||||
# Connection details for the MariaDB/MySQL database where consent
|
||||
# records are stored. You can point this at the same database as
|
||||
# the MCLogger plugin so consent events appear in the web panel.
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
database: "mclogger"
|
||||
username: "mclogger"
|
||||
password: "change_me_please"
|
||||
ssl: false
|
||||
# Maximum number of concurrent DB connections
|
||||
pool-size: 5
|
||||
|
||||
# ── Consent settings ─────────────────────────────────────────
|
||||
consent:
|
||||
# ── Policy version ──────────────────────────────────────────
|
||||
# Change this value every time you update your Privacy Policy.
|
||||
# Players who accepted an older version will be prompted again.
|
||||
# Tip: use the Document ID shown on your MCLogger web panel
|
||||
# policy page (e.g. "ABC123") for automatic synchronisation.
|
||||
policy-version: "1.0"
|
||||
|
||||
# ── Policy URL ──────────────────────────────────────────────
|
||||
# Full URL players can visit to read the Privacy Policy.
|
||||
# If you host it via the MCLogger web panel, the URL looks like:
|
||||
# https://your-panel.example.com/policy/<group-id>
|
||||
policy-url: "https://example.com/privacy-policy"
|
||||
|
||||
# ── Enforcement mode ────────────────────────────────────────
|
||||
# KICK – Kick the player immediately at login if no consent.
|
||||
# Players see a disconnect screen with the policy URL.
|
||||
# HOLD – Allow the player to join but freeze them in place.
|
||||
# They cannot move, chat, or run commands until they
|
||||
# accept (or the grace period expires and they get kicked).
|
||||
# REMIND – Let the player join freely; send periodic reminders
|
||||
# but never kick. Useful for soft opt-in scenarios.
|
||||
enforcement-mode: HOLD
|
||||
|
||||
# ── Command name ────────────────────────────────────────────
|
||||
# The command players type to interact with the consent system.
|
||||
# NOTE: This only changes display text in prompts.
|
||||
# The actual Bukkit command is always "/consent" (registered
|
||||
# in plugin.yml with aliases /privacy and /pp).
|
||||
command: "consent"
|
||||
|
||||
# ── Velocity / BungeeCord network kick ──────────────────────
|
||||
# Set to true if this server runs behind a Velocity or BungeeCord
|
||||
# proxy. When enabled, kick messages are forwarded via the
|
||||
# BungeeCord plugin-messaging channel so the player is removed
|
||||
# from the entire network instead of just this server.
|
||||
# Requires the proxy to have the plugin-messaging channel enabled.
|
||||
velocity-kick: false
|
||||
|
||||
# ── Grace period (HOLD mode only) ───────────────────────────
|
||||
# How many seconds a player has to accept before being kicked.
|
||||
# Set to 0 to wait forever (player can take as long as they like).
|
||||
grace-period-seconds: 120
|
||||
|
||||
# ── Reminder interval (REMIND mode only) ────────────────────
|
||||
# How often (seconds) to resend the consent prompt to players
|
||||
# who haven't responded. 0 = send once on join only.
|
||||
reminder-interval-seconds: 300
|
||||
|
||||
# ── Move-message cooldown ────────────────────────────────────
|
||||
# Minimum seconds between "please accept first" messages when a
|
||||
# player tries to move in HOLD mode. Prevents chat spam.
|
||||
move-message-cooldown-seconds: 5
|
||||
|
||||
# ── Exempt worlds ────────────────────────────────────────────
|
||||
# Players in these worlds are not subject to consent enforcement.
|
||||
# Useful if you have a lobby/hub world where players arrive before
|
||||
# being redirected to a game world.
|
||||
# Example: ["world_lobby", "world_hub"]
|
||||
exempt-worlds: []
|
||||
|
||||
# ── Bypass permission ────────────────────────────────────────
|
||||
# Players with this permission bypass all consent checks.
|
||||
# Default: consent.bypass (assigned to ops by default)
|
||||
bypass-permission: "consent.bypass"
|
||||
# ── GDPR – Aufbewahrungsfristen (Art. 5(1)(e)) ─────────────────
|
||||
# Number of days to keep accepted consent records.
|
||||
# After this period data is automatically deleted by the plugin.
|
||||
# Set to 0 to keep records indefinitely (not recommended for GDPR).
|
||||
retention-days-consent: 1095 # 3 years
|
||||
|
||||
# Number of days to keep decline records.
|
||||
retention-days-declines: 1095 # 3 years
|
||||
# ── Admin settings ───────────────────────────────────────────
|
||||
admin:
|
||||
# Log consent accept/decline events to the server_events table in
|
||||
# the MCLogger database. Requires database.database to point to
|
||||
# the same DB as MCLogger.
|
||||
log-to-mclogger-db: true
|
||||
|
||||
# Broadcast a message to online admins when a player
|
||||
# accepts or declines the policy.
|
||||
notify-admins: false
|
||||
|
||||
# Permission node required to use admin sub-commands:
|
||||
# /consent admin reload
|
||||
# /consent admin list
|
||||
# /consent admin revoke <player>
|
||||
# /consent admin forceaccept <player>
|
||||
admin-permission: "consent.admin"
|
||||
|
||||
# ── Messages ─────────────────────────────────────────────────
|
||||
# All messages support MiniMessage formatting tags:
|
||||
# <red>, <green>, <gold>, <bold>, <italic>, <underlined>
|
||||
# <click:open_url:'URL'>, <hover:show_text:'text'>, etc.
|
||||
# Available placeholders:
|
||||
# {url} – The policy URL (consent.policy-url)
|
||||
# {command} – The consent command (consent.command)
|
||||
# {player} – The player's name
|
||||
# {version} – The current policy version
|
||||
# {seconds} – Remaining grace period seconds (where applicable)
|
||||
messages:
|
||||
# ── Prefix ──────────────────────────────────────────────────
|
||||
# Prepended to every plugin message.
|
||||
prefix: "<gold>[Privacy]</gold> "
|
||||
|
||||
# ── Join prompt ─────────────────────────────────────────────
|
||||
# Sent to a player when they join and consent is required.
|
||||
# This text must inform the player what data is being collected
|
||||
# and for what purpose (Art. 13 GDPR).
|
||||
join-prompt: |
|
||||
<yellow><bold>Privacy Policy Consent Required</bold></yellow>
|
||||
<gray>Before you can play you must read and accept our Privacy Policy.</gray>
|
||||
<dark_gray>We collect: chat messages, commands, login/logout times, IP address
|
||||
(pseudonymized), and player position – for server moderation purposes.</dark_gray>
|
||||
<aqua><click:open_url:'{url}'><hover:show_text:'<gray>Click to open in browser</gray>'>📄 {url}</hover></click></aqua>
|
||||
<green>➜ Type <bold>/{command} accept</bold> to agree and start playing.</green>
|
||||
<red>➜ Type <bold>/{command} decline</bold> to disconnect.</red>
|
||||
|
||||
# ── Accepted ────────────────────────────────────────────────
|
||||
accepted: "<green>✔ Thank you! You have accepted the Privacy Policy (v{version}). Enjoy your stay!</green>"
|
||||
|
||||
# ── Declined (shown just before the player is kicked) ───────
|
||||
declined: "<red>You declined the Privacy Policy. You cannot play on this server without accepting it.</red>"
|
||||
|
||||
# ── Kick message – KICK mode, no prior consent ───────────────
|
||||
# This is the disconnect screen message (plain text only –
|
||||
# some Minecraft versions strip MiniMessage in kick screens).
|
||||
kick-message: |
|
||||
Privacy Policy Consent Required
|
||||
|
||||
You must accept our Privacy Policy to play on this server.
|
||||
Visit: {url}
|
||||
|
||||
Reconnect and type /{command} accept to accept.
|
||||
|
||||
# ── Kick message – player declined ──────────────────────────
|
||||
decline-kick-message: |
|
||||
Privacy Policy Declined
|
||||
|
||||
You have declined our Privacy Policy.
|
||||
You cannot play on this server without accepting it.
|
||||
Contact an admin if you have questions.
|
||||
|
||||
# ── Grace period kick (HOLD mode) ───────────────────────────
|
||||
grace-kick-message: |
|
||||
Consent Timeout
|
||||
|
||||
You did not accept the Privacy Policy within the allowed time.
|
||||
Visit: {url}
|
||||
Reconnect and type /{command} accept to accept.
|
||||
|
||||
# ── Movement blocked (HOLD mode) ────────────────────────────
|
||||
hold-move-blocked: "<red>⛔ Please accept the Privacy Policy first! Type /<bold>{command} accept</bold>.</red>"
|
||||
|
||||
# ── Chat blocked (HOLD mode) ────────────────────────────────
|
||||
hold-chat-blocked: "<red>⛔ You cannot chat until you accept the Privacy Policy. Type /<bold>{command} accept</bold>.</red>"
|
||||
|
||||
# ── Command blocked (HOLD mode) ─────────────────────────────
|
||||
hold-command-blocked: "<red>⛔ Commands are disabled until you accept the Privacy Policy. Type /<bold>{command} accept</bold>.</red>"
|
||||
|
||||
# ── Already accepted ────────────────────────────────────────
|
||||
already-accepted: "<green>✔ You have already accepted the current Privacy Policy (v{version}).</green>"
|
||||
|
||||
# ── No consent currently required ───────────────────────────
|
||||
no-consent-required: "<gray>No consent is currently required on this server.</gray>"
|
||||
|
||||
# ── Grace period warning ─────────────────────────────────────
|
||||
grace-warning: "<yellow>⚠ You have <bold>{seconds}s</bold> left to accept the Privacy Policy or you will be disconnected.</yellow>"
|
||||
|
||||
# ── Admin broadcast: player accepted ────────────────────────
|
||||
admin-notify-accept: "<dark_gray>[Consent] <white>{player}</white> accepted the Privacy Policy (v{version}).</dark_gray>"
|
||||
|
||||
# ── Admin broadcast: player declined ────────────────────────
|
||||
admin-notify-decline: "<dark_gray>[Consent] <white>{player}</white> <red>declined</red> the Privacy Policy (v{version}).</dark_gray>"
|
||||
|
||||
# ── Help header ─────────────────────────────────────────────
|
||||
help-header: "<gold><bold>===== MCConsent Help =====</bold></gold>"
|
||||
|
||||
# ── Remind mode reminder ─────────────────────────────────────
|
||||
remind-message: |
|
||||
<yellow>⚠ Reminder: Please accept our Privacy Policy.</yellow>
|
||||
<aqua><click:open_url:'{url}'>{url}</click></aqua>
|
||||
<green>Type /<bold>{command} accept</bold> to accept.</green>
|
||||
# ── Consent withdrawn (Art. 7(3) GDPR) ───────────────────────
|
||||
# Shown in chat just before the player is kicked after /consent withdraw.
|
||||
withdrawn: "<yellow>Your consent has been withdrawn. You will be disconnected. You may rejoin and re-accept the Privacy Policy at any time.</yellow>"
|
||||
|
||||
# Disconnect screen message shown after /consent withdraw.
|
||||
withdraw-kick-message: |
|
||||
Consent Withdrawn
|
||||
|
||||
You have successfully withdrawn your consent.
|
||||
Your data will be handled according to the Privacy Policy.
|
||||
You may reconnect at any time and accept the Privacy Policy.
|
||||
Visit: {url}
|
||||
28
consent-plugin/src/main/resources/plugin.yml
Normal file
28
consent-plugin/src/main/resources/plugin.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
name: MCConsent
|
||||
version: '1.0.0'
|
||||
main: de.simolzimol.mclogger.consent.ConsentPlugin
|
||||
api-version: '1.17'
|
||||
description: Privacy Policy consent enforcement for Minecraft servers
|
||||
author: SimolZimol
|
||||
|
||||
commands:
|
||||
consent:
|
||||
description: Accept, decline, or check your Privacy Policy consent status
|
||||
usage: /consent <accept|decline|status|help>
|
||||
permission: consent.use
|
||||
aliases:
|
||||
- privacy
|
||||
- pp
|
||||
|
||||
permissions:
|
||||
consent.use:
|
||||
description: Use the /consent command
|
||||
default: true
|
||||
|
||||
consent.bypass:
|
||||
description: Skip consent checks entirely (e.g. for staff / ops)
|
||||
default: op
|
||||
|
||||
consent.admin:
|
||||
description: Access admin sub-commands (reload, list, revoke, forceaccept)
|
||||
default: op
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
BIN
paper-plugin/target/mclogger-paper-1.0.0-shaded.jar
Normal file
BIN
paper-plugin/target/mclogger-paper-1.0.0-shaded.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
BIN
velocity-plugin/target/mclogger-velocity-1.0.0-shaded.jar
Normal file
BIN
velocity-plugin/target/mclogger-velocity-1.0.0-shaded.jar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
104
web/app.py
104
web/app.py
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
14
web/limiter.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
MCLogger – Rate-Limiter Singleton
|
||||
Shared across app.py and all blueprints to avoid circular imports.
|
||||
"""
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
# In-memory storage is fine for single-process / single-worker deployments.
|
||||
# For multi-worker gunicorn, set RATELIMIT_STORAGE_URI=redis://... in ENV.
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri="memory://",
|
||||
default_limits=[],
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import smtplib
|
||||
from html import escape
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
|
||||
from config import Config
|
||||
|
||||
@@ -10,13 +12,92 @@ def build_from_header(from_email: str, from_name: str | None = None) -> str:
|
||||
return from_email
|
||||
|
||||
|
||||
def force_https_url(url: str) -> str:
|
||||
if url.startswith("http://"):
|
||||
return "https://" + url[len("http://"):]
|
||||
return url
|
||||
|
||||
def send_mail(settings: dict, recipient: str, subject: str, text_body: str):
|
||||
|
||||
def build_invite_email(
|
||||
username: str,
|
||||
invite_url: str,
|
||||
expiry_text: str,
|
||||
group_name: str | None = None,
|
||||
role_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
safe_user = escape(username)
|
||||
safe_url = escape(invite_url)
|
||||
safe_expiry = escape(expiry_text)
|
||||
safe_group = escape(group_name) if group_name else None
|
||||
safe_role = escape(role_name) if role_name else None
|
||||
|
||||
if safe_group:
|
||||
role_part = f" as <strong>{safe_role}</strong>" if safe_role else ""
|
||||
intro_html = (
|
||||
f"You have been invited to join the group <strong>{safe_group}</strong> "
|
||||
f"on MCLogger{role_part}."
|
||||
)
|
||||
role_text = f" as {role_name}" if role_name else ""
|
||||
intro_text = f"You have been invited to join the group '{group_name}' on MCLogger{role_text}."
|
||||
else:
|
||||
intro_html = "You have been invited to create an account on <strong>MCLogger</strong>."
|
||||
intro_text = "You have been invited to create an account on MCLogger."
|
||||
|
||||
text_body = (
|
||||
f"Hello {username},\n\n"
|
||||
f"{intro_text}\n"
|
||||
f"Open this link to create your account:\n\n{invite_url}\n\n"
|
||||
f"This invite expires {expiry_text}.\n"
|
||||
)
|
||||
|
||||
html_body = f"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f6f8fb;font-family:Arial,Helvetica,sans-serif;color:#1f2937;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="padding:24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#111827;color:#ffffff;padding:16px 20px;font-size:18px;font-weight:700;">
|
||||
MCLogger Invitation
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 20px 20px 20px;font-size:14px;line-height:1.6;">
|
||||
<p style="margin:0 0 12px 0;">Hello <strong>{safe_user}</strong>,</p>
|
||||
<p style="margin:0 0 12px 0;">{intro_html}</p>
|
||||
<p style="margin:0 0 20px 0;">Click the button below to create your account:</p>
|
||||
<p style="margin:0 0 20px 0;">
|
||||
<a href="{safe_url}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:700;padding:11px 16px;border-radius:8px;">Create Account</a>
|
||||
</p>
|
||||
<p style="margin:0 0 12px 0;">This invite expires <strong>{safe_expiry}</strong>.</p>
|
||||
<p style="margin:16px 0 0 0;font-size:12px;color:#6b7280;word-break:break-all;">If the button does not work, use this link:<br>{safe_url}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
|
||||
return text_body, html_body
|
||||
|
||||
|
||||
|
||||
def send_mail(settings: dict, recipient: str, subject: str, text_body: str, html_body: str | None = None):
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = build_from_header(settings["from_email"], settings.get("from_name"))
|
||||
msg["To"] = recipient
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
sender_domain = (settings.get("from_email", "noreply@example.com").split("@")[-1] or "example.com")
|
||||
msg["Message-ID"] = make_msgid(domain=sender_domain)
|
||||
msg.set_content(text_body)
|
||||
if html_body:
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
|
||||
with smtplib.SMTP(settings["host"], settings["port"], timeout=Config.MAIL_TIMEOUT) as smtp:
|
||||
smtp.ehlo()
|
||||
|
||||
407
web/panel_db.py
407
web/panel_db.py
@@ -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
|
||||
|
||||
|
||||
@@ -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
32
web/roles.py
Normal 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")
|
||||
@@ -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
31
web/templates/429.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Too Many Requests — MCLogger</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0d1117; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="text-center p-4" style="max-width:420px">
|
||||
<i class="bi bi-shield-exclamation text-warning" style="font-size:3rem"></i>
|
||||
<h2 class="fw-bold mt-3">Too Many Requests</h2>
|
||||
<p class="text-muted">You have submitted this form too frequently. Please wait
|
||||
{% if retry_after %}
|
||||
<strong>{{ retry_after }} second{{ 's' if retry_after != 1 }}</strong>
|
||||
{% else %}
|
||||
a moment
|
||||
{% endif %}
|
||||
before trying again.
|
||||
</p>
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary mt-2">
|
||||
<i class="bi bi-arrow-left me-1"></i>Go back
|
||||
</a>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
196
web/templates/admin/audit_log.html
Normal file
196
web/templates/admin/audit_log.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}Audit Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h4>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted small">{{ total }} event{{ 's' if total != 1 }}</span>
|
||||
{% if retention_days > 0 %}
|
||||
<span class="badge bg-secondary" title="Entries older than {{ retention_days }} days are deleted automatically on startup">
|
||||
<i class="bi bi-clock-history me-1"></i>Retention: {{ retention_days }}d
|
||||
</span>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('site_admin.audit_purge') }}"
|
||||
onsubmit="return confirm('Delete all audit entries older than {{ retention_days }} days?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
{% if retention_days <= 0 %}disabled title="Retention disabled (AUDIT_LOG_RETENTION_DAYS=0)"{% endif %}>
|
||||
<i class="bi bi-trash3 me-1"></i>Purge now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" action="{{ url_for('site_admin.audit_log') }}" class="card border-secondary mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm mb-1">Action</label>
|
||||
<select name="action" class="form-select form-select-sm bg-dark text-white border-secondary">
|
||||
<option value="">— All actions —</option>
|
||||
{% for a in actions %}
|
||||
<option value="{{ a }}" {{ 'selected' if action_filter == a }}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm mb-1">Group</label>
|
||||
<select name="group_id" class="form-select form-select-sm bg-dark text-white border-secondary">
|
||||
<option value="">— All groups —</option>
|
||||
{% for g in all_groups %}
|
||||
<option value="{{ g.id }}" {{ 'selected' if group_filter == g.id }}>{{ g.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm mb-1">Actor</label>
|
||||
<input type="text" name="actor" class="form-control form-control-sm bg-dark text-white border-secondary"
|
||||
placeholder="Username…" value="{{ actor_filter }}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">
|
||||
<i class="bi bi-funnel-fill me-1"></i>Filter
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.audit_log') }}" class="btn btn-sm btn-outline-secondary w-100">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border-secondary">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-secondary text-dark">
|
||||
<tr>
|
||||
<th style="width:155px">Timestamp (UTC)</th>
|
||||
<th style="width:130px">Actor</th>
|
||||
<th style="width:180px">Action</th>
|
||||
<th style="width:90px">Entity</th>
|
||||
<th style="width:80px">Entity ID</th>
|
||||
<th style="width:120px">Group</th>
|
||||
<th>Details</th>
|
||||
<th style="width:110px">IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ row.created_at | fmt_dt }}</td>
|
||||
<td>
|
||||
{% if row.actor_username %}
|
||||
<span class="text-info">{{ row.actor_username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set action_class = {
|
||||
'user.login': 'badge bg-success',
|
||||
'user.login_failed': 'badge bg-danger',
|
||||
'user.password_changed': 'badge bg-warning text-dark',
|
||||
'session.logout': 'badge bg-secondary',
|
||||
'admin.login': 'badge bg-warning text-dark',
|
||||
'admin.login_failed': 'badge bg-danger',
|
||||
'admin.view_users': 'badge bg-dark border border-secondary',
|
||||
'admin.view_user': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group_members': 'badge bg-dark border border-secondary',
|
||||
'admin.view_audit_log': 'badge bg-dark border border-secondary',
|
||||
'invite.created': 'badge bg-primary',
|
||||
'invite.accepted': 'badge bg-success',
|
||||
'invite.revoked': 'badge bg-secondary',
|
||||
'invite.resent': 'badge bg-info text-dark',
|
||||
'member.added': 'badge bg-primary',
|
||||
'member.removed': 'badge bg-danger',
|
||||
'member.role_changed': 'badge bg-warning text-dark',
|
||||
'member.updated': 'badge bg-warning text-dark',
|
||||
'group.created': 'badge bg-success',
|
||||
'group.updated': 'badge bg-secondary',
|
||||
'group.deleted': 'badge bg-danger',
|
||||
'db.credentials_changed': 'badge bg-warning text-dark',
|
||||
'db.credentials_deleted': 'badge bg-danger',
|
||||
'user.updated': 'badge bg-secondary',
|
||||
'user.deleted': 'badge bg-danger',
|
||||
'mail.settings_saved': 'badge bg-info text-dark',
|
||||
'mail.settings_deleted': 'badge bg-danger',
|
||||
'consent.given': 'badge bg-success',
|
||||
'consent.declined': 'badge bg-warning text-dark',
|
||||
'audit.purged': 'badge bg-danger',
|
||||
'panel.view_players': 'badge bg-dark border border-info',
|
||||
'panel.view_player': 'badge bg-info text-dark',
|
||||
'panel.view_sessions': 'badge bg-dark border border-info',
|
||||
'panel.view_chat': 'badge bg-dark border border-info',
|
||||
'panel.view_commands': 'badge bg-dark border border-info',
|
||||
'panel.view_deaths': 'badge bg-dark border border-info',
|
||||
'panel.view_blocks': 'badge bg-dark border border-info',
|
||||
'panel.view_proxy': 'badge bg-dark border border-info',
|
||||
'panel.view_server_events': 'badge bg-dark border border-info',
|
||||
'panel.view_perms': 'badge bg-dark border border-info',
|
||||
'player.data_exported': 'badge bg-info text-dark',
|
||||
'player.data_deleted': 'badge bg-danger',
|
||||
} %}
|
||||
<span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">
|
||||
{{ row.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ row.entity_type or '—' }}</td>
|
||||
<td class="text-muted small font-monospace">{{ row.entity_id or '—' }}</td>
|
||||
<td class="small">
|
||||
{% if row.group_name %}
|
||||
<span class="badge bg-dark border border-secondary">{{ row.group_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted font-monospace">
|
||||
{% if row.details %}
|
||||
{% set d = row.details if row.details is mapping else {} %}
|
||||
{% for k, v in d.items() %}
|
||||
<span class="me-2"><strong>{{ k }}:</strong> {{ v }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small font-monospace">{{ row.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted py-4">
|
||||
<i class="bi bi-journal-x me-2"></i>No audit events found.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li class="page-item {{ 'disabled' if page <= 1 }}">
|
||||
<a class="page-link" href="{{ url_for('site_admin.audit_log', page=page-1, action=action_filter, group_id=group_filter, actor=actor_filter) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% for p in range([1, page-2]|max, [total_pages+1, page+3]|min) %}
|
||||
<li class="page-item {{ 'active' if p == page }}">
|
||||
<a class="page-link" href="{{ url_for('site_admin.audit_log', page=p, action=action_filter, group_id=group_filter, actor=actor_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="page-item {{ 'disabled' if page >= total_pages }}">
|
||||
<a class="page-link" href="{{ url_for('site_admin.audit_log', page=page+1, action=action_filter, group_id=group_filter, actor=actor_filter) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="{{ url_for('site_admin.groups') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.groups' }}">Groups</a>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.users' }}">Users</a>
|
||||
<a href="{{ url_for('site_admin.mail_settings') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.mail_settings' }}">Mail</a>
|
||||
<a href="{{ url_for('site_admin.audit_log') }}" class="nav-link text-white {{ 'fw-bold' if request.endpoint == 'site_admin.audit_log' }}">Audit Log</a>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="d-inline">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm">
|
||||
@@ -40,6 +41,12 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -129,4 +129,120 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Recent Audit Activity ──────────────────────────────── -->
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-12">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-journal-text me-2"></i>Recent Audit Activity</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if retention_days > 0 %}
|
||||
<span class="badge bg-secondary small">
|
||||
<i class="bi bi-clock-history me-1"></i>Retention: {{ retention_days }}d
|
||||
</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('site_admin.audit_log') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-right-circle me-1"></i>Full log
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-dark table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-secondary text-dark">
|
||||
<tr>
|
||||
<th style="width:155px">Timestamp (UTC)</th>
|
||||
<th style="width:130px">Actor</th>
|
||||
<th style="width:180px">Action</th>
|
||||
<th style="width:120px">Group</th>
|
||||
<th>Details</th>
|
||||
<th style="width:110px">IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in recent_audit %}
|
||||
{% set action_class = {
|
||||
'user.login': 'badge bg-success',
|
||||
'user.login_failed': 'badge bg-danger',
|
||||
'user.password_changed': 'badge bg-warning text-dark',
|
||||
'session.logout': 'badge bg-secondary',
|
||||
'admin.login': 'badge bg-warning text-dark',
|
||||
'admin.login_failed': 'badge bg-danger',
|
||||
'admin.view_users': 'badge bg-dark border border-secondary',
|
||||
'admin.view_user': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group': 'badge bg-dark border border-secondary',
|
||||
'admin.view_group_members': 'badge bg-dark border border-secondary',
|
||||
'admin.view_audit_log': 'badge bg-dark border border-secondary',
|
||||
'invite.created': 'badge bg-primary',
|
||||
'invite.accepted': 'badge bg-success',
|
||||
'invite.revoked': 'badge bg-secondary',
|
||||
'invite.resent': 'badge bg-info text-dark',
|
||||
'member.added': 'badge bg-primary',
|
||||
'member.removed': 'badge bg-danger',
|
||||
'member.role_changed': 'badge bg-warning text-dark',
|
||||
'group.created': 'badge bg-success',
|
||||
'group.updated': 'badge bg-secondary',
|
||||
'group.deleted': 'badge bg-danger',
|
||||
'db.credentials_changed': 'badge bg-warning text-dark',
|
||||
'db.credentials_deleted': 'badge bg-danger',
|
||||
'user.updated': 'badge bg-secondary',
|
||||
'user.deleted': 'badge bg-danger',
|
||||
'mail.settings_saved': 'badge bg-info text-dark',
|
||||
'mail.settings_deleted': 'badge bg-danger',
|
||||
'consent.given': 'badge bg-success',
|
||||
'consent.declined': 'badge bg-warning text-dark',
|
||||
'audit.purged': 'badge bg-danger', 'panel.view_players': 'badge bg-dark border border-info',
|
||||
'panel.view_player': 'badge bg-info text-dark',
|
||||
'panel.view_sessions': 'badge bg-dark border border-info',
|
||||
'panel.view_chat': 'badge bg-dark border border-info',
|
||||
'panel.view_commands': 'badge bg-dark border border-info',
|
||||
'panel.view_deaths': 'badge bg-dark border border-info',
|
||||
'panel.view_blocks': 'badge bg-dark border border-info',
|
||||
'panel.view_proxy': 'badge bg-dark border border-info',
|
||||
'panel.view_server_events': 'badge bg-dark border border-info',
|
||||
'panel.view_perms': 'badge bg-dark border border-info', } %}
|
||||
<tr>
|
||||
<td class="text-muted small">{{ row.created_at | fmt_dt }}</td>
|
||||
<td>
|
||||
{% if row.actor_username %}
|
||||
<span class="text-info">{{ row.actor_username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">
|
||||
{{ row.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">
|
||||
{% if row.group_name %}
|
||||
<span class="badge bg-dark border border-secondary">{{ row.group_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted font-monospace">
|
||||
{% if row.details %}
|
||||
{% set d = row.details if row.details is mapping else {} %}
|
||||
{% for k, v in d.items() %}
|
||||
<span class="me-2"><strong>{{ k }}:</strong> {{ v }}</span>
|
||||
{% endfor %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small font-monospace">{{ row.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-3">
|
||||
<i class="bi bi-journal-x me-2"></i>No audit events yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% block title %}{{ 'Edit User' if user else 'New User' }}{% endblock %}
|
||||
{% block title %}{{ 'Edit User' if user else 'Invite New User' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
<h2 class="mb-0">{{ 'Edit User: ' ~ user.username if user else 'New User' }}</h2>
|
||||
<h2 class="mb-0">{{ 'Edit User: ' ~ user.username if user else 'Invite New User' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-7">
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
@@ -24,14 +24,42 @@
|
||||
<input type="email" name="email" class="form-control" required
|
||||
value="{{ user.email if user else request.form.get('email', '') }}">
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ 'New Password (leave blank = unchanged)' if user else 'Password *' }}</label>
|
||||
<input type="password" name="{{ 'new_password' if user else 'password' }}" class="form-control"
|
||||
{{ '' if user else 'required' }}>
|
||||
{% if not user %}
|
||||
<div class="form-text">Minimum 8 characters recommended.</div>
|
||||
{% endif %}
|
||||
<label class="form-label">New Password <span class="text-muted">(leave blank = unchanged)</span></label>
|
||||
<input type="password" name="new_password" class="form-control">
|
||||
</div>
|
||||
{% else %}
|
||||
{# ── Invite form: group + role (optional) ── #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Group <span class="text-muted">(optional)</span></label>
|
||||
<select name="group_id" id="invite_group" class="form-select" onchange="toggleRoleField()">
|
||||
<option value="">— No group —</option>
|
||||
{% for g in (groups or []) %}
|
||||
<option value="{{ g.id }}"
|
||||
{% if request.form.get('group_id')|string == g.id|string %}selected{% endif %}>
|
||||
{{ g.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">If selected, the user will be added to this group upon accepting the invite.</div>
|
||||
</div>
|
||||
<div class="mb-3" id="role_field" style="display:none;">
|
||||
<label class="form-label">Group Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="auditor">Auditor</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="group_admin">Group Admin</option>
|
||||
<option value="group_owner">Group Owner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-info py-2 mb-3">
|
||||
<i class="bi bi-envelope-check me-1"></i>
|
||||
The user will receive an email with a link to set their own password.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" name="is_site_admin" id="is_site_admin" class="form-check-input"
|
||||
@@ -44,7 +72,11 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>{{ 'Save' if user else 'Create' }}
|
||||
{% if user %}
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
{% else %}
|
||||
<i class="bi bi-envelope-fill me-1"></i>Send Invitation
|
||||
{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('site_admin.users') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
@@ -53,4 +85,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not user %}
|
||||
<script>
|
||||
function toggleRoleField() {
|
||||
var gid = document.getElementById('invite_group').value;
|
||||
document.getElementById('role_field').style.display = gid ? 'block' : 'none';
|
||||
}
|
||||
// Show role field if a group was pre-selected (e.g. after validation error)
|
||||
document.addEventListener('DOMContentLoaded', toggleRoleField);
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,11 +4,77 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-people-fill me-2"></i>Users</h2>
|
||||
<a href="{{ url_for('site_admin.user_new') }}" class="btn btn-success">
|
||||
<i class="bi bi-person-plus-fill me-1"></i>New User
|
||||
<i class="bi bi-envelope-plus-fill me-1"></i>Invite User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ── Pending Invitations ── #}
|
||||
{% if pending_invites %}
|
||||
<h5 class="text-muted mb-2"><i class="bi bi-envelope-open me-1"></i>Pending Invitations</h5>
|
||||
<div class="card border-warning mb-4">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0 small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Group</th>
|
||||
<th>Role</th>
|
||||
<th>Expires</th>
|
||||
<th>Sent</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inv in pending_invites %}
|
||||
<tr>
|
||||
<td class="fw-semibold">{{ inv.invited_username }}</td>
|
||||
<td class="text-muted">{{ inv.invited_email }}</td>
|
||||
<td>
|
||||
{% if inv.group_name %}
|
||||
<span class="badge bg-secondary">{{ inv.group_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ inv.role }}</td>
|
||||
<td class="text-muted">{{ inv.expires_at | fmt_dt }}</td>
|
||||
<td class="text-muted">{{ inv.send_count }}×</td>
|
||||
<td class="text-end">
|
||||
{# Copy link #}
|
||||
{% set invite_url = url_for('auth.accept_invite', token=inv.token, _external=True) %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
title="Copy invite link"
|
||||
onclick="navigator.clipboard.writeText('{{ invite_url }}').then(()=>this.title='Copied!')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
{# Resend #}
|
||||
<form method="post" action="{{ url_for('site_admin.user_invite_resend', invite_id=inv.id) }}" class="d-inline">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-info" title="Resend email">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</form>
|
||||
{# Revoke #}
|
||||
<form method="post" action="{{ url_for('site_admin.user_invite_revoke', invite_id=inv.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Revoke invitation for {{ inv.invited_username }}?')">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Revoke">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card border-secondary">
|
||||
<h5 class="text-muted mb-2"><i class="bi bi-people me-1"></i>Registered Users</h5>
|
||||
<div class="card border-secondary">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to regular login
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted small">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
84
web/templates/auth/consent.html
Normal file
84
web/templates/auth/consent.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCLogger – Privacy Consent</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { background: #0d1117; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.consent-card { width: 100%; max-width: 600px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="consent-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-check fs-1 text-warning"></i>
|
||||
<h3 class="fw-bold mt-2">Privacy Policy Consent</h3>
|
||||
<p class="text-muted small">Version {{ policy_version }}</p>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card border-warning mb-4">
|
||||
<div class="card-header bg-transparent border-warning text-warning fw-semibold">
|
||||
<i class="bi bi-file-earmark-text me-2"></i>What data we process
|
||||
</div>
|
||||
<div class="card-body small text-secondary">
|
||||
<p>To operate MCLogger we process the following personal data:</p>
|
||||
<ul class="mb-2">
|
||||
<li><strong>Account data</strong> — username, e-mail address, hashed password (no plain-text storage)</li>
|
||||
<li><strong>Session & security data</strong> — login timestamps, IP addresses (stored for up to 90 days in the audit log)</li>
|
||||
<li><strong>Minecraft server data</strong> — player names, UUIDs, chat messages, commands & block interactions logged by the Minecraft plugin</li>
|
||||
<li><strong>Audit events</strong> — records of actions you perform in the panel (logins, member changes, configuration edits)</li>
|
||||
</ul>
|
||||
<p class="mb-0">
|
||||
<strong>Legal basis:</strong> Art. 6 (1)(b) GDPR — performance of a contract / provision of the service.<br>
|
||||
<strong>Retention:</strong> Audit log entries containing IP addresses are automatically deleted after 90 days.
|
||||
Account data is retained for as long as your account exists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-body small text-secondary">
|
||||
<p class="mb-1">
|
||||
<strong>Your rights (GDPR Art. 15–21):</strong> You may request access to, rectification or deletion of your
|
||||
personal data, as well as data portability, at any time by contacting
|
||||
<a href="mailto:simon@devanturas.net" class="text-warning">simon@devanturas.net</a>.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Read the full <a href="{{ url_for('privacy_policy') }}" target="_blank" class="text-warning">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<button type="submit" name="action" value="accept" class="btn btn-warning w-100 fw-semibold">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>I accept the Privacy Policy
|
||||
</button>
|
||||
<button type="submit" name="action" value="decline"
|
||||
class="btn btn-outline-secondary w-100"
|
||||
onclick="return confirm('Declining will log you out. Are you sure?')">
|
||||
<i class="bi bi-x-circle me-1"></i>Decline & Logout
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted text-center mt-3 small">
|
||||
By accepting you confirm that you have read and understood the Privacy Policy
|
||||
(version {{ policy_version }}).
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,6 +58,9 @@
|
||||
<i class="bi bi-shield-fill me-1"></i>Site Admin Login
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted small">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<a href="{{ url_for('group_admin.dashboard') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.dashboard' }}">Dashboard</a>
|
||||
<a href="{{ url_for('group_admin.members') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.members' }}">Members</a>
|
||||
<a href="{{ url_for('group_admin.database') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.database' }}">Database</a>
|
||||
<a href="{{ url_for('group_admin.privacy_policy') }}" class="nav-link text-dark {{ 'fw-bold' if request.endpoint == 'group_admin.privacy_policy' }}">Privacy Policy</a>
|
||||
<a href="{{ url_for('panel.dashboard') }}" class="btn btn-outline-dark btn-sm">
|
||||
<i class="bi bi-grid me-1"></i>Panel
|
||||
</a>
|
||||
@@ -42,6 +43,12 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="text-center py-3 mt-4 border-top border-secondary">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('privacy_policy') }}" class="text-muted text-decoration-none">Privacy Policy</a>
|
||||
— MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer">Devanturas</a>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
66
web/templates/group_admin/player_delete_confirm.html
Normal file
66
web/templates/group_admin/player_delete_confirm.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Delete Player Data{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-7 col-lg-6">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger bg-opacity-75 fw-bold">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>Permanently Delete Player Data
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-3">
|
||||
You are about to <strong>permanently delete all logged data</strong> for:
|
||||
</p>
|
||||
|
||||
<div class="alert alert-secondary d-flex align-items-center gap-3 py-2">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/48"
|
||||
class="rounded" alt="{{ player.username }}" width="48" height="48"
|
||||
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'48\' height=\'48\' viewBox=\'0 0 48 48\'%3E%3Crect width=\'48\' height=\'48\' rx=\'6\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'22\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
|
||||
<div>
|
||||
<div class="fw-bold">{{ player.username }}</div>
|
||||
<div class="text-muted small font-monospace">{{ player.uuid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-danger fw-semibold mt-3 mb-1">
|
||||
<i class="bi bi-exclamation-circle-fill me-1"></i>This action cannot be undone.
|
||||
</p>
|
||||
<ul class="text-muted small mb-4">
|
||||
<li>Sessions, chat, commands, deaths, teleports</li>
|
||||
<li>Block events, proxy events, inventory events</li>
|
||||
<li>Player stats, entity interactions</li>
|
||||
<li>The player's base record (UUID, username, IP, playtime)</li>
|
||||
</ul>
|
||||
|
||||
<form method="post" action="{{ url_for('group_admin.player_delete', uuid=player.uuid) }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="confirm_name" class="form-label fw-semibold">
|
||||
Type <span class="text-danger font-monospace">{{ player.username }}</span> to confirm:
|
||||
</label>
|
||||
<input type="text" id="confirm_name" name="confirm_name"
|
||||
class="form-control bg-dark text-white border-danger"
|
||||
placeholder="{{ player.username }}" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash3-fill me-1"></i>Delete All Data
|
||||
</button>
|
||||
<a href="{{ url_for('panel.player_detail', uuid=player.uuid) }}"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This deletion is logged in the audit log as required by Art. 5(2) GDPR (accountability).
|
||||
The export function (Art. 20 GDPR) is available on the player detail page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
81
web/templates/group_admin/privacy_policy.html
Normal file
81
web/templates/group_admin/privacy_policy.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "group_admin/base.html" %}
|
||||
{% block title %}Privacy Policy{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-9">
|
||||
|
||||
<h2 class="mb-1"><i class="bi bi-file-earmark-lock2 me-2"></i>Server Privacy Policy</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Write your Minecraft server's privacy policy here. Players will be shown this page when
|
||||
the <strong>MCConsent</strong> plugin asks them to consent before playing.
|
||||
</p>
|
||||
|
||||
{# ── Public URL banner ────────────────────────────────────── #}
|
||||
{% if public_url %}
|
||||
<div class="alert alert-info d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-link-45deg fs-5 flex-shrink-0"></i>
|
||||
<div>
|
||||
<strong>Public URL</strong> — paste this into your <code>consent-plugin/config.yml</code>
|
||||
as the <code>policy-url</code> value:<br>
|
||||
<code id="publicUrl">{{ public_url }}</code>
|
||||
<button class="btn btn-sm btn-outline-light ms-2" onclick="navigator.clipboard.writeText('{{ public_url }}')">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-secondary mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Save the policy once to generate a secret public URL.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Last updated ─────────────────────────────────────────── #}
|
||||
{% if policy and policy.updated_at %}
|
||||
<p class="text-muted small">Last updated: {{ policy.updated_at.strftime('%Y-%m-%d %H:%M UTC') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# ── Editor form ──────────────────────────────────────────── #}
|
||||
<form method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="policy_url" class="form-label fw-semibold">
|
||||
Additional / External Policy URL <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="url" class="form-control font-monospace"
|
||||
id="policy_url" name="policy_url" maxlength="500"
|
||||
placeholder="https://your-website.example.com/privacy"
|
||||
value="{{ (policy.policy_url or '') if policy else '' }}">
|
||||
<div class="form-text">
|
||||
If you host a full policy on your own website you can link it here. It will be
|
||||
displayed as a button on the public policy page.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="policy_text" class="form-label fw-semibold">Policy Text</label>
|
||||
<textarea class="form-control font-monospace" id="policy_text" name="policy_text"
|
||||
rows="24" placeholder="Enter your privacy policy here…"
|
||||
style="resize: vertical;">{{ (policy.policy_text or '') if policy else '' }}</textarea>
|
||||
<div class="form-text">
|
||||
Plain text or basic Markdown is accepted. HTML is <strong>not</strong> rendered.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-floppy me-1"></i>Save Policy
|
||||
</button>
|
||||
{% if policy %}
|
||||
<a href="{{ public_url }}" target="_blank" class="btn btn-outline-light">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Preview public page
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
64
web/templates/group_policy.html
Normal file
64
web/templates/group_policy.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy — {{ group.name }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body class="bg-dark text-light">
|
||||
|
||||
<nav class="navbar navbar-dark bg-secondary bg-opacity-25 border-bottom border-secondary mb-4">
|
||||
<div class="container">
|
||||
<span class="navbar-brand">
|
||||
<i class="bi bi-file-earmark-lock2 me-2 text-warning"></i>
|
||||
<strong>{{ group.name }}</strong> — Privacy Policy
|
||||
</span>
|
||||
<span class="text-muted small">Powered by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">MCLogger</a> — Made by Devanturas</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container" style="max-width: 860px;">
|
||||
|
||||
{% if not policy or not policy.policy_text %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
This server has not yet published a privacy policy.
|
||||
Please contact the server administrator for more information.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{% if policy.updated_at %}
|
||||
<p class="text-muted small mb-4">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
Last updated: {{ policy.updated_at.strftime('%B %d, %Y') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if policy.policy_url %}
|
||||
<a href="{{ policy.policy_url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-outline-info btn-sm mb-4">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>View full policy on our website
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="card bg-secondary bg-opacity-10 border-secondary">
|
||||
<div class="card-body">
|
||||
<pre class="mb-0 text-light" style="white-space: pre-wrap; word-break: break-word; font-family: inherit;">{{ policy.policy_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<hr class="border-secondary mt-5">
|
||||
<p class="text-muted small text-center mb-4">
|
||||
This page is hosted by <a href="https://log.devanturas.net" class="text-secondary" target="_blank" rel="noopener noreferrer">MCLogger</a>
|
||||
on behalf of <em>{{ group.name }}</em>.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,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>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
class="rounded mb-3" alt="{{ player.username }}"
|
||||
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'80\' height=\'80\' viewBox=\'0 0 80 80\'%3E%3Crect width=\'80\' height=\'80\' rx=\'8\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'36\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
@@ -139,4 +140,30 @@
|
||||
<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Overview
|
||||
</a>
|
||||
|
||||
{% if is_admin and not session.get('is_site_admin') %}
|
||||
<div class="card border-warning mt-4">
|
||||
<div class="card-header bg-warning bg-opacity-10 text-warning fw-semibold">
|
||||
<i class="bi bi-shield-lock me-2"></i>GDPR Actions
|
||||
</div>
|
||||
<div class="card-body d-flex flex-wrap gap-3 align-items-center">
|
||||
<div>
|
||||
<a href="{{ url_for('group_admin.player_export', uuid=player.uuid) }}"
|
||||
class="btn btn-outline-info">
|
||||
<i class="bi bi-download me-1"></i>Export Data (Art. 20 GDPR)
|
||||
</a>
|
||||
<div class="form-text text-muted mt-1">Download all logged data as ZIP (group admins & owners)</div>
|
||||
</div>
|
||||
{% if session.get('role') == 'group_owner' %}
|
||||
<div>
|
||||
<a href="{{ url_for('group_admin.player_delete', uuid=player.uuid) }}"
|
||||
class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash3 me-1"></i>Delete All Data (Art. 17 GDPR)
|
||||
</a>
|
||||
<div class="form-text text-danger mt-1">Permanently erase all player data (owner only)</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center py-4">
|
||||
<img src="https://minotar.net/avatar/{{ player.username }}/80"
|
||||
class="rounded mb-3" alt="{{ player.username }}" onerror="this.src='/static/img/default.png'">
|
||||
class="rounded mb-3" alt="{{ player.username }}"
|
||||
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'80\' height=\'80\' viewBox=\'0 0 80 80\'%3E%3Crect width=\'80\' height=\'80\' rx=\'8\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'36\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
|
||||
<h5 class="fw-bold mb-1">{{ player.username }}</h5>
|
||||
{% if player.is_op %}
|
||||
<span class="badge bg-warning text-dark mb-2"><i class="bi bi-shield-fill"></i> OP</span>
|
||||
|
||||
246
web/templates/privacy_policy.html
Normal file
246
web/templates/privacy_policy.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy Policy — MCLogger</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { background: #0d1117; }
|
||||
.policy-card { max-width: 820px; margin: 0 auto; }
|
||||
h2 { font-size: 1.15rem; margin-top: 2rem; }
|
||||
h3 { font-size: 1rem; margin-top: 1.25rem; }
|
||||
p, li { color: #c9d1d9; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="py-5 px-3">
|
||||
<div class="policy-card">
|
||||
<div class="text-center mb-5">
|
||||
<i class="bi bi-database-fill-gear fs-1 text-success"></i>
|
||||
<h1 class="fw-bold mt-2 h3">MCLogger — Privacy Policy</h1>
|
||||
<p class="text-muted small">Last updated: {{ last_updated }}</p>
|
||||
<p class="text-muted small">
|
||||
<i class="bi bi-fingerprint me-1"></i>Document ID:
|
||||
<span class="font-monospace fw-bold text-secondary">{{ policy_version }}</span>
|
||||
<span class="text-muted"> — this ID changes automatically when the policy content changes.
|
||||
Your consent is tied to this ID.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card border-secondary mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">1. Controller & Contact</h2>
|
||||
<p>
|
||||
The controller responsible for data processing within this service is:
|
||||
</p>
|
||||
<p>
|
||||
<strong>Simon</strong><br>
|
||||
E-Mail: <a href="mailto:simon@devanturas.net">simon@devanturas.net</a>
|
||||
</p>
|
||||
<p>
|
||||
For any questions, requests, or concerns regarding your personal data, please contact the
|
||||
address above.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">2. What Is MCLogger?</h2>
|
||||
<p>
|
||||
MCLogger is a self-hosted logging and analytics panel for Minecraft server operators.
|
||||
It collects and displays in-game activity data (sessions, chat, commands, deaths, block
|
||||
events, proxy events) and provides a multi-tenant web interface for authorised server
|
||||
administrators and group members.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">3. Data We Collect</h2>
|
||||
|
||||
<h3 class="fw-semibold">3.1 Minecraft Player Data</h3>
|
||||
<p>When players connect to a Minecraft server that uses the MCLogger plugin, the
|
||||
following data is automatically recorded:</p>
|
||||
<ul>
|
||||
<li><strong>Player identity:</strong> Minecraft username, UUID</li>
|
||||
<li><strong>Sessions:</strong> join time, leave time, session duration, server name</li>
|
||||
<li><strong>IP addresses:</strong> the IP address used at connection time</li>
|
||||
<li><strong>Chat messages:</strong> message content, timestamp, username</li>
|
||||
<li><strong>Commands:</strong> command text, timestamp, username</li>
|
||||
<li><strong>Deaths:</strong> death message/cause, timestamp, location, username</li>
|
||||
<li><strong>Block events:</strong> block type, action (place/break), coordinates, username, timestamp</li>
|
||||
<li><strong>Proxy events:</strong> connect/disconnect events on the proxy network, timestamp</li>
|
||||
</ul>
|
||||
<p>
|
||||
This data is stored in a MariaDB database operated by the server operator. Players should
|
||||
be informed about this logging by the Minecraft server's own rules or MOTD.
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.2 Panel User Accounts</h3>
|
||||
<p>When a user account is created for the web panel, the following data is stored:</p>
|
||||
<ul>
|
||||
<li><strong>Username</strong></li>
|
||||
<li><strong>Password</strong> (stored as a salted PBKDF2-HMAC-SHA256 hash; the plain-text password is never stored)</li>
|
||||
<li><strong>E-mail address</strong> (when provided via the invite flow)</li>
|
||||
<li><strong>Group membership and role</strong></li>
|
||||
<li><strong>Account creation date</strong></li>
|
||||
</ul>
|
||||
|
||||
<h3 class="fw-semibold">3.3 Invite Tokens</h3>
|
||||
<p>
|
||||
When a group administrator invites a user by e-mail, a time-limited invite token is
|
||||
generated and stored together with the recipient's e-mail address. The token expires
|
||||
after {{ invite_expiry_hours }} hours. Accepted and revoked tokens are retained in the database for
|
||||
audit purposes.
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.4 Session Data</h3>
|
||||
<p>
|
||||
MCLogger uses server-side sessions (Flask session cookie) to keep you logged in.
|
||||
The session cookie is HTTP-only, SameSite-protected, and expires when your browser
|
||||
session ends.
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.5 Panel Audit Log</h3>
|
||||
<p>
|
||||
MCLogger maintains an <strong>internal audit log</strong> in the panel database that records
|
||||
security-relevant and data-access events. Each entry contains:
|
||||
</p>
|
||||
<ul>
|
||||
<li>The panel user who performed the action (username and internal ID)</li>
|
||||
<li>The action taken (e.g. login, logout, member role change, viewing player data)</li>
|
||||
<li>The affected entity (e.g. a Minecraft player's UUID when player profile pages are accessed)</li>
|
||||
<li>The IP address of the panel user at the time of the action</li>
|
||||
<li>A UTC timestamp</li>
|
||||
</ul>
|
||||
<p>
|
||||
This includes access to pages that display Minecraft player data (player list, player detail,
|
||||
chat history, commands, deaths, block events, sessions, proxy events). The log therefore
|
||||
records <em>who</em> in the panel team accessed <em>which</em> player's data and <em>when</em>,
|
||||
providing an accountable audit trail as required by Art. 32 GDPR.
|
||||
Audit log entries are automatically deleted after {{ audit_retention_days }} days
|
||||
(configurable by the operator).
|
||||
</p>
|
||||
|
||||
<h3 class="fw-semibold">3.6 Server Log Files</h3>
|
||||
<p>
|
||||
The web server (gunicorn) may write standard HTTP access logs containing IP
|
||||
addresses, request paths, and timestamps. These logs are used for operational
|
||||
security monitoring and are not shared with third parties.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">4. Purpose & Legal Basis of Processing</h2>
|
||||
<table class="table table-sm table-dark table-bordered">
|
||||
<thead class="table-secondary">
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Purpose</th>
|
||||
<th>Legal Basis (GDPR)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Minecraft player activity data</td>
|
||||
<td>Server administration, moderation, abuse prevention</td>
|
||||
<td>Art. 6(1)(f) — legitimate interest of the server operator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Panel user accounts</td>
|
||||
<td>Authentication and authorisation for the web panel</td>
|
||||
<td>Art. 6(1)(b) — performance of a contract / access service</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>E-mail addresses (invites)</td>
|
||||
<td>Sending one-time panel invitation links</td>
|
||||
<td>Art. 6(1)(a) — consent (the invite was requested by a group admin)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Server access logs</td>
|
||||
<td>Security monitoring and error diagnosis</td>
|
||||
<td>Art. 6(1)(f) — legitimate interest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Panel audit log (incl. IP addresses of panel users)</td>
|
||||
<td>Accountability for access to personal data; security incident traceability</td>
|
||||
<td>Art. 6(1)(c) — legal obligation / Art. 32 GDPR (security of processing)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">5. Data Retention</h2>
|
||||
<ul>
|
||||
<li><strong>Minecraft logs</strong> are retained as long as the server operator deems necessary for moderation purposes.</li>
|
||||
<li><strong>Panel accounts</strong> are retained until manually deleted by a site administrator.</li>
|
||||
<li><strong>Invite tokens</strong> expire after {{ invite_expiry_hours }} hours and are never sent to third parties beyond the intended recipient.</li>
|
||||
<li><strong>Panel audit log entries</strong> are automatically deleted after {{ audit_retention_days }} days. This includes IP address data logged on data-access events.</li>
|
||||
<li><strong>Server access logs</strong> are typically rotated within 30 days.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">6. Data Sharing & Third Parties</h2>
|
||||
<p>
|
||||
Data collected by MCLogger is <strong>not sold, rented, or shared</strong> with third
|
||||
parties. All data remains within the infrastructure controlled by the server operator.
|
||||
No third-party analytics services, advertising networks, or tracking pixels are used.
|
||||
</p>
|
||||
<p>
|
||||
Player head images are loaded from <strong>minotar.net</strong>, a public Minecraft avatar service.
|
||||
Minotar may process the Minecraft username and your IP address as part of serving the image.
|
||||
Please consult <a href="https://minotar.net" target="_blank" rel="noopener noreferrer">minotar.net</a>
|
||||
for their privacy practices. If the image cannot be loaded, a local fallback placeholder is displayed.
|
||||
</p>
|
||||
<p>
|
||||
External resources loaded by the web interface (Bootstrap CSS/JS and Bootstrap Icons)
|
||||
are served from the jsDelivr CDN (<code>cdn.jsdelivr.net</code>). jsDelivr may process
|
||||
your IP address as part of delivering these static files. Please consult
|
||||
<a href="https://www.jsdelivr.com/privacy-policy-jsdelivr-net" target="_blank" rel="noopener noreferrer">jsDelivr's privacy policy</a>
|
||||
for details.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">7. Security</h2>
|
||||
<p>
|
||||
MCLogger applies the following technical safeguards:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Passwords are hashed with PBKDF2-HMAC-SHA256 (per-user salt + server pepper).</li>
|
||||
<li>Stored database credentials and SMTP credentials are encrypted with Fernet symmetric encryption before being written to the database.</li>
|
||||
<li>CSRF tokens are enforced on all state-changing requests.</li>
|
||||
<li>Security response headers (X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, Referrer-Policy) are set on every response.</li>
|
||||
<li>SMTP connections use STARTTLS.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">8. Your Rights (GDPR)</h2>
|
||||
<p>If you are subject to the GDPR you have the following rights:</p>
|
||||
<ul>
|
||||
<li><strong>Right of access</strong> (Art. 15) — request a copy of your personal data.</li>
|
||||
<li><strong>Right to rectification</strong> (Art. 16) — request correction of inaccurate data.</li>
|
||||
<li><strong>Right to erasure</strong> (Art. 17) — request deletion of your data ("right to be forgotten").</li>
|
||||
<li><strong>Right to restriction of processing</strong> (Art. 18) — request that processing is restricted while a dispute is resolved.</li>
|
||||
<li><strong>Right to data portability</strong> (Art. 20) — receive your data in a structured, machine-readable format.</li>
|
||||
<li><strong>Right to object</strong> (Art. 21) — object to processing based on legitimate interest.</li>
|
||||
<li><strong>Right to withdraw consent</strong> (Art. 7(3)) — withdraw any consent you have given at any time.</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise any of these rights, please contact:
|
||||
<a href="mailto:simon@devanturas.net">simon@devanturas.net</a>
|
||||
</p>
|
||||
<p>
|
||||
You also have the right to lodge a complaint with your national data protection
|
||||
supervisory authority.
|
||||
</p>
|
||||
|
||||
<h2 class="fw-semibold border-bottom border-secondary pb-2">9. Changes to This Policy</h2>
|
||||
<p>
|
||||
This privacy policy may be updated to reflect changes in the software or applicable law.
|
||||
The "Last updated" date at the top of this page indicates when the most recent revision
|
||||
was made.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-muted small pb-5">
|
||||
<a href="{{ url_for('auth.login') }}" class="text-muted me-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Login
|
||||
</a>
|
||||
MCLogger — Made by <a href="https://log.devanturas.net" class="text-muted" target="_blank" rel="noopener noreferrer">Devanturas</a> — <a href="mailto:simon@devanturas.net" class="text-muted">simon@devanturas.net</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user