Files
2026-10-24 20:15:15 +00:00

281 lines
15 KiB
PHP

<?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) {
}
});