modified: web/blueprints/group_admin.py

modified:   web/templates/admin/audit_log.html
	new file:   web/templates/group_admin/player_delete_confirm.html
	modified:   web/templates/panel/player_detail.html
This commit is contained in:
simon
2026-04-15 12:42:37 +02:00
parent 52674fee29
commit aa0544a4a5
4 changed files with 266 additions and 2 deletions

View File

@@ -2,10 +2,15 @@
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, build_invite_email, force_https_url
import panel_db as db
@@ -337,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,
@@ -372,3 +376,169 @@ def database_delete():
)
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)

View File

@@ -131,6 +131,8 @@
'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 }}

View File

@@ -0,0 +1,66 @@
{% extends "group_admin/base.html" %}
{% block title %}Delete Player Data{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-7 col-lg-6">
<div class="card border-danger">
<div class="card-header bg-danger bg-opacity-75 fw-bold">
<i class="bi bi-exclamation-triangle-fill me-2"></i>Permanently Delete Player Data
</div>
<div class="card-body">
<p class="mb-3">
You are about to <strong>permanently delete all logged data</strong> for:
</p>
<div class="alert alert-secondary d-flex align-items-center gap-3 py-2">
<img src="https://minotar.net/avatar/{{ player.username }}/48"
class="rounded" alt="{{ player.username }}" width="48" height="48"
onerror="this.onerror=null;this.src='data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'48\' height=\'48\' viewBox=\'0 0 48 48\'%3E%3Crect width=\'48\' height=\'48\' rx=\'6\' fill=\'%23374151\'/%3E%3Ctext x=\'50%25\' y=\'54%25\' text-anchor=\'middle\' dominant-baseline=\'middle\' font-size=\'22\' font-family=\'monospace\' fill=\'%239ca3af\'%3E%3F%3C/text%3E%3C/svg%3E'">
<div>
<div class="fw-bold">{{ player.username }}</div>
<div class="text-muted small font-monospace">{{ player.uuid }}</div>
</div>
</div>
<p class="text-danger fw-semibold mt-3 mb-1">
<i class="bi bi-exclamation-circle-fill me-1"></i>This action cannot be undone.
</p>
<ul class="text-muted small mb-4">
<li>Sessions, chat, commands, deaths, teleports</li>
<li>Block events, proxy events, inventory events</li>
<li>Player stats, entity interactions</li>
<li>The player's base record (UUID, username, IP, playtime)</li>
</ul>
<form method="post" action="{{ url_for('group_admin.player_delete', uuid=player.uuid) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="confirm_name" class="form-label fw-semibold">
Type <span class="text-danger font-monospace">{{ player.username }}</span> to confirm:
</label>
<input type="text" id="confirm_name" name="confirm_name"
class="form-control bg-dark text-white border-danger"
placeholder="{{ player.username }}" autocomplete="off" required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill me-1"></i>Delete All Data
</button>
<a href="{{ url_for('panel.player_detail', uuid=player.uuid) }}"
class="btn btn-outline-secondary">
<i class="bi bi-x-lg me-1"></i>Cancel
</a>
</div>
</form>
</div>
</div>
<div class="mt-3 text-muted small">
<i class="bi bi-info-circle me-1"></i>
This deletion is logged in the audit log as required by Art. 5(2) GDPR (accountability).
The export function (Art. 20 GDPR) is available on the player detail page.
</div>
</div>
</div>
{% endblock %}

View File

@@ -140,4 +140,30 @@
<a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Overview
</a>
{% if is_admin and not session.get('is_site_admin') %}
<div class="card border-warning mt-4">
<div class="card-header bg-warning bg-opacity-10 text-warning fw-semibold">
<i class="bi bi-shield-lock me-2"></i>GDPR Actions
</div>
<div class="card-body d-flex flex-wrap gap-3 align-items-center">
<div>
<a href="{{ url_for('group_admin.player_export', uuid=player.uuid) }}"
class="btn btn-outline-info">
<i class="bi bi-download me-1"></i>Export Data (Art. 20 GDPR)
</a>
<div class="form-text text-muted mt-1">Download all logged data as ZIP (group admins &amp; owners)</div>
</div>
{% if session.get('role') == 'group_owner' %}
<div>
<a href="{{ url_for('group_admin.player_delete', uuid=player.uuid) }}"
class="btn btn-outline-danger">
<i class="bi bi-trash3 me-1"></i>Delete All Data (Art. 17 GDPR)
</a>
<div class="form-text text-danger mt-1">Permanently erase all player data (owner only)</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}