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 MCLogger Gruppen-Admin-Bereich
Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten. Gruppen-Admins können ihre Mitglieder und MC-DB-Verbindung verwalten.
""" """
import csv
import io
import json import json
import zipfile
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps 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 config import Config
from mailer import send_mail, build_invite_email, force_https_url from mailer import send_mail, build_invite_email, force_https_url
import panel_db as db import panel_db as db
@@ -337,7 +342,6 @@ def database():
error = "Password is required." error = "Password is required."
else: else:
try: try:
import pymysql
test_conn = pymysql.connect( test_conn = pymysql.connect(
host=host, port=int(port), user=user, host=host, port=int(port), user=user,
password=password, database=database_name, password=password, database=database_name,
@@ -372,3 +376,169 @@ def database_delete():
) )
flash("Database connection removed.", "success") flash("Database connection removed.", "success")
return redirect(url_for("group_admin.database")) 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_proxy': 'badge bg-dark border border-info',
'panel.view_server_events': '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', '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"> <span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">
{{ row.action }} {{ 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"> <a href="{{ url_for('panel.players') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Overview <i class="bi bi-arrow-left me-1"></i>Back to Overview
</a> </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 %} {% endblock %}