Upload via GUI (94 Dateien)
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
// Datenschutz: Anonymisierung von Tickets ohne Registrierung und Löschung registrierter Kunden auf Antrag. Alle Funktionen beginnen mit datenschutz_.
|
||||
|
||||
const DATENSCHUTZ = 'datenschutz';
|
||||
const DATENSCHUTZ_TEXT_REMOVED = '[Nachricht aus Datenschutzgründen entfernt]';
|
||||
|
||||
/** Was sich anonymisieren lässt (Schlüssel => Beschriftung). */
|
||||
/**
|
||||
* Selbstheilung: Fehlt nach einem Update eine Datenbank-Änderung des Plugins (ältere DeskHub-Dateien spielen sie nicht von selbst ein),
|
||||
* wird sie hier nachgeholt, statt dass das Plugin mit Datenbankfehlern abgeschaltet wird.
|
||||
*/
|
||||
function datenschutz_ensure_schema(bool $force = false): void
|
||||
{
|
||||
static $done = false;
|
||||
if ($done && !$force) return;
|
||||
$done = true;
|
||||
try {
|
||||
get_db()->query('SELECT ticket_id FROM plugin_datenschutz_done LIMIT 1');
|
||||
} catch (Throwable $e) {
|
||||
try {
|
||||
plugin_run_migrations(get_db(), DATENSCHUTZ);
|
||||
} catch (Throwable $e2) {
|
||||
error_log('datenschutz schema: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function datenschutz_field_labels(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Name',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'ip' => 'IP-Adresse',
|
||||
'collaborators' => 'Mitleser (Adressen in Kopie)',
|
||||
'fieldvalues' => 'Zusatzfelder (vom Kunden ausgefüllt)',
|
||||
'tokens' => 'Zugangslinks zum Ticket',
|
||||
'rating' => 'Kommentar der Bewertung',
|
||||
'messages' => 'Nachrichten des Kunden (Text)',
|
||||
'attachments' => 'Anhänge des Kunden (Dateien)',
|
||||
];
|
||||
}
|
||||
|
||||
/** Auswahl-Vorlagen für die Einstellungen. @return array<string,array{0:string,1:list<string>}> */
|
||||
function datenschutz_presets(): array
|
||||
{
|
||||
return [
|
||||
'standard' => ['Stammdaten (Name, E-Mail, IP, Mitleser, Zusatzfelder, Links)', ['name', 'email', 'ip', 'collaborators', 'fieldvalues', 'tokens', 'rating']],
|
||||
'full' => ['Komplett (zusätzlich Nachrichten und Anhänge)', array_keys(datenschutz_field_labels())],
|
||||
'name' => ['Nur der Name', ['name']],
|
||||
'email' => ['Nur die E-Mail-Adresse', ['email']],
|
||||
'name_email' => ['Name und E-Mail-Adresse', ['name', 'email']],
|
||||
];
|
||||
}
|
||||
|
||||
function datenschutz_units(): array
|
||||
{
|
||||
return ['days' => 'Tage', 'weeks' => 'Wochen', 'months' => 'Monate', 'years' => 'Jahre'];
|
||||
}
|
||||
|
||||
/** Zeitpunkt, vor dem Tickets als „fällig“ gelten. */
|
||||
function datenschutz_cutoff(array $cfg): string
|
||||
{
|
||||
return date('Y-m-d H:i:s', (int) strtotime('-' . (int) $cfg['amount'] . ' ' . $cfg['unit']));
|
||||
}
|
||||
|
||||
/** Adresse eines Reiters der Seite. */
|
||||
function datenschutz_tab_url(string $tab, array $extra = []): string
|
||||
{
|
||||
$q = ['p' => DATENSCHUTZ, 'page' => 'datenschutz', 'tab' => $tab] + array_filter($extra, fn($v) => $v !== '' && $v !== null && $v !== 1);
|
||||
return '/staff/plugin.php?' . http_build_query($q);
|
||||
}
|
||||
|
||||
/** Reiterleiste. @param array<string,string> $tabs Schlüssel => Beschriftung */
|
||||
function datenschutz_tabs(string $active, array $tabs): string
|
||||
{
|
||||
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border,#e3e9ee);margin:0 0 18px">';
|
||||
foreach ($tabs as $key => $label) {
|
||||
$on = $key === $active;
|
||||
$html .= '<a href="' . htmlspecialchars(datenschutz_tab_url($key), ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
|
||||
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border,#e3e9ee);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted,#64748b);border:1px solid transparent') . '">'
|
||||
. htmlspecialchars($label) . '</a>';
|
||||
}
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
/** „30 Tage“, „1 Monat“ usw. für Texte. */
|
||||
function datenschutz_frist_label(array $cfg): string
|
||||
{
|
||||
$one = ['days' => 'Tag', 'weeks' => 'Woche', 'months' => 'Monat', 'years' => 'Jahr'];
|
||||
$many = datenschutz_units();
|
||||
return (int) $cfg['amount'] . ' ' . ((int) $cfg['amount'] === 1 ? $one[$cfg['unit']] : $many[$cfg['unit']]);
|
||||
}
|
||||
|
||||
function datenschutz_config(): array
|
||||
{
|
||||
$labels = datenschutz_field_labels();
|
||||
$raw = plugin_setting(DATENSCHUTZ, 'fields', null);
|
||||
$fields = $raw === null ? datenschutz_presets()['standard'][1] : array_values(array_intersect(array_keys($labels), array_filter(explode(',', (string) $raw))));
|
||||
$unit = (string) plugin_setting(DATENSCHUTZ, 'unit', 'days');
|
||||
$unit = isset(datenschutz_units()[$unit]) ? $unit : 'days';
|
||||
$max = ['days' => 3650, 'weeks' => 520, 'months' => 120, 'years' => 10][$unit];
|
||||
return [
|
||||
'enabled' => plugin_setting(DATENSCHUTZ, 'enabled', '0') === '1',
|
||||
'amount' => max(1, min($max, (int) plugin_setting(DATENSCHUTZ, 'amount', '30'))),
|
||||
'unit' => $unit,
|
||||
'basis' => plugin_setting(DATENSCHUTZ, 'basis', 'closed') === 'created' ? 'created' : 'closed',
|
||||
'include_open' => plugin_setting(DATENSCHUTZ, 'include_open', '0') === '1',
|
||||
'fields' => $fields,
|
||||
];
|
||||
}
|
||||
/** Nicht rückrechenbare Ersatzadresse (Zufallswert, keine Ableitung aus der echten Adresse). */
|
||||
function datenschutz_anon_email(): string
|
||||
{
|
||||
return 'anon_' . bin2hex(random_bytes(10)) . '@deleted.local';
|
||||
}
|
||||
|
||||
/** Adresse für das Protokoll: nur Anfangsbuchstaben, damit das Protokoll selbst keine Kundendaten enthält. */
|
||||
function datenschutz_mask(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
$dom = $domain !== '' ? mb_substr($domain, 0, 1) . '***' . (str_contains($domain, '.') ? substr($domain, strrpos($domain, '.')) : '') : '';
|
||||
return mb_substr($local, 0, 1) . '***' . ($dom !== '' ? '@' . $dom : '');
|
||||
}
|
||||
|
||||
function datenschutz_log(PDO $pdo, string $kind, int $tickets, int $accounts, string $note, ?int $staffId = null): void
|
||||
{
|
||||
$pdo->prepare('INSERT INTO plugin_datenschutz_log (kind, tickets, accounts, note, staff_id, created_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$kind, $tickets, $accounts, mb_substr($note, 0, 250), $staffId, db_now()]);
|
||||
}
|
||||
|
||||
/** WHERE-Teil für Tickets ohne Registrierung, die anonymisiert werden sollen. @return array{0:string,1:array} */
|
||||
function datenschutz_candidate_sql(array $cfg): array
|
||||
{
|
||||
$cut = datenschutz_cutoff($cfg);
|
||||
$closed = "status IN ('closed','resolved')";
|
||||
$notDone = "requester_email NOT LIKE 'anon!_%' ESCAPE '!' AND id NOT IN (SELECT ticket_id FROM plugin_datenschutz_done)";
|
||||
if ($cfg['basis'] === 'closed') {
|
||||
$sql = "(({$closed} AND COALESCE(closed_at, resolved_at, updated_at) < ?)" . ($cfg['include_open'] ? " OR (status NOT IN ('closed','resolved') AND updated_at < ?)" : '') . ')';
|
||||
$params = $cfg['include_open'] ? [$cut, $cut] : [$cut];
|
||||
} else {
|
||||
$sql = "(({$closed} AND created_at < ?)" . ($cfg['include_open'] ? " OR (status NOT IN ('closed','resolved') AND created_at < ? AND updated_at < ?)" : '') . ')';
|
||||
$params = $cfg['include_open'] ? [$cut, $cut, $cut] : [$cut];
|
||||
}
|
||||
return ["user_id IS NULL AND {$notDone} AND {$sql}", $params];
|
||||
}
|
||||
|
||||
function datenschutz_candidate_count(PDO $pdo): int
|
||||
{
|
||||
[$where, $params] = datenschutz_candidate_sql(datenschutz_config());
|
||||
$s = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE {$where}");
|
||||
$s->execute($params);
|
||||
return (int) $s->fetchColumn();
|
||||
}
|
||||
|
||||
/** Entfernt die gewählten Kundendaten eines Tickets unwiderruflich. */
|
||||
function datenschutz_anonymize_ticket(PDO $pdo, int $ticketId, array $cfg): void
|
||||
{
|
||||
$f = $cfg['fields'];
|
||||
$sets = [];
|
||||
$params = [];
|
||||
if (in_array('name', $f, true)) $sets[] = "requester_name = 'Anonymisiert'";
|
||||
if (in_array('email', $f, true)) { $sets[] = 'requester_email = ?'; $params[] = datenschutz_anon_email(); }
|
||||
if (in_array('ip', $f, true)) $sets[] = "requester_ip = '0.0.0.0'";
|
||||
if ($sets) $pdo->prepare('UPDATE tickets SET ' . implode(', ', $sets) . ' WHERE id = ?')->execute(array_merge($params, [$ticketId]));
|
||||
if (in_array('collaborators', $f, true)) $pdo->prepare('DELETE FROM ticket_collaborators WHERE ticket_id = ?')->execute([$ticketId]);
|
||||
if (in_array('tokens', $f, true)) $pdo->prepare('DELETE FROM ticket_access_tokens WHERE ticket_id = ?')->execute([$ticketId]);
|
||||
if (in_array('fieldvalues', $f, true)) $pdo->prepare('UPDATE ticket_field_values SET value = NULL WHERE ticket_id = ?')->execute([$ticketId]);
|
||||
if (in_array('rating', $f, true)) $pdo->prepare('UPDATE ticket_ratings SET comment = NULL, user_id = NULL WHERE ticket_id = ?')->execute([$ticketId]);
|
||||
if (in_array('email', $f, true) || in_array('name', $f, true)) {
|
||||
try { $pdo->prepare('DELETE FROM plugin_communitybot_links WHERE ticket_id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Plugin nicht installiert */ }
|
||||
}
|
||||
if (in_array('messages', $f, true)) {
|
||||
$pdo->prepare("UPDATE ticket_threads SET message = ? WHERE ticket_id = ? AND author_type = 'customer'")->execute([DATENSCHUTZ_TEXT_REMOVED, $ticketId]);
|
||||
}
|
||||
if (in_array('attachments', $f, true)) {
|
||||
$dir = realpath(rtrim((string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads'), '/\\'));
|
||||
$q = $pdo->prepare("SELECT a.id, a.stored_name FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ? AND t.author_type = 'customer'");
|
||||
$q->execute([$ticketId]);
|
||||
foreach ($q->fetchAll() as $a) {
|
||||
if ($dir && !in_array($a['stored_name'], ['gesperrt', 'geloescht'], true)) {
|
||||
foreach ([$dir . '/' . basename((string) $a['stored_name']), $dir . '/quarantaene/' . basename((string) $a['stored_name'])] as $file) if (is_file($file)) @unlink($file);
|
||||
}
|
||||
$pdo->prepare("UPDATE ticket_attachments SET original_name = '[Anhang aus Datenschutzgründen gelöscht]', stored_name = 'geloescht' WHERE id = ?")->execute([$a['id']]);
|
||||
}
|
||||
}
|
||||
$has = $pdo->prepare('SELECT COUNT(*) FROM plugin_datenschutz_done WHERE ticket_id = ?');
|
||||
$has->execute([$ticketId]);
|
||||
if (!(int) $has->fetchColumn()) $pdo->prepare('INSERT INTO plugin_datenschutz_done (ticket_id, done_at) VALUES (?,?)')->execute([$ticketId, db_now()]);
|
||||
$labels = datenschutz_field_labels();
|
||||
log_activity($pdo, $ticketId, null, 'Kundendaten aus Datenschutzgründen anonymisiert (' . implode(', ', array_map(fn($k) => $labels[$k], $f)) . ')');
|
||||
}
|
||||
|
||||
/** Anonymisiert fällige Tickets ohne Registrierung. @return int Zahl der Tickets */
|
||||
function datenschutz_run(PDO $pdo, int $limit = 200, string $kind = 'auto', ?int $staffId = null): int
|
||||
{
|
||||
$cfg = datenschutz_config();
|
||||
if (!$cfg['enabled'] || !$cfg['fields']) return 0;
|
||||
[$where, $params] = datenschutz_candidate_sql($cfg);
|
||||
$s = $pdo->prepare("SELECT id FROM tickets WHERE {$where} ORDER BY id LIMIT " . max(1, $limit));
|
||||
$s->execute($params);
|
||||
$n = 0;
|
||||
foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
try {
|
||||
datenschutz_anonymize_ticket($pdo, (int) $id, $cfg);
|
||||
$n++;
|
||||
} catch (Throwable $e) {
|
||||
error_log('datenschutz ticket ' . $id . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
plugin_set_setting(DATENSCHUTZ, 'last_run', db_now());
|
||||
if ($n > 0) datenschutz_log($pdo, $kind, $n, 0, 'Tickets ohne Registrierung nach ' . datenschutz_frist_label($cfg) . ' anonymisiert (' . count($cfg['fields']) . ' Datenarten)', $staffId);
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** Hintergrundlauf: höchstens einmal pro Stunde. */
|
||||
function datenschutz_tick(PDO $pdo): int
|
||||
{
|
||||
if (!datenschutz_config()['enabled']) return 0;
|
||||
if (time() - (int) plugin_setting(DATENSCHUTZ, 'last_tick', '0') < 3600) return 0;
|
||||
plugin_set_setting(DATENSCHUTZ, 'last_tick', (string) time());
|
||||
return datenschutz_run($pdo, 200, 'auto');
|
||||
}
|
||||
/** Registrierten Kunden suchen. @return array{user:array,tickets:int,open:int,already:bool}|null */
|
||||
function datenschutz_find_customer(PDO $pdo, string $email): ?array
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
if ($email === '') return null;
|
||||
$s = $pdo->prepare('SELECT id, name, email, organization_id FROM users WHERE LOWER(email) = ?');
|
||||
$s->execute([$email]);
|
||||
$u = $s->fetch();
|
||||
if (!$u) return null;
|
||||
$c = $pdo->prepare("SELECT COUNT(*), SUM(CASE WHEN status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) FROM tickets WHERE user_id = ? OR LOWER(requester_email) = ?");
|
||||
$c->execute([(int) $u['id'], $email]);
|
||||
[$total, $open] = array_map('intval', array_values($c->fetch(PDO::FETCH_NUM)));
|
||||
return ['user' => $u, 'tickets' => $total, 'open' => $open, 'already' => str_starts_with((string) $u['email'], 'anon_')];
|
||||
}
|
||||
|
||||
/** Löscht die Daten eines registrierten Kunden auf dessen Antrag unwiderruflich. @return array{ok:bool,error?:string,tickets?:int} */
|
||||
function datenschutz_delete_customer(PDO $pdo, array $staff, int $userId, bool $closeOpen): array
|
||||
{
|
||||
if (($staff['role'] ?? '') !== 'admin') return ['ok' => false, 'error' => 'Das darf nur ein Administrator.'];
|
||||
$s = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||
$s->execute([$userId]);
|
||||
$u = $s->fetch();
|
||||
if (!$u) return ['ok' => false, 'error' => 'Der Kunde wurde nicht gefunden.'];
|
||||
if (str_starts_with((string) $u['email'], 'anon_')) return ['ok' => false, 'error' => 'Dieser Kunde ist bereits anonymisiert.'];
|
||||
$email = strtolower((string) $u['email']);
|
||||
$t = $pdo->prepare('SELECT id, status FROM tickets WHERE user_id = ? OR LOWER(requester_email) = ?');
|
||||
$t->execute([$userId, $email]);
|
||||
$tickets = $t->fetchAll();
|
||||
$open = array_filter($tickets, fn($x) => !in_array($x['status'], ['closed', 'resolved'], true));
|
||||
if ($open && !$closeOpen) return ['ok' => false, 'error' => 'Der Kunde hat noch ' . count($open) . ' offene Tickets. Bitte die Option „Offene Tickets schließen“ wählen oder die Tickets zuerst bearbeiten.'];
|
||||
$cfg = datenschutz_config();
|
||||
// Bei einer Löschung auf Antrag werden Nachrichten und Anhänge des Kunden immer mit entfernt: er hat die Löschung seiner Daten verlangt
|
||||
$cfg['fields'] = array_keys(datenschutz_field_labels());
|
||||
$now = db_now();
|
||||
foreach ($tickets as $row) {
|
||||
if (in_array($row['id'], array_column($open, 'id'), true)) {
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $row['id']]);
|
||||
log_activity($pdo, (int) $row['id'], null, 'Ticket auf Antrag des Kunden auf Löschung geschlossen');
|
||||
}
|
||||
datenschutz_anonymize_ticket($pdo, (int) $row['id'], $cfg);
|
||||
$pdo->prepare('UPDATE tickets SET user_id = NULL WHERE id = ?')->execute([$row['id']]);
|
||||
}
|
||||
$pdo->prepare('UPDATE ticket_threads SET user_id = NULL WHERE user_id = ?')->execute([$userId]);
|
||||
$pdo->prepare('DELETE FROM customer_notes WHERE user_id = ?')->execute([$userId]);
|
||||
try { $pdo->prepare('DELETE FROM password_resets WHERE LOWER(email) = ?')->execute([$email]); } catch (Throwable $e) {}
|
||||
$pdo->prepare("UPDATE users SET name = 'Anonymisiert', email = ?, phone = NULL, organization_id = NULL, password_hash = ? WHERE id = ?")
|
||||
->execute([datenschutz_anon_email(), password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $userId]);
|
||||
datenschutz_log($pdo, 'request', count($tickets), 1, 'Löschung auf Antrag: ' . datenschutz_mask($email), (int) $staff['id']);
|
||||
return ['ok' => true, 'tickets' => count($tickets)];
|
||||
}
|
||||
|
||||
plugin_add_hook('plugin.uninstall.' . DATENSCHUTZ, function () {
|
||||
try {
|
||||
get_db()->exec('DROP TABLE IF EXISTS plugin_datenschutz_log');
|
||||
get_db()->exec('DROP TABLE IF EXISTS plugin_datenschutz_done');
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_datenschutz_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(12) NOT NULL,
|
||||
tickets INT NOT NULL DEFAULT 0,
|
||||
accounts INT NOT NULL DEFAULT 0,
|
||||
note VARCHAR(250) NOT NULL DEFAULT '',
|
||||
staff_id INT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_datenschutz_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
tickets INTEGER NOT NULL DEFAULT 0,
|
||||
accounts INTEGER NOT NULL DEFAULT 0,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
staff_id INTEGER NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_datenschutz_done (
|
||||
ticket_id INT PRIMARY KEY,
|
||||
done_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_datenschutz_done (
|
||||
ticket_id INTEGER PRIMARY KEY,
|
||||
done_at TEXT NOT NULL
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
// Seite des Datenschutz-Plugins. Verfügbar: $staff, $pdo. Nur für Administratoren.
|
||||
if (($staff['role'] ?? '') !== 'admin') { echo '<h1>Datenschutz</h1><div class="flash error">Diese Seite ist nur für Administratoren zugänglich.</div>'; return; }
|
||||
$message = null;
|
||||
$error = null;
|
||||
$found = null;
|
||||
$searched = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'settings') {
|
||||
$unit = isset(datenschutz_units()[$_POST['unit'] ?? '']) ? $_POST['unit'] : 'days';
|
||||
$picked = array_values(array_intersect(array_keys(datenschutz_field_labels()), (array) ($_POST['fields'] ?? [])));
|
||||
if (!$picked) {
|
||||
$error = 'Bitte mindestens eine Datenart zum Anonymisieren wählen.';
|
||||
} else {
|
||||
plugin_set_setting(DATENSCHUTZ, 'amount', (string) max(1, (int) ($_POST['amount'] ?? 30)));
|
||||
plugin_set_setting(DATENSCHUTZ, 'unit', $unit);
|
||||
plugin_set_setting(DATENSCHUTZ, 'basis', ($_POST['basis'] ?? '') === 'created' ? 'created' : 'closed');
|
||||
plugin_set_setting(DATENSCHUTZ, 'include_open', !empty($_POST['include_open']) ? '1' : '0');
|
||||
plugin_set_setting(DATENSCHUTZ, 'fields', implode(',', $picked));
|
||||
$message = 'Einstellungen gespeichert.';
|
||||
}
|
||||
} elseif ($action === 'enable') {
|
||||
if (empty($_POST['confirm'])) $error = 'Bitte bestätigen, dass Sie die unwiderrufliche Anonymisierung einschalten wollen.';
|
||||
else { plugin_set_setting(DATENSCHUTZ, 'enabled', '1'); datenschutz_log($pdo, 'manual', 0, 0, 'Automatische Anonymisierung eingeschaltet', (int) $staff['id']); $message = 'Die automatische Anonymisierung ist eingeschaltet. Sie läuft ab jetzt im Hintergrund.'; }
|
||||
} elseif ($action === 'disable') {
|
||||
plugin_set_setting(DATENSCHUTZ, 'enabled', '0');
|
||||
datenschutz_log($pdo, 'manual', 0, 0, 'Automatische Anonymisierung ausgeschaltet', (int) $staff['id']);
|
||||
$message = 'Die automatische Anonymisierung ist ausgeschaltet.';
|
||||
} elseif ($action === 'run') {
|
||||
$n = datenschutz_run($pdo, 500, 'manual', (int) $staff['id']);
|
||||
$message = $n . ' Ticket(s) anonymisiert.';
|
||||
} elseif ($action === 'core_off') {
|
||||
set_setting('retention_enabled', '0');
|
||||
audit_log($staff['id'], 'Automatische Datenlöschung des Kerns ausgeschaltet (Plugin Datenschutz übernimmt)');
|
||||
$message = 'Die automatische Datenlöschung des Kerns ist ausgeschaltet.';
|
||||
} elseif ($action === 'find') {
|
||||
$searched = trim((string) ($_POST['email'] ?? ''));
|
||||
$found = datenschutz_find_customer($pdo, $searched);
|
||||
if (!$found) $error = 'Unter dieser Adresse gibt es keinen registrierten Kunden.';
|
||||
} elseif ($action === 'delete_customer') {
|
||||
if (empty($_POST['confirm'])) $error = 'Bitte die Löschung bestätigen.';
|
||||
else {
|
||||
$r = datenschutz_delete_customer($pdo, $staff, (int) ($_POST['user_id'] ?? 0), !empty($_POST['close_open']));
|
||||
if ($r['ok']) $message = 'Die Daten des Kunden wurden unwiderruflich gelöscht (' . $r['tickets'] . ' Ticket(s) anonymisiert, Konto anonymisiert, Nachrichten und Anhänge entfernt).';
|
||||
else { $error = $r['error']; $searched = trim((string) ($_POST['email'] ?? '')); $found = datenschutz_find_customer($pdo, $searched); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cfg = datenschutz_config();
|
||||
$count = datenschutz_candidate_count($pdo);
|
||||
$tabs = ['uebersicht' => 'Übersicht', 'einstellungen' => 'Einstellungen', 'antrag' => 'Löschung auf Antrag', 'protokoll' => 'Protokoll'];
|
||||
$tab = (string) ($_GET['tab'] ?? 'uebersicht');
|
||||
if (!isset($tabs[$tab])) $tab = 'uebersicht';
|
||||
// Nach einer Suche bzw. Löschung bleibt man im Reiter „Löschung auf Antrag“
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['action'] ?? '', ['find', 'delete_customer'], true)) $tab = 'antrag';
|
||||
$log = [];
|
||||
$logTotal = 0;
|
||||
$perPage = 50;
|
||||
$seite = 1;
|
||||
if ($tab === 'protokoll') {
|
||||
$logTotal = (int) $pdo->query('SELECT COUNT(*) FROM plugin_datenschutz_log')->fetchColumn();
|
||||
$pages = max(1, (int) ceil($logTotal / $perPage));
|
||||
$seite = min($pages, max(1, (int) ($_GET['seite'] ?? 1)));
|
||||
$log = $pdo->query('SELECT l.*, s.name AS staff_name FROM plugin_datenschutz_log l LEFT JOIN staff s ON s.id = l.staff_id ORDER BY l.id DESC LIMIT ' . $perPage . ' OFFSET ' . (($seite - 1) * $perPage))->fetchAll();
|
||||
}
|
||||
$coreOn = get_setting('retention_enabled', '0') === '1';
|
||||
$kinds = ['auto' => 'Automatisch', 'manual' => 'Von Hand', 'request' => 'Löschung auf Antrag'];
|
||||
?>
|
||||
<h1>Datenschutz</h1>
|
||||
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
|
||||
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||
|
||||
<?= datenschutz_tabs($tab, $tabs) ?>
|
||||
|
||||
<?php if ($coreOn): ?>
|
||||
<div class="flash error" style="max-width:900px">
|
||||
Die automatische Datenlöschung von DeskHub (Einstellungen → Datenschutz) ist eingeschaltet. Sie anonymisiert nach ihrer Frist <strong>auch registrierte Kunden</strong>. Das widerspricht der Regel „Daten registrierter Kunden bleiben bis zur Löschung auf Antrag“. Schalten Sie sie aus, dieses Plugin übernimmt die Aufgabe.
|
||||
<form method="post" style="margin-top:8px"><?= csrf_field() ?><input type="hidden" name="action" value="core_off"><button class="btn secondary" type="submit">Automatische Löschung von DeskHub ausschalten</button></form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tab === 'uebersicht'): ?>
|
||||
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||
<h3>Regeln</h3>
|
||||
<ul style="font-size:.92rem;line-height:1.6;padding-left:18px">
|
||||
<li><strong>Tickets ohne Registrierung:</strong> Nach <?= e(datenschutz_frist_label($cfg)) ?> <?= $cfg['basis'] === 'closed' ? 'nach dem Schließen oder Lösen' : 'nach der Erstellung' ?> werden unwiderruflich entfernt: <strong><?= e(implode(', ', array_map(fn($k) => datenschutz_field_labels()[$k], $cfg['fields']))) ?></strong>. Eine Wiederherstellung ist nicht möglich<?= in_array('email', $cfg['fields'], true) ? ' (die Ersatzadresse ist ein Zufallswert)' : '' ?>.</li>
|
||||
<li><strong>Registrierte Kunden:</strong> Die Daten bleiben gespeichert, bis der Kunde die Löschung beantragt. Sie führen die Löschung unten aus.</li>
|
||||
</ul>
|
||||
<p style="margin-top:8px">
|
||||
<?php if ($cfg['enabled']): ?><strong style="color:#157a45">✓ Automatische Anonymisierung ist eingeschaltet.</strong> Zurzeit fällig: <strong><?= $count ?></strong> Ticket(s). Letzter Lauf: <?= e(plugin_setting(DATENSCHUTZ, 'last_run', '') ? format_dt((string) plugin_setting(DATENSCHUTZ, 'last_run', '')) : 'noch nicht') ?>.
|
||||
<?php else: ?><strong style="color:#b45309">Die automatische Anonymisierung ist noch ausgeschaltet.</strong> Beim Einschalten würden sofort <strong><?= $count ?></strong> vorhandene Ticket(s) anonymisiert.<?php endif; ?>
|
||||
</p>
|
||||
<?php if ($cfg['enabled']): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Jetzt <?= (int) $count ?> Ticket(s) unwiderruflich anonymisieren?')"><?= csrf_field() ?><input type="hidden" name="action" value="run"><button class="btn secondary" type="submit">Jetzt ausführen</button></form>
|
||||
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="disable"><button class="btn secondary" type="submit">Ausschalten</button></form>
|
||||
<?php else: ?>
|
||||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="enable">
|
||||
<label style="font-weight:400"><input type="checkbox" name="confirm" value="1" style="width:auto"> Ich weiß, dass die Anonymisierung unwiderruflich ist und sofort <?= $count ?> vorhandene Ticket(s) betrifft.</label>
|
||||
<div class="btn-row"><button class="btn" type="submit">Automatische Anonymisierung einschalten</button></div></form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tab === 'einstellungen'): ?>
|
||||
<form method="post" class="card" style="max-width:900px;margin-bottom:16px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="settings">
|
||||
<h3>Einstellungen (nur Administratoren)</h3>
|
||||
<label>Frist, nach der anonymisiert wird</label>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<input type="number" name="amount" min="1" max="3650" value="<?= (int) $cfg['amount'] ?>" style="max-width:110px">
|
||||
<select name="unit" style="max-width:140px"><?php foreach (datenschutz_units() as $k => $l): ?><option value="<?= e($k) ?>" <?= $cfg['unit'] === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?></select>
|
||||
<select name="basis"><option value="closed" <?= $cfg['basis'] === 'closed' ? 'selected' : '' ?>>nach dem Schließen bzw. Lösen des Tickets</option><option value="created" <?= $cfg['basis'] === 'created' ? 'selected' : '' ?>>nach der Erstellung des Tickets</option></select>
|
||||
</div>
|
||||
<label style="display:block;margin-top:8px;font-weight:400"><input type="checkbox" name="include_open" value="1" style="width:auto" <?= $cfg['include_open'] ? 'checked' : '' ?>> Auch offene Tickets anonymisieren, wenn sie seit Fristablauf nicht bearbeitet wurden (sonst nur geschlossene und gelöste)</label>
|
||||
|
||||
<label style="margin-top:14px">Was soll anonymisiert werden?</label>
|
||||
<div id="ds-presets" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<?php foreach (datenschutz_presets() as $key => [$plabel, $pfields]): ?><button type="button" class="btn secondary" style="padding:3px 11px;font-size:.8rem" data-fields="<?= e(implode(',', $pfields)) ?>"><?= e($plabel) ?></button><?php endforeach; ?>
|
||||
</div>
|
||||
<?php foreach (datenschutz_field_labels() as $key => $flabel): ?>
|
||||
<label style="display:block;font-weight:400"><input type="checkbox" name="fields[]" value="<?= e($key) ?>" style="width:auto" <?= in_array($key, $cfg['fields'], true) ? 'checked' : '' ?>> <?= e($flabel) ?></label>
|
||||
<?php endforeach; ?>
|
||||
<p class="muted" style="font-size:.8rem;margin-top:6px">Nachrichten können Namen, Adressen oder Telefonnummern im Text enthalten; Antworten der Mitarbeiter bleiben immer erhalten. Die Auswahl gilt für Tickets, die ab jetzt anonymisiert werden. Bereits bearbeitete Tickets werden nicht noch einmal angefasst.</p>
|
||||
<script>
|
||||
(function () {
|
||||
var box = document.getElementById('ds-presets');
|
||||
box.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('button[data-fields]'); if (!b) return;
|
||||
var want = b.getAttribute('data-fields').split(',');
|
||||
b.form.querySelectorAll('input[name="fields[]"]').forEach(function (c) { c.checked = want.indexOf(c.value) !== -1; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tab === 'antrag'): ?>
|
||||
<div class="card" style="max-width:900px;margin-bottom:16px">
|
||||
<h3>Löschung auf Antrag eines registrierten Kunden</h3>
|
||||
<p class="muted" style="font-size:.85rem">Der Kunde hat die Löschung seiner Daten verlangt. Das Konto wird anonymisiert und kann sich nicht mehr anmelden. Seine Tickets verlieren Name, Adresse, Nachrichten und Anhänge. Das ist nicht rückgängig zu machen.</p>
|
||||
<form method="post" style="display:flex;gap:8px;flex-wrap:wrap"><?= csrf_field() ?><input type="hidden" name="action" value="find"><input type="text" name="email" value="<?= e($searched) ?>" placeholder="E-Mail-Adresse des Kunden" style="flex:1;min-width:220px" required><button class="btn secondary" type="submit">Suchen</button></form>
|
||||
<?php if ($found && !$found['already']): ?>
|
||||
<form method="post" style="margin-top:12px;border-top:1px solid var(--border,#e3e9ee);padding-top:12px" onsubmit="return confirm('Die Daten dieses Kunden jetzt unwiderruflich löschen?')">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="delete_customer"><input type="hidden" name="user_id" value="<?= (int) $found['user']['id'] ?>"><input type="hidden" name="email" value="<?= e($searched) ?>">
|
||||
<p><strong><?= e((string) $found['user']['name']) ?></strong> (<?= e((string) $found['user']['email']) ?>)<br><?= (int) $found['tickets'] ?> Ticket(s), davon <?= (int) $found['open'] ?> offen.</p>
|
||||
<?php if ($found['open']): ?><label style="font-weight:400"><input type="checkbox" name="close_open" value="1" style="width:auto"> Offene Tickets schließen und anonymisieren</label><?php endif; ?>
|
||||
<label style="display:block;font-weight:400"><input type="checkbox" name="confirm" value="1" style="width:auto"> Der Kunde hat die Löschung beantragt. Ich weiß, dass sie unwiderruflich ist.</label>
|
||||
<div class="btn-row"><button class="btn danger" type="submit">Daten unwiderruflich löschen</button></div>
|
||||
</form>
|
||||
<?php elseif ($found): ?><p class="muted" style="margin-top:10px">Dieser Kunde ist bereits anonymisiert.</p><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tab === 'protokoll'): ?>
|
||||
<div class="card" style="max-width:900px">
|
||||
<h3>Protokoll</h3>
|
||||
<?php if (!$log): ?><p class="muted">Noch keine Einträge. Das Protokoll enthält keine Kundendaten, bei Löschanträgen nur eine verkürzte Adresse.</p><?php else: ?>
|
||||
<table style="width:100%"><thead><tr><th>Wann</th><th>Art</th><th>Tickets</th><th>Hinweis</th><th>Von</th></tr></thead><tbody>
|
||||
<?php foreach ($log as $l): ?>
|
||||
<tr><td class="muted" style="white-space:nowrap"><?= e(format_dt((string) $l['created_at'])) ?></td><td><?= e($kinds[$l['kind']] ?? $l['kind']) ?></td><td><?= (int) $l['tickets'] ?></td><td><?= e((string) $l['note']) ?></td><td><?= e((string) ($l['staff_name'] ?? '')) ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody></table>
|
||||
<?php if ($pages > 1): ?>
|
||||
<p class="muted" style="margin-top:10px;font-size:.85rem">
|
||||
<?php if ($seite > 1): ?><a href="<?= e(datenschutz_tab_url('protokoll', ['seite' => $seite - 1])) ?>">← Neuere</a> · <?php endif; ?>
|
||||
Seite <?= $seite ?> von <?= $pages ?>
|
||||
<?php if ($seite < $pages): ?> · <a href="<?= e(datenschutz_tab_url('protokoll', ['seite' => $seite + 1])) ?>">Ältere →</a><?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"slug": "datenschutz",
|
||||
"name": "Datenschutz (Anonymisierung und Löschung)",
|
||||
"version": "1.2.0",
|
||||
"description": "Kundendaten aus Tickets ohne Registrierung werden nach einer frei wählbaren Frist (Tage, Wochen, Monate, Jahre; Standard 30 Tage) unwiderruflich anonymisiert. Sie wählen selbst, was: nur Name, nur E-Mail-Adresse, beides oder komplett inklusive IP, Mitleser, Zusatzfelder, Nachrichten und Anhänge. Daten registrierter Kunden bleiben, bis der Kunde die Löschung beantragt; der Administrator führt sie mit einem Klick aus. Alle Einstellungen nur für Administratoren.",
|
||||
"author": "DeskHub",
|
||||
"requires": "1.0.0",
|
||||
"permissions": ["hooks.cron", "ui.menu", "db.migrations"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// Datenschutz: Anonymisierung im Hintergrund, Löschung auf Antrag über die Seite.
|
||||
require_once __DIR__ . '/lib.php';
|
||||
datenschutz_ensure_schema();
|
||||
|
||||
plugin_add_hook('cron.run', function () {
|
||||
datenschutz_tick(get_db());
|
||||
});
|
||||
|
||||
// Im Adminportal, nur für Administratoren (die Seite prüft das zusätzlich selbst)
|
||||
plugin_register_menu('Datenschutz', 'datenschutz', null, 'admin');
|
||||
Reference in New Issue
Block a user