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}> */ 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 $tabs Schlüssel => Beschriftung */ function datenschutz_tabs(string $active, array $tabs): string { $html = '
'; foreach ($tabs as $key => $label) { $on = $key === $active; $html .= '' . htmlspecialchars($label) . ''; } return $html . '
'; } /** „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) { } });