Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:18 +00:00
parent 56deade877
commit 6cced3beda
50 changed files with 11148 additions and 0 deletions
@@ -0,0 +1,148 @@
<?php
/**
* includes/crontab_installer.php
*
* Installiert/entfernt die 6 DeskHub-Cronjobs direkt in der System-Crontab des
* aktuellen Benutzers (crontab -l / crontab -). Nur sinnvoll auf einem eigenen
* (v)Server mit Shell-Zugriff für den Web-User – auf klassischem Shared-Hosting
* ist exec()/proc_open() fast immer gesperrt, siehe crontab_capability_check().
*
* Sicherheitsprinzip: Es wird NIE ein von außen beeinflussbarer String als
* Shell-Befehl ausgeführt. Die Crontab-Zeilen sind fest im Code hinterlegt
* (nur der erkannte PHP-Pfad und der DeskHub-Installationspfad sind variabel,
* beide serverseitig ermittelt, nicht aus einer Nutzereingabe). Nur der klar
* markierte "# DESKHUB-CRON-…"-Block wird verwaltet, alle anderen vorhandenen
* Crontab-Einträge des Nutzers bleiben unangetastet.
*/
const DESKHUB_CRON_MARKER_START = '# DESKHUB-CRON-START (automatisch verwaltet von DeskHub – Einstellungen → Cron-Jobs. Manuelle Änderungen hier werden beim erneuten Installieren überschrieben.)';
const DESKHUB_CRON_MARKER_END = '# DESKHUB-CRON-END';
/** Prüft, ob dieser Server eine automatische Installation überhaupt technisch zulässt. */
function crontab_capability_check(): array
{
// Crontab ist ein Unix-Mechanismus; auf Windows (z.B. lokale Entwicklung) von vornherein ausschließen,
// statt uns auf lokalisierte/unvorhersehbare Fehlertexte von cmd.exe verlassen zu müssen.
if (PHP_OS_FAMILY === 'Windows') {
return ['available' => false, 'reason' => 'Windows-Server werden nicht unterstützt (Crontab ist ein Unix/Linux-Mechanismus).'];
}
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
foreach (['proc_open', 'shell_exec'] as $fn) {
if (!function_exists($fn) || in_array($fn, $disabled, true)) {
return ['available' => false, 'reason' => "PHP-Funktion \"$fn\" ist auf diesem Server gesperrt (typisch für Shared-Hosting)."];
}
}
$out = @shell_exec('crontab -l 2>&1');
if ($out === null) {
return ['available' => false, 'reason' => 'Der Befehl "crontab" konnte nicht ausgeführt werden.'];
}
// "no crontab for <user>" ist der normale Zustand, wenn noch keine Crontab existiert – das ist OK.
if (stripos($out, 'not found') !== false || stripos($out, 'command not found') !== false) {
return ['available' => false, 'reason' => 'Das Programm "crontab" ist auf diesem Server nicht installiert.'];
}
if (stripos($out, 'permission denied') !== false) {
return ['available' => false, 'reason' => 'Der Web-Server-Benutzer hat keine Berechtigung für crontab.'];
}
return ['available' => true, 'reason' => ''];
}
/** Versucht, den echten CLI-PHP-Pfad zu ermitteln (PHP_BINARY zeigt unter Apache/FPM auf den falschen Binary). */
function detect_php_cli_binary(): string
{
$which = trim((string) @shell_exec('command -v php 2>/dev/null'));
if ($which !== '' && is_executable($which)) {
return $which;
}
foreach (['/usr/bin/php', '/usr/local/bin/php', '/opt/php/bin/php'] as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}
return 'php'; // Fallback: hoffen, dass PATH im Cron-Kontext passt
}
/** Baut den verwalteten DeskHub-Block mit den 6 Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */
function crontab_marker_block(): string
{
$php = detect_php_cli_binary();
$root = realpath(__DIR__ . '/..');
$lines = [
DESKHUB_CRON_MARKER_START,
"*/5 * * * * $php $root/cron/fetch_emails.php >> $root/cron/fetch_emails.log 2>&1",
"*/15 * * * * $php $root/cron/check_sla.php >> $root/cron/check_sla.log 2>&1",
"0 * * * * $php $root/cron/check_escalations.php >> $root/cron/check_escalations.log 2>&1",
"0 6 * * * $php $root/cron/process_recurring.php >> $root/cron/process_recurring.log 2>&1",
"0 2 * * * $php $root/cron/process_backups.php >> $root/cron/process_backups.log 2>&1",
"0 3 * * * $php $root/cron/anonymize_data.php >> $root/cron/anonymize_data.log 2>&1",
DESKHUB_CRON_MARKER_END,
];
return implode("\n", $lines);
}
function crontab_get_current(): string
{
$out = @shell_exec('crontab -l 2>/dev/null');
return $out !== null ? $out : '';
}
function crontab_is_installed(): bool
{
return str_contains(crontab_get_current(), DESKHUB_CRON_MARKER_START);
}
/** Entfernt einen ggf. vorhandenen DeskHub-Block aus einem Crontab-Text (Hilfsfunktion für install+remove). */
function crontab_strip_marker_block(string $content): string
{
$pattern = '/^' . preg_quote(DESKHUB_CRON_MARKER_START, '/') . '.*?^' . preg_quote(DESKHUB_CRON_MARKER_END, '/') . '\n?/ms';
return preg_replace($pattern, '', $content) ?? $content;
}
/** Schreibt einen kompletten neuen Crontab-Inhalt für den aktuellen Benutzer. */
function crontab_write(string $content): array
{
$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open('crontab -', $descriptors, $pipes);
if (!is_resource($proc)) {
return ['ok' => false, 'error' => 'crontab-Prozess konnte nicht gestartet werden.'];
}
fwrite($pipes[0], $content);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
return ['ok' => false, 'error' => trim($stderr) ?: trim($stdout) ?: "crontab beendete mit Fehlercode $code"];
}
return ['ok' => true, 'error' => ''];
}
/** Installiert/aktualisiert den DeskHub-Block in der Crontab des aktuellen Benutzers. */
function crontab_install(): array
{
$check = crontab_capability_check();
if (!$check['available']) {
return ['ok' => false, 'error' => $check['reason']];
}
$current = crontab_strip_marker_block(crontab_get_current());
$current = rtrim($current, "\n");
$new = ($current !== '' ? $current . "\n\n" : '') . crontab_marker_block() . "\n";
return crontab_write($new);
}
/** Entfernt den DeskHub-Block aus der Crontab, lässt alle anderen Einträge unangetastet. */
function crontab_uninstall(): array
{
$check = crontab_capability_check();
if (!$check['available']) {
return ['ok' => false, 'error' => $check['reason']];
}
$current = crontab_get_current();
if (!str_contains($current, DESKHUB_CRON_MARKER_START)) {
return ['ok' => true, 'error' => '']; // war gar nicht installiert
}
$new = crontab_strip_marker_block($current);
return crontab_write($new);
}
+83
View File
@@ -0,0 +1,83 @@
<?php
/**
* Datenbankverbindung (PDO).
* Unterstützt sowohl MySQL (Produktivbetrieb) als auch SQLite (lokale Tests),
* gesteuert über config/config.php -> db.driver
*/
require_once __DIR__ . '/../database/sqlite_schema.php';
function config(): array
{
static $config = null;
if ($config === null) {
$config = require __DIR__ . '/../config/config.php';
// Nur für Tests: einzelne Werte überschreiben (wirkt ausschließlich mit DESKHUB_TESTING=1)
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
$config = array_replace_recursive($config, $extra);
}
}
return $config;
}
function get_db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$cfg = config()['db'];
if ($cfg['driver'] === 'sqlite') {
$dbPath = $cfg['sqlite_path'];
$needInit = !file_exists($dbPath) || filesize($dbPath) === 0;
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA foreign_keys = ON;');
if ($needInit) {
$schemaFile = __DIR__ . '/../database/schema.sql';
if (!file_exists($schemaFile)) {
throw new \RuntimeException("Schema-Datei nicht gefunden: $schemaFile");
}
$sql = file_get_contents($schemaFile);
$pdo->exec(convert_schema_to_sqlite($sql));
}
} else {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $cfg['host'], $cfg['name']);
$pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], [
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4",
]);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Ausstehende Schema-Migrationen automatisch einspielen.
// Bereits angewendete werden übersprungen → Einstellungen bleiben erhalten.
$migrateFile = __DIR__ . '/../database/migrate.php';
if (file_exists($migrateFile)) {
require_once $migrateFile;
$migrationResult = run_migrations($pdo);
if ($migrationResult['errors']) {
throw new RuntimeException('Datenbankmigration fehlgeschlagen: ' . implode('; ', $migrationResult['errors']));
}
}
return $pdo;
}
/** Liefert NOW() als DATETIME-String, kompatibel zu beiden Treibern */
function db_now(): string
{
return date('Y-m-d H:i:s');
}
/** SQL-Datum-Differenz (Stunden addieren), kompatibel zu beiden Treibern -> wird in PHP berechnet */
function add_hours(string $datetime, int $hours): string
{
$ts = strtotime($datetime) + ($hours * 3600);
return date('Y-m-d H:i:s', $ts);
}
@@ -0,0 +1,227 @@
<?php
/**
* Gemeinsame Logik für E-Mail-Adressen (Konto, Abruf per IMAP, Versand per SMTP).
* Wird von staff/email_addresses.php (Weboberfläche) und api/staff/v1/email_addresses.php
* (Mobile-/Desktop-App) genutzt, damit beide exakt dasselbe tun.
*/
class EmailAddressException extends RuntimeException
{
public function __construct(string $message, private string $errorCode = 'INVALID', private int $httpStatus = 422)
{
parent::__construct($message);
}
public function errorCode(): string { return $this->errorCode; }
public function httpStatus(): int { return $this->httpStatus; }
}
/** Verknüpftes IMAP-Postfach einer Adresse (oder null). */
function email_address_imap(PDO $pdo, int $addressId): ?array
{
$stmt = $pdo->prepare('SELECT * FROM imap_accounts WHERE email_address_id = ? ORDER BY id LIMIT 1');
$stmt->execute([$addressId]);
return $stmt->fetch() ?: null;
}
/** Eigener SMTP-Zugang einer Absenderadresse (nur aktive Adresse mit aktiviertem SMTP), sonst null. */
function email_address_smtp_for(PDO $pdo, string $fromHeader): ?array
{
if (!preg_match('/<([^>]+)>/', $fromHeader, $m)) $m = [1 => $fromHeader];
$email = strtolower(trim($m[1]));
if ($email === '') return null;
$stmt = $pdo->prepare('SELECT smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc FROM email_addresses WHERE LOWER(email) = ? AND is_active = 1 AND smtp_enabled = 1');
$stmt->execute([$email]);
$row = $stmt->fetch();
return ($row && trim((string) $row['smtp_host']) !== '') ? $row : null;
}
/**
* Legt eine E-Mail-Adresse an oder aktualisiert sie – inklusive IMAP-Postfach und eigenem SMTP.
*
* @param array $in Schlüssel: email,label,department_id,priority_id,help_topic_id,is_default,is_active,notes,
* imap_enabled,imap_host,imap_port,imap_encryption,imap_username,imap_password,imap_folder,
* imap_after_fetch,imap_archive_folder,imap_spam_folder,
* smtp_enabled,smtp_host,smtp_port,smtp_encryption,smtp_username,smtp_password
* @return int ID der Adresse
* @throws EmailAddressException bei ungültigen Eingaben
*/
function email_address_save(PDO $pdo, int $id, array $in): int
{
$email = strtolower(trim((string) ($in['email'] ?? '')));
$label = trim((string) ($in['label'] ?? ''));
$deptId = (int) ($in['department_id'] ?? 0);
$prioId = isset($in['priority_id']) && $in['priority_id'] !== '' && $in['priority_id'] !== null ? (int) $in['priority_id'] : null;
$topicId = isset($in['help_topic_id']) && $in['help_topic_id'] !== '' && $in['help_topic_id'] !== null ? (int) $in['help_topic_id'] : null;
$isDefault = !empty($in['is_default']) ? 1 : 0;
if (array_key_exists('is_active', $in)) {
$isActive = !empty($in['is_active']) ? 1 : 0;
} else { // nicht angegeben: bisherigen Status behalten (neu = aktiv)
$cur = $pdo->prepare('SELECT is_active FROM email_addresses WHERE id = ?');
$cur->execute([$id]);
$isActive = $id ? (int) ($cur->fetchColumn() ?? 1) : 1;
}
$notes = trim((string) ($in['notes'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new EmailAddressException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
$chk = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ?');
$chk->execute([$deptId]);
if (!(int) $chk->fetchColumn()) throw new EmailAddressException('Bitte eine Abteilung wählen.', 'INVALID_DEPARTMENT');
if ($topicId !== null) {
$chk = $pdo->prepare('SELECT COUNT(*) FROM help_topics WHERE id = ?');
$chk->execute([$topicId]);
if (!(int) $chk->fetchColumn()) throw new EmailAddressException('Das gewählte Hilfethema existiert nicht.', 'INVALID_TOPIC');
}
$dup = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ? AND id <> ?');
$dup->execute([$email, $id]);
if ($dup->fetchColumn()) throw new EmailAddressException('Diese E-Mail-Adresse ist bereits angelegt.', 'DUPLICATE_EMAIL');
// IMAP
$existingImap = $id ? email_address_imap($pdo, $id) : null;
$imapOn = !empty($in['imap_enabled']) && $isActive;
$imap = [
'host' => trim((string) ($in['imap_host'] ?? '')),
'port' => (int) ($in['imap_port'] ?? 993) ?: 993,
'enc' => in_array((string) ($in['imap_encryption'] ?? ''), ['ssl', 'tls', 'none'], true) ? $in['imap_encryption'] : 'ssl',
'username' => trim((string) ($in['imap_username'] ?? '')) ?: $email,
'password' => trim((string) ($in['imap_password'] ?? '')),
'folder' => trim((string) ($in['imap_folder'] ?? '')) ?: 'INBOX',
'after' => in_array((string) ($in['imap_after_fetch'] ?? ''), ['seen', 'archive', 'delete'], true) ? $in['imap_after_fetch'] : 'seen',
'archive' => trim((string) ($in['imap_archive_folder'] ?? '')) ?: 'Processed',
'spam' => trim((string) ($in['imap_spam_folder'] ?? '')) ?: 'Junk',
];
if (!empty($in['imap_enabled'])) {
if ($imap['host'] === '') throw new EmailAddressException('Für den Abruf bitte einen Server (Host) angeben.', 'IMAP_HOST_REQUIRED');
if (!$existingImap && $imap['password'] === '') throw new EmailAddressException('Für den Abruf bitte ein Passwort angeben.', 'IMAP_PASSWORD_REQUIRED');
}
// SMTP
$existing = null;
if ($id) {
$s = $pdo->prepare('SELECT smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc FROM email_addresses WHERE id = ?');
$s->execute([$id]);
$existing = $s->fetch() ?: null;
}
$smtpOn = !empty($in['smtp_enabled']) ? 1 : 0;
$smtp = [
'host' => trim((string) ($in['smtp_host'] ?? '')),
'port' => (int) ($in['smtp_port'] ?? 587) ?: 587,
'enc' => in_array((string) ($in['smtp_encryption'] ?? ''), ['ssl', 'tls', 'none'], true) ? $in['smtp_encryption'] : 'tls',
'username' => trim((string) ($in['smtp_username'] ?? '')),
'password' => (string) ($in['smtp_password'] ?? ''),
];
if ($smtpOn && $smtp['host'] === '') throw new EmailAddressException('Für den eigenen Versand bitte einen SMTP-Server angeben.', 'SMTP_HOST_REQUIRED');
$smtpPassEnc = $smtp['password'] !== '' ? encrypt_secret($smtp['password']) : ($existing['smtp_password_enc'] ?? null);
if (!$smtpOn && $existing) {
// Eigener Versand ausgeschaltet: hinterlegte Zugangsdaten bleiben für später erhalten
$smtp = ['host' => (string) $existing['smtp_host'], 'port' => (int) $existing['smtp_port'], 'enc' => $existing['smtp_encryption'] ?: 'tls',
'username' => (string) $existing['smtp_username'], 'password' => ''];
}
if ($isDefault) {
$pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$deptId]);
}
$now = db_now();
if ($id === 0) {
$pdo->prepare(
'INSERT INTO email_addresses (email, label, department_id, priority_id, help_topic_id, is_default, is_active, notes,
smtp_enabled, smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$email, $label, $deptId, $prioId, $topicId, $isDefault, $isActive, $notes ?: null,
$smtpOn, $smtp['host'] ?: null, $smtp['port'], $smtp['enc'], $smtp['username'] ?: null, $smtpPassEnc, $now, $now]);
$id = (int) $pdo->lastInsertId();
} else {
$pdo->prepare(
'UPDATE email_addresses SET email=?, label=?, department_id=?, priority_id=?, help_topic_id=?, is_default=?, is_active=?, notes=?,
smtp_enabled=?, smtp_host=?, smtp_port=?, smtp_encryption=?, smtp_username=?, smtp_password_enc=?, updated_at=? WHERE id=?'
)->execute([$email, $label, $deptId, $prioId, $topicId, $isDefault, $isActive, $notes ?: null,
$smtpOn, $smtp['host'] ?: null, $smtp['port'], $smtp['enc'], $smtp['username'] ?: null, $smtpPassEnc, $now, $id]);
}
// Verknüpftes Postfach: Abteilung/Priorität/Hilfethema kommen aus dem Konto
if ($imapOn) {
if ($existingImap) {
$sql = 'UPDATE imap_accounts SET label=?, host=?, port=?, encryption=?, username=?, folder=?, after_fetch=?, archive_folder=?, spam_folder=?, department_id=?, priority_id=?, is_active=1';
$params = [$email, $imap['host'], $imap['port'], $imap['enc'], $imap['username'], $imap['folder'], $imap['after'], $imap['archive'], $imap['spam'], $deptId, $prioId];
if ($imap['password'] !== '') { $sql .= ', password_enc=?'; $params[] = encrypt_secret($imap['password']); }
$params[] = $existingImap['id'];
$pdo->prepare($sql . ' WHERE id=?')->execute($params);
} else {
$pdo->prepare(
'INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, department_id, priority_id, is_active, email_address_id, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)'
)->execute([$email, $imap['host'], $imap['port'], $imap['enc'], $imap['username'], encrypt_secret($imap['password']), $imap['folder'], $imap['after'], $imap['archive'], $imap['spam'], $deptId, $prioId, $id, $now]);
}
} elseif ($existingImap) {
$pdo->prepare('UPDATE imap_accounts SET is_active = 0, department_id = ?, priority_id = ? WHERE id = ?')->execute([$deptId, $prioId, $existingImap['id']]);
}
return $id;
}
/** Löscht eine Adresse samt Postfach (oder löst das Postfach, falls Tickets darauf verweisen). Gibt die gelöschte E-Mail zurück. */
function email_address_delete(PDO $pdo, int $id): string
{
$stmt = $pdo->prepare('SELECT email FROM email_addresses WHERE id = ?');
$stmt->execute([$id]);
$email = (string) ($stmt->fetchColumn() ?: $id);
if ($imap = email_address_imap($pdo, $id)) {
$used = $pdo->prepare('SELECT COUNT(*) FROM ticket_email_sources WHERE imap_account_id = ?');
$used->execute([$imap['id']]);
if ((int) $used->fetchColumn() > 0) {
$pdo->prepare('UPDATE imap_accounts SET is_active = 0, email_address_id = NULL WHERE id = ?')->execute([$imap['id']]);
} else {
$pdo->prepare('DELETE FROM imap_accounts WHERE id = ?')->execute([$imap['id']]);
}
}
$pdo->prepare('DELETE FROM email_addresses WHERE id = ?')->execute([$id]);
return $email;
}
/** Sendet eine Test-Mail von dieser Adresse an $recipient. Gibt die Erfolgsmeldung zurück. */
function email_address_send_test(PDO $pdo, int $id, string $recipient): string
{
$stmt = $pdo->prepare('SELECT email, label, smtp_enabled FROM email_addresses WHERE id = ?');
$stmt->execute([$id]);
$addr = $stmt->fetch();
if (!$addr) throw new EmailAddressException('E-Mail-Adresse nicht gefunden.', 'NOT_FOUND', 404);
$from = $addr['label'] ? "{$addr['label']} <{$addr['email']}>" : $addr['email'];
$via = $addr['smtp_enabled'] ? 'eigenen SMTP-Server' : 'globalen SMTP-Server';
$html = '<p>Dies ist eine Test-Mail von DeskHub.</p><p>Absender: ' . e($addr['email']) . '<br>Versand über: ' . e($via) . '</p>';
if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from)) {
throw new EmailAddressException('Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. ' . smtp_last_error(), 'MAIL_FAILED', 502);
}
return "Test-Mail von {$addr['email']} an {$recipient} gesendet (über {$via}).";
}
/** Prüft die IMAP-Verbindung des verknüpften Postfachs. Gibt die Erfolgsmeldung zurück. */
function email_address_test_imap(PDO $pdo, int $id): string
{
$acc = email_address_imap($pdo, $id);
if (!$acc) throw new EmailAddressException('Für diese Adresse ist kein Postfach hinterlegt. Bitte zuerst speichern.', 'NO_MAILBOX');
if (!function_exists('imap_open')) throw new EmailAddressException('PHP-IMAP-Erweiterung (php-imap) ist auf diesem Server nicht installiert.', 'NO_IMAP', 501);
$flag = match ($acc['encryption']) { 'ssl' => '/imap/ssl', 'tls' => '/imap/tls', default => '/imap/notls' };
$mbox = @imap_open('{' . $acc['host'] . ':' . $acc['port'] . $flag . '}' . $acc['folder'], $acc['username'], decrypt_secret($acc['password_enc']), 0, 1);
if (!$mbox) throw new EmailAddressException('Verbindung fehlgeschlagen: ' . imap_last_error(), 'IMAP_FAILED', 502);
$unseen = count(imap_search($mbox, 'UNSEEN') ?: []);
imap_close($mbox);
return "Verbindung erfolgreich. {$unseen} ungelesene Mail(s) im Postfach.";
}
/** Prüft die SMTP-Anmeldung dieser Adresse, ohne eine Mail zu senden. Gibt die Erfolgsmeldung zurück. */
function email_address_test_smtp(PDO $pdo, int $id): string
{
$stmt = $pdo->prepare('SELECT smtp_host, smtp_port, smtp_encryption, smtp_username, smtp_password_enc FROM email_addresses WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row || trim((string) $row['smtp_host']) === '') throw new EmailAddressException('Für diese Adresse ist kein eigener SMTP-Server hinterlegt. Bitte zuerst speichern.', 'NO_SMTP');
$timeout = 8;
$scheme = $row['smtp_encryption'] === 'ssl' ? 'ssl://' : '';
$errno = 0; $errstr = '';
$sock = @stream_socket_client($scheme . $row['smtp_host'] . ':' . (int) $row['smtp_port'], $errno, $errstr, $timeout);
if (!$sock) throw new EmailAddressException("Verbindung zu {$row['smtp_host']}:{$row['smtp_port']} fehlgeschlagen: {$errstr}", 'SMTP_FAILED', 502);
$banner = fgets($sock, 512);
fclose($sock);
if (!is_string($banner) || strpos($banner, '220') !== 0) throw new EmailAddressException('Der Server hat keine SMTP-Begrüßung gesendet.', 'SMTP_FAILED', 502);
return "SMTP-Server {$row['smtp_host']}:{$row['smtp_port']} ist erreichbar. Die Anmeldung wird beim Versenden der Test-Mail geprüft.";
}
@@ -0,0 +1,545 @@
<?php
$footerPages = get_db()->query("SELECT title, slug FROM site_pages WHERE is_active=1 AND show_in_nav=1 ORDER BY sort_order, title")->fetchAll();
$privacyLabel = get_setting('footer_privacy_label', 'Datenschutz');
$privacyUrl = get_setting('footer_privacy_url', '');
$privacyHtml = get_setting('footer_privacy_html', '');
$imprintLabel = get_setting('footer_imprint_label', 'Impressum');
$imprintUrl = get_setting('footer_imprint_url', '');
$imprintHtml = get_setting('footer_imprint_html', '');
$hasPrivacy = $privacyUrl || $privacyHtml;
$hasImprint = $imprintUrl || $imprintHtml;
$hasPopup = (!$privacyUrl && $privacyHtml) || (!$imprintUrl && $imprintHtml);
?>
</div><!-- /.page-content -->
<?php if ($hasPopup): ?>
<!-- Popup-Overlay für Datenschutz / Impressum -->
<div id="legal-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9999; overflow-y:auto; padding:40px 16px">
<div style="background:#fff; max-width:760px; margin:0 auto; border-radius:10px; padding:32px 36px; position:relative; font-size:.9rem; line-height:1.7">
<button onclick="closeLegalPopup()" style="position:absolute; top:14px; right:18px; background:none; border:none; font-size:1.4rem; cursor:pointer; color:#6b7280">&times;</button>
<div id="legal-popup-content"></div>
</div>
</div>
<script>
function openLegalPopup(html) {
document.getElementById('legal-popup-content').innerHTML = html;
document.getElementById('legal-overlay').style.display = 'block';
document.body.style.overflow = 'hidden';
}
function closeLegalPopup() {
document.getElementById('legal-overlay').style.display = 'none';
document.body.style.overflow = '';
}
document.getElementById('legal-overlay').addEventListener('click', function(e){
if (e.target === this) closeLegalPopup();
});
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeLegalPopup(); });
</script>
<?php endif; ?>
<footer class="public-footer">
<nav style="margin-bottom:8px; display:flex; gap:20px; justify-content:center; flex-wrap:wrap">
<?php if ($footerPages): ?>
<?php foreach ($footerPages as $fp): ?>
<a href="/page.php?slug=<?= e($fp['slug']) ?>" style="color:#6b7280; font-size:.78rem"><?= e($fp['title']) ?></a>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($hasPrivacy): ?>
<?php if ($privacyUrl): ?>
<a href="<?= e($privacyUrl) ?>" target="_blank" rel="noopener" style="color:#6b7280; font-size:.78rem"><?= e($privacyLabel) ?></a>
<?php else: ?>
<a href="#" onclick="openLegalPopup(<?= htmlspecialchars(json_encode($privacyHtml), ENT_QUOTES) ?>); return false;" style="color:#6b7280; font-size:.78rem"><?= e($privacyLabel) ?></a>
<?php endif; ?>
<?php endif; ?>
<?php if ($hasImprint): ?>
<?php if ($imprintUrl): ?>
<a href="<?= e($imprintUrl) ?>" target="_blank" rel="noopener" style="color:#6b7280; font-size:.78rem"><?= e($imprintLabel) ?></a>
<?php else: ?>
<a href="#" onclick="openLegalPopup(<?= htmlspecialchars(json_encode($imprintHtml), ENT_QUOTES) ?>); return false;" style="color:#6b7280; font-size:.78rem"><?= e($imprintLabel) ?></a>
<?php endif; ?>
<?php endif; ?>
</nav>
Copyright &copy; <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> &ndash; Alle Rechte vorbehalten.
</footer>
<?php if (get_setting('live_chat_enabled', '0') === '1'): ?>
<!-- Live-Chat-Widget: kann jederzeit unter Einstellungen → Allgemein wieder deaktiviert werden -->
<style>
@keyframes lc-pop { 0%{transform:scale(.8);opacity:0} 100%{transform:scale(1);opacity:1} }
@keyframes lc-pulse { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }
#lc-bubble.lc-unread{animation:lc-pulse 1.4s ease-in-out infinite}
.lc-msg-row{animation:lc-pop .18s ease-out}
</style>
<div id="lc-bubble" style="position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:1.5rem;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.25);z-index:9998;user-select:none">
💬
<span id="lc-badge" style="display:none;position:absolute;top:-3px;right:-3px;background:#dc2626;color:#fff;border-radius:10px;min-width:20px;height:20px;font-size:.68rem;font-weight:700;align-items:center;justify-content:center;padding:0 5px;border:2px solid #fff"></span>
</div>
<div id="lc-panel" style="display:none;position:fixed;bottom:88px;right:20px;width:340px;max-width:calc(100vw - 32px);max-height:min(560px, calc(100vh - 120px));background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:9998;overflow:hidden;flex-direction:column">
<div style="background:var(--primary);color:#fff;padding:14px 16px;flex-shrink:0">
<strong style="font-size:.92rem">💬 Live-Chat</strong>
<span id="lc-close" style="cursor:pointer;font-size:1.2rem;line-height:1;opacity:.85;float:right">&times;</span>
<div style="font-size:.72rem;opacity:.9;margin-top:3px;display:flex;align-items:center;gap:5px">
<span id="lc-status-dot" style="width:7px;height:7px;border-radius:50%;background:#9ca3af;display:inline-block;flex-shrink:0"></span>
<span id="lc-status-text">Verfügbarkeit wird geprüft…</span>
</div>
</div>
<div id="lc-messages" style="flex:1;overflow-y:auto;padding:12px 14px;display:none;background:#f7f8fa"></div>
<div id="lc-typing" style="display:none;padding:0 14px 8px;font-size:.76rem;color:#9ca3af;background:#f7f8fa;font-style:italic">Mitarbeiter tippt …</div>
<div id="lc-ended" style="display:none;padding:16px;font-size:.85rem;color:#6b7280;text-align:center">
Dieser Chat wurde beendet.<br><a href="#" id="lc-restart" style="color:var(--primary);font-weight:600">Neuen Chat starten</a>
</div>
<div id="lc-converted" style="display:none;padding:20px 16px;font-size:.85rem;color:#374151;text-align:center">
<div style="font-size:1.6rem;margin-bottom:8px">✅</div>
<div style="font-weight:600;margin-bottom:4px">Vielen Dank! Ihr Ticket wurde erstellt.</div>
<div id="lc-converted-number" style="color:#6b7280;margin-bottom:8px"></div>
<div style="color:#6b7280">Da gerade niemand direkt verfügbar ist, antworten wir Ihnen per E-Mail, sobald wir Ihre Anfrage bearbeitet haben.</div>
</div>
<form id="lc-start-form" style="padding:14px 16px;display:flex;flex-direction:column;gap:8px">
<p style="margin:0 0 2px;font-size:.83rem;color:#374151">👋 Hallo! Wie können wir Ihnen helfen?</p>
<div id="lc-unavailable-hint" style="display:none;background:#fffbeb;border:1px solid #fde68a;color:#92400e;font-size:.78rem;padding:8px 10px;border-radius:6px">Gerade ist niemand verfügbar – wir antworten aber, sobald wir wieder da sind.</div>
<div id="lc-error" style="display:none;color:#dc2626;font-size:.78rem"></div>
<input type="text" id="lc-name" placeholder="Ihr Name" required style="padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem">
<input type="email" id="lc-email" placeholder="Ihre E-Mail-Adresse" required style="padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem">
<div style="position:absolute;left:-9999px" aria-hidden="true"><input type="text" id="lc-website" tabindex="-1" autocomplete="off"></div>
<textarea id="lc-message" placeholder="Ihre Nachricht…" required rows="3" style="padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem;resize:vertical"></textarea>
<div style="display:flex;align-items:center;gap:8px">
<label for="lc-start-attachment" style="cursor:pointer;font-size:.78rem;color:#374151;display:flex;align-items:center;gap:4px;padding:5px 9px;border:1px solid #d1d5db;border-radius:6px">📎 Datei</label>
<input type="file" id="lc-start-attachment" style="position:absolute;left:-9999px" accept="image/*,.pdf,.doc,.docx,.txt">
<span id="lc-start-attachment-name" style="font-size:.76rem;color:#6b7280"></span>
</div>
<button type="submit" class="btn" id="lc-start-btn" style="justify-content:center">Chat starten</button>
</form>
<form id="lc-send-form" style="display:none;padding:10px 12px;gap:8px;border-top:1px solid #eee;flex-shrink:0;flex-direction:column">
<div id="lc-send-attachment-name" style="display:none;font-size:.76rem;color:#6b7280;padding:0 2px"></div>
<div style="display:flex;gap:8px">
<label for="lc-send-attachment" style="cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:36px;border:1px solid #d1d5db;border-radius:6px;font-size:1rem">📎</label>
<input type="file" id="lc-send-attachment" style="position:absolute;left:-9999px" accept="image/*,.pdf,.doc,.docx,.txt">
<input type="text" id="lc-send-input" placeholder="Nachricht…" style="flex:1;padding:9px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:.85rem">
<button type="submit" class="btn" id="lc-send-btn" style="padding:8px 14px">Senden</button>
</div>
</form>
</div>
<script>
(function () {
var CSRF = <?= json_encode(csrf_token()) ?>;
var bubble = document.getElementById('lc-bubble');
var badge = document.getElementById('lc-badge');
var panel = document.getElementById('lc-panel');
var closeBtn = document.getElementById('lc-close');
var messagesEl = document.getElementById('lc-messages');
var typingEl = document.getElementById('lc-typing');
var endedEl = document.getElementById('lc-ended');
var convertedEl = document.getElementById('lc-converted');
var convertedNumberEl = document.getElementById('lc-converted-number');
var startForm = document.getElementById('lc-start-form');
var sendForm = document.getElementById('lc-send-form');
var sendInput = document.getElementById('lc-send-input');
var errorEl = document.getElementById('lc-error');
var statusDot = document.getElementById('lc-status-dot');
var statusText = document.getElementById('lc-status-text');
var pollTimer = null;
var renderedIds = {};
var opened = false;
var unreadCount = 0;
var lastTypingPingAt = 0;
var caughtUp = false; // wird nach dem ersten Laden true – erst danach lösen neue Nachrichten Ton/Badge aus
function playNotifySound() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 660;
gain.gain.setValueAtTime(0.12, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.start(); osc.stop(ctx.currentTime + 0.3);
} catch (e) {}
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
function setUnread(n) {
unreadCount = n;
if (n > 0 && !opened) {
badge.textContent = n > 9 ? '9+' : String(n);
badge.style.display = 'flex';
bubble.classList.add('lc-unread');
} else {
badge.style.display = 'none';
bubble.classList.remove('lc-unread');
}
}
function updateStatus(available) {
if (available === false) {
statusDot.style.background = '#9ca3af';
statusText.textContent = 'Gerade nicht erreichbar';
} else {
statusDot.style.background = '#22c55e';
statusText.textContent = 'Wir sind für Sie da';
}
}
function setStaffTyping(isTyping) {
typingEl.style.display = isTyping ? 'block' : 'none';
}
// Signalisiert dem Mitarbeiter "Kunde tippt gerade" – höchstens alle 3s, damit nicht
// bei jedem einzelnen Tastenanschlag eine Anfrage rausgeht. Fehler werden bewusst
// ignoriert (verpasstes Signal ist folgenlos, siehe live_chat.php Aktion "typing").
function pingTyping() {
var now = Date.now();
if (now - lastTypingPingAt < 3000) return;
lastTypingPingAt = now;
var body = new URLSearchParams();
body.set('action', 'typing');
body.set('csrf_token', CSRF);
fetch('/live_chat.php', { method: 'POST', body: body }).catch(function () {});
}
if (sendInput) sendInput.addEventListener('input', pingTyping);
/** Hängt nur wirklich neue Nachrichten an (statt alles neu zu rendern) – dadurch
* bleibt die Scroll-Position stabil und neue Blasen können sanft einblenden. */
function appendNewMessages(messages) {
var appended = 0;
messages.forEach(function (m) {
if (renderedIds[m.id]) return;
renderedIds[m.id] = true;
appended++;
var row = document.createElement('div');
row.className = 'lc-msg-row';
row.style.marginBottom = '4px';
row.style.display = 'flex';
row.style.flexDirection = 'column';
row.style.alignItems = m.authorType === 'customer' ? 'flex-end' : 'flex-start';
if (m.authorType === 'staff' && m.authorName) {
var nameEl = document.createElement('div');
nameEl.style.fontSize = '.68rem';
nameEl.style.color = '#9ca3af';
nameEl.style.margin = '6px 2px 2px';
nameEl.textContent = m.authorName;
row.appendChild(nameEl);
}
if (m.message) {
var bubbleEl = document.createElement('div');
bubbleEl.style.maxWidth = '78%';
bubbleEl.style.padding = '8px 11px';
bubbleEl.style.borderRadius = '10px';
bubbleEl.style.fontSize = '.85rem';
bubbleEl.style.whiteSpace = 'pre-wrap';
bubbleEl.style.wordBreak = 'break-word';
bubbleEl.style.marginBottom = '2px';
if (m.authorType === 'customer') {
bubbleEl.style.background = 'var(--primary)';
bubbleEl.style.color = '#fff';
} else {
bubbleEl.style.background = '#fff';
bubbleEl.style.border = '1px solid #e5e7eb';
bubbleEl.style.color = '#1f2937';
}
bubbleEl.textContent = m.message; // textContent = automatisch XSS-sicher, kein HTML-Parsing
row.appendChild(bubbleEl);
}
var imgExts = ['jpg','jpeg','png','gif','webp'];
(m.attachments || []).forEach(function (a) {
var ext = (a.name.split('.').pop() || '').toLowerCase();
if (imgExts.indexOf(ext) !== -1) {
var link = document.createElement('a');
link.href = '/download.php?id=' + a.id + '&preview=1';
link.target = '_blank';
link.style.cssText = 'display:block;margin-bottom:4px;border-radius:8px;overflow:hidden;max-width:180px';
var img = document.createElement('img');
img.src = '/download.php?id=' + a.id + '&preview=1';
img.alt = a.name;
img.style.cssText = 'display:block;max-width:100%;max-height:160px;object-fit:cover';
link.appendChild(img);
row.appendChild(link);
} else {
var fileLink = document.createElement('a');
fileLink.href = '/download.php?id=' + a.id;
fileLink.target = '_blank';
fileLink.style.cssText = 'display:inline-flex;align-items:center;gap:5px;font-size:.78rem;padding:5px 10px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;text-decoration:none;color:#1f2937;margin-bottom:4px';
fileLink.textContent = '📎 ' + a.name;
row.appendChild(fileLink);
}
});
var timeEl = document.createElement('div');
timeEl.style.fontSize = '.65rem';
timeEl.style.color = '#9ca3af';
timeEl.textContent = (m.createdAt || '').substring(11, 16);
row.appendChild(timeEl);
messagesEl.appendChild(row);
// Beim allerersten Laden (Historie "nachladen") nie Ton/Badge auslösen – nur für
// Nachrichten, die tatsächlich NACH diesem Zeitpunkt neu reinkommen.
if (caughtUp && m.authorType === 'staff') {
if (!opened) setUnread(unreadCount + 1);
playNotifySound();
}
});
if (appended) messagesEl.scrollTop = messagesEl.scrollHeight;
caughtUp = true;
}
function showActiveChat(data) {
// Wurde der Chat (z.B. im Backend) inzwischen beendet, direkt den "Beendet"-Zustand
// zeigen, statt ein scheinbar aktives Eingabefeld für ein bereits geschlossenes Ticket.
// "Beendet" hat Vorrang vor "Ticket erstellt" – ein abgeschlossenes Ticket ist der
// endgültigere Zustand, egal ob es vorher ein normaler Chat war oder nicht.
if (data.status === 'closed') {
showEnded();
return;
}
// War gerade niemand verfügbar, wurde aus dem Chat ein ganz normales Ticket (kein
// Live-Chat mehr) – statt der eigenen Nachricht als Chat-Bubble zeigen wir dem Kunden
// eine klare Bestätigung, dass sein Anliegen als Ticket angekommen ist. Live-Polling
// lohnt hier nicht: das Ticket wird nicht nachträglich wieder zum Chat.
if (data.convertedToTicket) {
showConverted(data);
return;
}
startForm.style.display = 'none';
endedEl.style.display = 'none';
convertedEl.style.display = 'none';
messagesEl.style.display = 'block';
sendForm.style.display = 'flex';
updateStatus(data.staffAvailable);
setStaffTyping(!!data.staffTyping);
appendNewMessages(data.messages || []);
startPolling();
}
function showConverted(data) {
startForm.style.display = 'none';
messagesEl.style.display = 'none';
sendForm.style.display = 'none';
endedEl.style.display = 'none';
convertedEl.style.display = 'block';
convertedNumberEl.textContent = data.number ? ('Ticket #' + data.number) : '';
setStaffTyping(false);
stopPolling();
}
function showEnded() {
startForm.style.display = 'none';
messagesEl.style.display = 'none';
sendForm.style.display = 'none';
convertedEl.style.display = 'none';
endedEl.style.display = 'block';
setStaffTyping(false);
stopPolling();
}
function showStartForm(staffAvailable) {
startForm.style.display = 'flex';
messagesEl.style.display = 'none';
sendForm.style.display = 'none';
endedEl.style.display = 'none';
convertedEl.style.display = 'none';
updateStatus(staffAvailable);
setStaffTyping(false);
document.getElementById('lc-unavailable-hint').style.display = (staffAvailable === false) ? 'block' : 'none';
stopPolling();
}
function updateCsrf(data) {
if (data && data.csrfToken) CSRF = data.csrfToken;
}
function checkState() {
fetch('/live_chat.php?action=state')
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
// Wurde der vorherige Chat bereits (z.B. beim letzten Besuch, ohne dass der Kunde
// live dabei war) beendet ODER zu einem normalen Ticket, nicht extra die "Beendet"-
// bzw. "Ticket erstellt"-Meldung zeigen – der Kunde müsste sonst erst auf "Neuen
// Chat starten" klicken. Stattdessen beim erneuten Laden der Seite (bzw. erneuten
// Öffnen des Bubbles) direkt wieder das normale Startformular anzeigen, damit er bei
// Bedarf sofort einen neuen Chat/eine neue Anfrage starten kann. Passiert das
// Beenden/Umwandeln dagegen WÄHREND der Kunde live zusieht (Panel offen), bleibt die
// jeweilige Meldung bestehen (siehe startPolling() bzw. showActiveChat()) – da ist
// sie sinnvolles Feedback.
if (data.active && (data.status === 'closed' || data.convertedToTicket)) {
renderedIds = {};
messagesEl.innerHTML = '';
showStartForm(data.staffAvailable);
return;
}
if (data.active) showActiveChat(data);
else showStartForm(data.staffAvailable);
})
.catch(function () { showStartForm(); });
}
// Hält die Verfügbarkeitsanzeige ("Wir sind für Sie da" / "Gerade nicht erreichbar") live,
// unabhängig davon, ob gerade ein Chat läuft oder nur das Startformular gezeigt wird – ändert
// ein Mitarbeiter seinen Status, wird das hier binnen weniger Sekunden sichtbar, ohne dass der
// Kunde die Seite neu laden muss. Rührt bewusst nur Status/Hinweisbanner an, nicht das Formular.
function pollAvailability() {
fetch('/live_chat.php?action=state')
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
updateStatus(data.staffAvailable);
var hint = document.getElementById('lc-unavailable-hint');
if (hint && startForm.style.display !== 'none') {
hint.style.display = (data.staffAvailable === false) ? 'block' : 'none';
}
})
.catch(function () {});
}
setInterval(pollAvailability, 15000);
// Läuft dauerhaft im Hintergrund weiter (auch bei eingeklapptem Widget), damit Antworten
// per Ton + Zähler-Badge am Bubble-Icon auffallen, statt erst beim nächsten Öffnen bemerkt zu werden.
function startPolling() {
stopPolling();
pollTimer = setInterval(function () {
fetch('/live_chat.php?action=poll')
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
if (!data.active) { stopPolling(); return; }
if (data.status === 'closed') { if (opened) showEnded(); stopPolling(); return; }
if (opened) { updateStatus(data.staffAvailable); setStaffTyping(!!data.staffTyping); }
appendNewMessages(data.messages || []);
})
.catch(function () {});
}, 8000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function setBusy(btn, busy, busyLabel, idleLabel) {
btn.disabled = busy;
btn.textContent = busy ? busyLabel : idleLabel;
}
bubble.addEventListener('click', function () {
opened = panel.style.display !== 'flex';
panel.style.display = opened ? 'flex' : 'none';
if (opened) {
setUnread(0);
checkState();
}
});
closeBtn.addEventListener('click', function () {
opened = false;
panel.style.display = 'none';
});
// Dateiauswahl anzeigen/zurücksetzen – gemeinsam für Start- und Send-Formular.
function wireAttachmentPreview(inputEl, nameEl, isSpan) {
inputEl.addEventListener('change', function () {
var file = inputEl.files[0];
if (!file) { nameEl.textContent = ''; if (!isSpan) nameEl.style.display = 'none'; return; }
if (file.size > 15 * 1024 * 1024) {
showError('Die Datei ist zu groß (max. 15 MB).');
inputEl.value = '';
nameEl.textContent = '';
if (!isSpan) nameEl.style.display = 'none';
return;
}
nameEl.textContent = '📎 ' + file.name;
if (!isSpan) nameEl.style.display = 'block';
});
}
var startAttachment = document.getElementById('lc-start-attachment');
var startAttachmentName = document.getElementById('lc-start-attachment-name');
var sendAttachment = document.getElementById('lc-send-attachment');
var sendAttachmentName = document.getElementById('lc-send-attachment-name');
wireAttachmentPreview(startAttachment, startAttachmentName, true);
wireAttachmentPreview(sendAttachment, sendAttachmentName, false);
startForm.addEventListener('submit', function (e) {
e.preventDefault();
errorEl.style.display = 'none';
var startBtn = document.getElementById('lc-start-btn');
var body = new FormData();
body.set('action', 'start');
body.set('csrf_token', CSRF);
body.set('name', document.getElementById('lc-name').value);
body.set('email', document.getElementById('lc-email').value);
body.set('message', document.getElementById('lc-message').value);
body.set('website', document.getElementById('lc-website').value);
if (startAttachment.files[0]) body.set('attachment', startAttachment.files[0]);
setBusy(startBtn, true, 'Wird gestartet…', 'Chat starten');
fetch('/live_chat.php', { method: 'POST', body: body })
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
if (data.error) { showError(data.error); return; }
document.getElementById('lc-message').value = '';
startAttachment.value = '';
startAttachmentName.textContent = '';
showActiveChat(data);
})
.catch(function () { showError('Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'); })
.finally(function () { setBusy(startBtn, false, 'Wird gestartet…', 'Chat starten'); });
});
sendForm.addEventListener('submit', function (e) {
e.preventDefault();
var input = document.getElementById('lc-send-input');
var sendBtn = document.getElementById('lc-send-btn');
var text = input.value.trim();
var file = sendAttachment.files[0];
if (!text && !file) return;
var body = new FormData();
body.set('action', 'send');
body.set('csrf_token', CSRF);
body.set('message', text);
if (file) body.set('attachment', file);
input.value = '';
setBusy(sendBtn, true, '…', 'Senden');
fetch('/live_chat.php', { method: 'POST', body: body })
.then(function (r) { return r.json(); })
.then(function (data) {
updateCsrf(data);
if (data.ended) { showEnded(); return; }
if (data.error) { return; }
sendAttachment.value = '';
sendAttachmentName.textContent = '';
sendAttachmentName.style.display = 'none';
appendNewMessages(data.messages || []);
})
.catch(function () {})
.finally(function () { setBusy(sendBtn, false, '…', 'Senden'); input.focus(); });
});
document.getElementById('lc-restart').addEventListener('click', function (e) {
e.preventDefault();
renderedIds = {};
messagesEl.innerHTML = '';
showStartForm();
});
// Beim Laden der Seite bereits prüfen, ob ein Chat weiterläuft, damit Antworten auch
// erkannt werden (Ton + Badge), ohne dass der Kunde das Widget vorher öffnen muss.
checkState();
})();
</script>
<?php endif; ?>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
<?php
/** @var array|null $user (current_user(), wird von der aufrufenden Seite gesetzt) */
$user = current_user();
$activePage = $activePage ?? '';
$logo = get_setting('logo_path');
$displayName = get_setting('display_name', config()['site']['name']);
// Wartungsmodus: Wenn der Helpdesk-Status auf "offline" steht, wird dem
// Kundenportal eine Wartungsmeldung angezeigt. Der Mitarbeiterbereich ist
// davon nicht betroffen (eigenes Header-Template), damit Admins den Status
// jederzeit wieder zurücksetzen können.
if (get_setting('helpdesk_status', 'online') === 'offline') {
$offlineMessage = get_setting('offline_message', 'Das Supportcenter ist derzeit nicht erreichbar. Bitte versuchen Sie es später erneut.');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($displayName) ?> – Wartungsmodus</title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">
<link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
</script>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card" style="text-align:center">
<?php require __DIR__ . '/brand_block.php'; ?>
<h1>Vorübergehend nicht erreichbar</h1>
<p class="sub"><?= nl2br(e($offlineMessage)) ?></p>
<p style="margin-top:18px;font-size:.8rem"><a href="/staff/login.php" class="muted">Mitarbeiter-Login →</a></p>
</div>
</div>
</body>
</html>
<?php
exit;
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Verhindert, dass Safari/iOS Zahlenfolgen im Live-Chat-Widget automatisch als
Telefonnummern erkennt und farbig/als Link darstellt (z.B. Postleitzahlen, Codes). -->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<title><?= e($pageTitle ?? $displayName) ?> – <?= e($displayName) ?></title>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<?php $customThemeStylesheet = active_custom_theme_stylesheet(); ?>
<?php if ($customThemeStylesheet): ?>
<link rel="stylesheet" href="<?= e($customThemeStylesheet) ?>">
<?php endif; ?>
<link rel="manifest" href="/manifest.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
</script>
</head>
<?php $globalBanner = get_setting('banner_path'); ?>
<body>
<?php if (!$globalBanner && !$customThemeStylesheet): ?>
<style>
.auth-wrap { background: linear-gradient(135deg, #16202a 0%, #20303d 55%, #16202a 100%) !important; }
.auth-wrap::before { background-image: radial-gradient(rgba(255,255,255,.06) 1px, transparent 1px); background-size: 24px 24px; }
.hero-search { background: linear-gradient(135deg, #16202a 0%, #20303d 55%, #16202a 100%) !important; }
.hero-search::before { background-image: radial-gradient(rgba(255,255,255,.07) 1px, transparent 1px); background-size: 24px 24px; }
</style>
<?php endif; ?>
<header class="site-header">
<div class="topbar-thin">
<span class="welcome">Willkommen, <?= $user ? e($user['name']) : 'Gast' ?></span>
<nav>
<a href="/home.php" class="<?= $activePage==='home'?'active':'' ?>">Supportcenter-Startseite</a>
<a href="/ticket_new.php" class="<?= $activePage==='new'?'active':'' ?>">Neues Ticket eröffnen</a>
<?php if ($user): ?>
<a href="/dashboard.php" class="<?= $activePage==='dashboard'?'active':'' ?>">Meine Tickets</a>
<?php else: ?>
<a href="/ticket_lookup.php" class="<?= $activePage==='lookup'?'active':'' ?>">Ticketstatus überprüfen</a>
<?php endif; ?>
<?php if (get_setting('kb_enabled', '1') === '1'): ?>
<a href="/kb.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a>
<?php endif; ?>
<?php if ($user): ?>
<a href="/logout.php">Abmelden</a>
<?php else: ?>
<a href="/login.php" class="<?= $activePage==='login'?'active':'' ?>">Anmelden</a>
<?php endif; ?>
</nav>
</div>
<div class="logo-row" style="display:flex; align-items:center; gap:12px">
<a href="/home.php" style="line-height:0; flex-shrink:0">
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e($displayName) ?>">
<?php else: ?>
<span class="text-brand">Desk<span>Hub</span></span>
<?php endif; ?>
</a>
<?php if ($logo && $displayName): ?>
<span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
<?php endif; ?>
</div>
</header>
<?php if (!empty($showHeroBanner)): ?>
<?php
$kbEnabledForHero = get_setting('kb_enabled', '1') === '1';
$heroEyebrow = $heroEyebrow ?? 'Supportcenter';
$heroTitle = $heroTitle ?? ($kbEnabledForHero ? 'Wie können wir helfen?' : 'Willkommen im Supportcenter');
$heroSubtitle = $heroSubtitle ?? ($kbEnabledForHero
? 'Wissensdatenbank durchsuchen oder direkt ein Ticket eröffnen – wir antworten so schnell wie möglich.'
: 'Eröffnen Sie ein Ticket – wir antworten so schnell wie möglich.');
$heroSearchPlaceholder = $heroSearchPlaceholder ?? 'z. B. Passwort zurücksetzen, Rechnung, Versand …';
$heroSearchValue = $heroSearchValue ?? '';
$banner = !empty($showHeroImage) ? get_setting('banner_path') : null;
?>
<div class="hero-search<?= $banner ? ' has-image' : '' ?>"<?= $banner ? ' style="background-image:url(' . e($banner) . ')"' : '' ?>>
<div class="hero-inner">
<span class="eyebrow"><?= e($heroEyebrow) ?></span>
<h1><?= e($heroTitle) ?></h1>
<p class="sub"><?= e($heroSubtitle) ?></p>
<?php if ($kbEnabledForHero): ?>
<form method="get" action="/kb.php" class="search-form">
<input type="search" name="q" placeholder="<?= e($heroSearchPlaceholder) ?>" value="<?= e($heroSearchValue) ?>">
<button type="submit">Suchen</button>
</form>
<div class="quick-links">oder direkt <a href="/ticket_new.php">ein Ticket eröffnen</a></div>
<?php else: ?>
<div class="btn-row" style="justify-content:center">
<a href="/ticket_new.php" class="btn">Neues Ticket eröffnen</a>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<div class="page-content">
<?php foreach (get_flashes() as $f): ?>
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<?php endforeach; ?>
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* Systemüberwachung: erkennt, wenn der Mail-Abruf oder die Hintergrundjobs nicht mehr laufen,
* benachrichtigt Administratoren per E-Mail und liefert eine Liste von Problemen für die
* Weboberfläche und die Apps (Banner).
*/
/** Ab so vielen Fehlversuchen in Folge gilt ein Postfach als ausgefallen (bei 5-Minuten-Takt ≈ 15 Min.). */
const HEALTH_FAIL_THRESHOLD = 3;
/** Wiederholte Alarm-Mail frühestens nach so vielen Sekunden, solange der Fehler anhält. */
const HEALTH_ALERT_REPEAT = 86400;
/** E-Mail-Adressen aller aktiven Administratoren. */
function health_admin_emails(PDO $pdo): array
{
return $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
/**
* Hält das Ergebnis eines Abrufs für ein Postfach fest. $error = null bedeutet Erfolg.
* Verschickt eine Alarm-Mail an die Administratoren, wenn der Fehler anhält, und eine
* Entwarnung, wenn das Postfach wieder erreichbar ist.
*/
function health_record_mail_result(PDO $pdo, array $account, ?string $error): void
{
$now = db_now();
$label = $account['label'] ?: $account['username'];
if ($error === null) {
$wasAlerted = !empty($account['alert_sent_at']);
$pdo->prepare('UPDATE imap_accounts SET last_success_at = ?, last_error = NULL, fail_count = 0, alert_sent_at = NULL WHERE id = ?')->execute([$now, $account['id']]);
if ($wasAlerted) {
foreach (health_admin_emails($pdo) as $to) {
send_mail($to, "DeskHub: Postfach „{$label}“ ist wieder erreichbar",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> funktioniert wieder.</p>');
}
}
return;
}
$fails = (int) ($account['fail_count'] ?? 0) + 1;
$pdo->prepare('UPDATE imap_accounts SET last_error = ?, fail_count = ? WHERE id = ?')->execute([mb_substr($error, 0, 500), $fails, $account['id']]);
$lastAlert = !empty($account['alert_sent_at']) ? strtotime($account['alert_sent_at']) : 0;
if ($fails >= HEALTH_FAIL_THRESHOLD && (time() - $lastAlert) >= HEALTH_ALERT_REPEAT) {
$since = $account['last_success_at'] ? date('d.m.Y H:i', strtotime($account['last_success_at'])) : 'noch nie';
$sent = false;
foreach (health_admin_emails($pdo) as $to) {
$sent = send_mail($to, "DeskHub: Mail-Abruf für „{$label}“ fehlgeschlagen",
'<p>Der Mail-Abruf für das Postfach <strong>' . e($label) . '</strong> (' . e($account['host']) . ') schlägt seit '
. $fails . ' Versuchen fehl. Eingehende Mails werden in dieser Zeit <strong>nicht</strong> als Tickets angelegt.</p>'
. '<p>Letzter Fehler: <code>' . e($error) . '</code><br>Letzter erfolgreicher Abruf: ' . e($since) . '</p>'
. '<p>Bitte Zugangsdaten und Server unter E-Mail-Adressen → Entferntes Postfach prüfen.</p>') || $sent;
}
// Auch ohne versendete Mail als „gemeldet“ markieren, damit nicht bei jedem Lauf neu versucht wird
$pdo->prepare('UPDATE imap_accounts SET alert_sent_at = ? WHERE id = ?')->execute([$now, $account['id']]);
}
}
/** Merkt sich den Erfolg bzw. Misserfolg des letzten Mailversands (für die Statusanzeige). */
function health_record_send_result(bool $ok, string $error = ''): void
{
try {
if ($ok) { set_setting('mail_send_last_ok', db_now()); return; }
set_setting('mail_send_last_error', db_now() . '|' . mb_substr($error, 0, 300));
} catch (Throwable $e) {
// Überwachung darf den Versand nie beeinträchtigen
}
}
/**
* Liefert die aktuellen Probleme als Liste von ['level' => 'error'|'warning', 'code' => ..., 'message' => ...].
* Leer = alles in Ordnung. Rein lesend, daher auch bei jedem Seitenaufruf aufrufbar.
*/
function system_health(PDO $pdo): array
{
$issues = [];
// Das zuletzt geprüfte Backup lässt sich nicht wiederherstellen
$bk = json_decode((string) get_setting('backup_last_check', ''), true);
if (is_array($bk) && array_key_exists('ok', $bk) && !$bk['ok']) {
$issues[] = ['level' => 'error', 'code' => 'backup_broken', 'message' => 'Das Backup ' . ($bk['file'] ?? '') . ' ist nicht wiederherstellbar: ' . implode(' ', (array) ($bk['errors'] ?? [])) . ' Bitte unter Backup ein neues erstellen.'];
}
$accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1')->fetchAll();
if ($accounts) {
if (!function_exists('imap_open')) {
$issues[] = ['level' => 'error', 'code' => 'imap_missing', 'message' => 'Die PHP-IMAP-Erweiterung fehlt – eingehende Mails können nicht abgerufen werden.'];
}
foreach ($accounts as $a) {
$label = $a['label'] ?: $a['username'];
$fails = (int) ($a['fail_count'] ?? 0);
if ($fails >= HEALTH_FAIL_THRESHOLD) {
$since = !empty($a['last_success_at']) ? ' Letzter Erfolg: ' . date('d.m.Y H:i', strtotime($a['last_success_at'])) . '.' : '';
$issues[] = ['level' => 'error', 'code' => 'mailbox_down', 'message' => "Postfach „{$label}“ ist nicht erreichbar ({$fails} Fehlversuche in Folge).{$since} " . ($a['last_error'] ?? '')];
} elseif ($fails > 0) {
$issues[] = ['level' => 'warning', 'code' => 'mailbox_flaky', 'message' => "Postfach „{$label}“: letzter Abruf fehlgeschlagen. " . ($a['last_error'] ?? '')];
}
}
// Der Abruf selbst läuft nicht mehr (Cronjob/Seitenaufrufe bleiben aus)
$last = get_setting('cron_fetch_emails_last_run', '');
if (get_setting('pseudo_cron_enabled', '1') === '1' || $last !== '') {
if ($last !== '' && (time() - strtotime($last)) > 1200) {
$issues[] = ['level' => 'warning', 'code' => 'fetch_stale', 'message' => 'Der Mail-Abruf wurde seit ' . cron_last_run_label($last) . ' nicht ausgeführt. Läuft der Cronjob (cron/fetch_emails.php)?'];
}
}
}
$lastErr = (string) get_setting('mail_send_last_error', '');
if ($lastErr !== '') {
[$at, $msg] = array_pad(explode('|', $lastErr, 2), 2, '');
$ok = (string) get_setting('mail_send_last_ok', '');
if ($at !== '' && strtotime($at) > time() - 86400 && ($ok === '' || strtotime($ok) < strtotime($at))) {
$issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg];
}
}
return $issues;
}
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* includes/imap_spam.php
*
* Markiert per E-Mail eingegangene Tickets als Spam (und macht das rückgängig) –
* verschiebt dabei die Original-E-Mail im zugehörigen IMAP-Postfach in den
* konfigurierten Spam-/Junk-Ordner bzw. bei "Rückgängig" wieder zurück.
*
* set_ticket_spam() ist der gemeinsame Einstiegspunkt für Web (staff/ticket_view.php)
* und die mobile App (api/staff/v1/ticket_spam.php) – beide rufen dieselbe Funktion auf,
* damit sich das Verhalten nie auseinanderentwickelt.
*/
function imap_connect_account(array $account, string $folder): mixed
{
if (!function_exists('imap_open')) return false;
$sslFlag = match ($account['encryption']) {
'ssl' => '/imap/ssl',
'tls' => '/imap/tls',
default => '/imap/notls',
};
$mailbox = '{' . $account['host'] . ':' . $account['port'] . $sslFlag . '}' . $folder;
return @imap_open($mailbox, $account['username'], decrypt_secret($account['password_enc']), 0, 1);
}
function imap_normalize_message_id(?string $value): string
{
return trim(strtolower((string) $value), " \t\r\n<>");
}
/**
* Verschiebt EINE bereits als Ticket importierte Mail in einen Ziel-Ordner
* (Spam beim Markieren, ursprünglicher Posteingang beim Rückgängigmachen).
* Aktualisiert bei Erfolg current_folder in ticket_email_sources, damit
* nachfolgende Aufrufe (erneutes Markieren, Rückgängig machen) wissen, wo die
* Mail gerade tatsächlich liegt.
*/
function move_ticket_email_source(PDO $pdo, array $source, array $account, string $targetFolder): array
{
if (!function_exists('imap_open')) {
return ['ok' => false, 'message' => 'PHP-IMAP ist nicht installiert.'];
}
$sourceFolder = trim((string) ($source['current_folder'] ?? ''));
$targetFolder = trim($targetFolder);
if ($sourceFolder === '' || $targetFolder === '') {
return ['ok' => false, 'message' => 'IMAP-Quell- oder Zielordner fehlt.'];
}
if (strcasecmp($sourceFolder, $targetFolder) === 0) {
return ['ok' => true, 'message' => 'Die E-Mail befindet sich bereits in „' . $targetFolder . '“.'];
}
$mbox = imap_connect_account($account, $sourceFolder);
if (!$mbox) {
return ['ok' => false, 'message' => 'IMAP-Quellordner konnte nicht geöffnet werden: ' . (imap_last_error() ?: 'unbekannter Fehler')];
}
$emailNum = null;
$uid = (int) ($source['message_uid'] ?? 0);
if ($uid > 0) {
$candidate = @imap_msgno($mbox, $uid);
if ($candidate > 0) $emailNum = $candidate;
}
// Nach einem vorherigen Verschieben kann sich die UID geändert haben. Dann über Message-ID suchen.
if (!$emailNum && !empty($source['message_id'])) {
$targetId = imap_normalize_message_id($source['message_id']);
$hits = @imap_search($mbox, 'ALL');
if ($hits) {
foreach ($hits as $num) {
$h = @imap_headerinfo($mbox, $num);
$mid = imap_normalize_message_id($h->message_id ?? '');
if ($mid !== '' && $mid === $targetId) {
$emailNum = (int) $num;
break;
}
}
}
}
if (!$emailNum) {
imap_close($mbox);
return ['ok' => false, 'message' => 'Original-E-Mail wurde im IMAP-Ordner nicht gefunden.'];
}
if (!@imap_mail_move($mbox, (string) $emailNum, $targetFolder)) {
$err = imap_last_error() ?: 'unbekannter IMAP-Fehler';
imap_close($mbox);
return ['ok' => false, 'message' => 'Verschieben nach „' . $targetFolder . '“ fehlgeschlagen: ' . $err];
}
@imap_expunge($mbox);
imap_close($mbox);
if (!empty($source['id'])) {
$pdo->prepare('UPDATE ticket_email_sources SET current_folder = ? WHERE id = ?')
->execute([$targetFolder, (int) $source['id']]);
}
return ['ok' => true, 'message' => 'E-Mail nach „' . $targetFolder . '“ verschoben.'];
}
/**
* Markiert ein E-Mail-Ticket als Spam, oder macht das (mit $restore = true)
* wieder rückgängig. Gemeinsam genutzt von Web und App.
*
* @return array{error: ?string, moved: int, already: int, failed: string[]}
*/
function set_ticket_spam(PDO $pdo, array $ticket, int $staffId, bool $restore = false): array
{
if (($ticket['source'] ?? '') !== 'email') {
return ['error' => 'Nur per E-Mail eingegangene Tickets können als Spam markiert werden.', 'moved' => 0, 'already' => 0, 'failed' => []];
}
if ($restore && empty($ticket['is_spam'])) {
return ['error' => 'Dieses Ticket ist nicht als Spam markiert.', 'moved' => 0, 'already' => 0, 'failed' => []];
}
if (!$restore && !empty($ticket['is_spam'])) {
return ['error' => 'Dieses Ticket ist bereits als Spam markiert.', 'moved' => 0, 'already' => 0, 'failed' => []];
}
$srcStmt = $pdo->prepare(
'SELECT tes.*, ia.label, ia.spam_folder, ia.folder AS inbox_folder, ia.host, ia.port, ia.encryption, ia.username, ia.password_enc
FROM ticket_email_sources tes
JOIN imap_accounts ia ON ia.id = tes.imap_account_id
WHERE tes.ticket_id = ? ORDER BY tes.id'
);
$srcStmt->execute([(int) $ticket['id']]);
$sources = $srcStmt->fetchAll();
$moved = 0;
$already = 0;
$failed = [];
foreach ($sources as $source) {
$targetFolder = $restore
? ($source['inbox_folder'] ?: 'INBOX')
: ($source['spam_folder'] ?: 'Junk');
$result = move_ticket_email_source($pdo, $source, $source, $targetFolder);
if ($result['ok']) {
if (str_contains($result['message'], 'befindet sich bereits')) {
$already++;
} else {
$moved++;
}
} else {
$failed[] = ($source['label'] ?: 'Postfach') . ': ' . $result['message'];
}
}
$now = db_now();
if ($restore) {
$pdo->prepare('UPDATE tickets SET is_spam = 0, updated_at = ? WHERE id = ?')->execute([$now, $ticket['id']]);
log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben');
} else {
$pdo->prepare(
"UPDATE tickets SET is_spam=1, status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
)->execute([$now, $now, $ticket['id']]);
finalize_work_session($pdo, (int) $ticket['id'], $staffId);
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert');
}
return ['error' => null, 'moved' => $moved, 'already' => $already, 'failed' => $failed];
}
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* Schnittstellen für Fremdsysteme:
* - API-Schlüssel für die öffentliche Ticket-API (api/public/v1/tickets.php)
* - Webhooks: DeskHub meldet Ereignisse (Ticket erstellt/beantwortet/Status/Zuweisung) per HTTP-POST an eine URL.
*/
/** Alle Ereignisse, die Webhooks abonnieren können. */
function webhook_event_labels(): array
{
return [
'ticket.created' => 'Ticket erstellt',
'ticket.replied' => 'Antwort/Notiz zu einem Ticket',
'ticket.status_changed' => 'Status geändert',
'ticket.assigned' => 'Ticket zugewiesen',
];
}
// ── API-Schlüssel ───────────────────────────────────────────────────────────
/** Erzeugt einen Schlüssel. Der Klartext wird nur hier zurückgegeben und nie gespeichert (nur der SHA-256-Hash). */
function api_key_create(PDO $pdo, string $name, bool $canCreate, bool $canRead, string $ipWhitelist, ?int $staffId): array
{
$name = trim($name);
if ($name === '') throw new AdminModelException('Bitte einen Namen für den Schlüssel angeben.', 'NAME_REQUIRED');
if (!$canCreate && !$canRead) throw new AdminModelException('Der Schlüssel braucht mindestens ein Recht (Tickets anlegen oder lesen).', 'NO_PERMISSION');
$ips = [];
foreach (preg_split('/[\s,;]+/', $ipWhitelist) ?: [] as $ip) {
if ($ip === '') continue;
if (!filter_var($ip, FILTER_VALIDATE_IP)) throw new AdminModelException("Ungültige IP-Adresse: {$ip}", 'INVALID_IP');
$ips[] = $ip;
}
$key = 'dh_' . bin2hex(random_bytes(24));
$pdo->prepare('INSERT INTO api_keys (name, key_hash, key_prefix, can_create_tickets, can_read_tickets, ip_whitelist, is_active, created_by, created_at) VALUES (?,?,?,?,?,?,1,?,?)')
->execute([$name, hash('sha256', $key), substr($key, 0, 9), $canCreate ? 1 : 0, $canRead ? 1 : 0, $ips ? implode(',', $ips) : null, $staffId, db_now()]);
return ['id' => (int) $pdo->lastInsertId(), 'key' => $key];
}
/** Prüft einen Schlüssel samt IP-Beschränkung. Gibt die Zeile zurück oder null. */
function api_key_verify(PDO $pdo, string $key, string $ip): ?array
{
if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return null;
$stmt = $pdo->prepare('SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1');
$stmt->execute([hash('sha256', $key)]);
$row = $stmt->fetch();
if (!$row) return null;
if (!empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true)) return null;
$pdo->prepare('UPDATE api_keys SET last_used_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
return $row;
}
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
/**
* Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an,
* benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus.
*
* @return array{number:string,id:int}
* @throws AdminModelException bei ungültigen Eingaben
*/
function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
{
$name = trim((string) ($in['name'] ?? ''));
$email = trim((string) ($in['email'] ?? ''));
$subject = trim((string) ($in['subject'] ?? ''));
$message = trim((string) ($in['message'] ?? ''));
if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT');
if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE');
if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403);
$topicId = am_int_or_null($in, 'helpTopicId');
$deptId = am_int_or_null($in, 'departmentId');
$prioId = am_int_or_null($in, 'priorityId');
if ($topicId !== null) {
$t = $pdo->prepare('SELECT * FROM help_topics WHERE id = ? AND is_active = 1');
$t->execute([$topicId]);
$topic = $t->fetch();
if (!$topic) throw new AdminModelException('Das Hilfethema existiert nicht oder ist deaktiviert.', 'INVALID_TOPIC');
$deptId = $deptId ?? (int) $topic['department_id'];
$prioId = $prioId ?? (int) $topic['priority_id'];
}
$ruleResult = evaluate_ticket_rules($pdo, ['subject' => $subject, 'message' => $message, 'requester_email' => $email, 'requester_name' => $name, 'source' => 'api']);
if ($ruleResult['reject']) throw new AdminModelException('Das Ticket wurde abgelehnt.', 'REJECTED', 403);
if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department'];
if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority'];
if ($ruleResult['set_help_topic'] !== null) $topicId = $ruleResult['set_help_topic'];
$deptId = active_department_id($pdo, $deptId ?: (int) get_setting('default_department_id', '1'));
if (!$deptId) throw new AdminModelException('Es ist keine Abteilung eingerichtet.', 'NO_DEPARTMENT', 503);
if (!$prioId) $prioId = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$pStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$pStmt->execute([$prioId]);
$priority = $pStmt->fetch();
if (!$priority) throw new AdminModelException('Die Priorität ist ungültig.', 'INVALID_PRIORITY');
$sla = calc_sla_dates($priority, null, $deptId, $topicId);
$number = generate_ticket_number($pdo);
$now = db_now();
if (get_setting('ip_anonymize', '0') === '1') {
$clientIp = preg_replace('/\.(\d+)$/', '.0', $clientIp);
$clientIp = preg_replace('/:[0-9a-f]{1,4}$/', ':0', $clientIp);
}
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, help_topic_id, priority_id,
status, source, requester_ip, first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$number, $subject, $name, $email, $deptId, $topicId, $prioId, 'open', 'api', $clientIp, $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
->execute([$ticketId, 'customer', $message, $now]);
log_activity($pdo, $ticketId, null, 'Ticket über die API erstellt');
if ($ruleResult['assign_staff'] !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$ruleResult['assign_staff'], $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$d = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$d->execute([$deptId]);
$dept = $d->fetch();
$forNotify = ['ticket_number' => $number, 'subject' => $subject, 'requester_name' => $name, 'requester_email' => $email,
'department_id' => $deptId, 'department_name' => $dept['name'] ?? null];
notify_new_ticket_channels($forNotify, $dept['email'] ?? null);
notify_staff_new_ticket($forNotify);
notify_customer_ticket_created($forNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
return ['number' => $number, 'id' => $ticketId];
}
// ── Webhooks ────────────────────────────────────────────────────────────────
/** Prüft eine Webhook-URL: nur http(s), und der Zielhost darf nicht intern (localhost/private Netze) sein. */
function webhook_validate_url(string $url): void
{
$parts = parse_url($url);
if (!$parts || !in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
throw new AdminModelException('Bitte eine gültige URL mit http:// oder https:// angeben.', 'INVALID_URL');
}
$ip = filter_var($parts['host'], FILTER_VALIDATE_IP) ? $parts['host'] : gethostbyname($parts['host']);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw new AdminModelException('Die Adresse zeigt auf ein internes oder nicht auflösbares Ziel und ist nicht erlaubt.', 'INVALID_URL');
}
}
/** Legt einen Webhook an oder aktualisiert ihn. Ein leeres Secret lässt das bisherige unverändert. */
function webhook_save(PDO $pdo, int $id, array $in): int
{
$name = trim((string) ($in['name'] ?? ''));
$url = trim((string) ($in['url'] ?? ''));
$secret = (string) ($in['secret'] ?? '');
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : 1;
$events = array_values(array_intersect(array_keys(webhook_event_labels()), array_map('strval', (array) ($in['events'] ?? []))));
if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED');
if (!$events) throw new AdminModelException('Bitte mindestens ein Ereignis wählen.', 'NO_EVENTS');
webhook_validate_url($url);
$now = db_now();
if ($id === 0) {
if ($secret === '') $secret = bin2hex(random_bytes(20));
$pdo->prepare('INSERT INTO webhooks (name, url, secret_enc, events, is_active, fail_count, created_at) VALUES (?,?,?,?,?,0,?)')
->execute([$name, $url, encrypt_secret($secret), implode(',', $events), $isActive, $now]);
return (int) $pdo->lastInsertId();
}
$sql = 'UPDATE webhooks SET name=?, url=?, events=?, is_active=?';
$params = [$name, $url, implode(',', $events), $isActive];
if ($secret !== '') { $sql .= ', secret_enc=?'; $params[] = encrypt_secret($secret); }
$pdo->prepare($sql . ' WHERE id=?')->execute([...$params, $id]);
return $id;
}
/** Baut die Ereignis-Nutzlast für ein Ticket. */
function webhook_payload(PDO $pdo, string $event, int $ticketId, array $extra = []): ?array
{
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, d.name AS department_name, s.name AS staff_name
FROM tickets t JOIN priorities p ON p.id = t.priority_id JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id WHERE t.id = ?'
);
$stmt->execute([$ticketId]);
$t = $stmt->fetch();
if (!$t) return null;
return [
'event' => $event,
'occurredAt' => gmdate('c'),
'ticket' => [
'number' => $t['ticket_number'], 'subject' => $t['subject'], 'status' => $t['status'], 'source' => $t['source'],
'priority' => $t['priority_name'], 'department' => $t['department_name'], 'assignedTo' => $t['staff_name'],
'requesterName' => $t['requester_name'], 'requesterEmail' => $t['requester_email'],
'createdAt' => $t['created_at'], 'updatedAt' => $t['updated_at'],
],
] + $extra;
}
/** Sendet einen Webhook sofort. Gibt [HTTP-Status, Fehlertext|null] zurück. */
function webhook_deliver(array $hook, array $payload): array
{
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$secret = decrypt_secret($hook['secret_enc']);
$headers = [
'Content-Type: application/json', 'User-Agent: DeskHub-Webhook/1', 'X-DeskHub-Event: ' . $payload['event'],
'X-DeskHub-Signature: sha256=' . hash_hmac('sha256', $body, $secret),
];
// Ziel beim Senden erneut prüfen und die Adresse festnageln (verhindert DNS-Rebinding: der Name zeigte beim Speichern
// auf eine öffentliche, zeigt jetzt aber auf eine interne Adresse). Nur Tests dürfen an lokale Empfänger senden.
$parts = parse_url((string) $hook['url']) ?: [];
$host = (string) ($parts['host'] ?? '');
$pinned = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
$testing = getenv('DESKHUB_TESTING') === '1';
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| (!$testing && !filter_var($pinned, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))) {
return [0, 'Ziel ist intern oder nicht auflösbar (blockiert)'];
}
if (!function_exists('curl_init')) {
$ctx = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body, 'timeout' => 5, 'ignore_errors' => true]]);
$res = @file_get_contents($hook['url'], false, $ctx);
$status = 0;
foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1];
return [$status, $res === false ? 'Verbindung fehlgeschlagen' : ($status >= 200 && $status < 300 ? null : "HTTP {$status}")];
}
$ch = curl_init($hook['url']);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_RESOLVE => [$host . ':' . (int) ($parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80)) . ':' . $pinned]]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$status, $err !== '' ? $err : ($status >= 200 && $status < 300 ? null : "HTTP {$status}")];
}
/**
* Meldet ein Ereignis an alle aktiven Webhooks, die es abonniert haben. Die Zustellung läuft nach dem
* Absenden der Antwort (Shutdown), damit sie den Aufrufer nicht aufhält. Fehler werden am Webhook vermerkt;
* nach 10 Fehlern in Folge wird er automatisch deaktiviert.
*/
function webhook_dispatch(PDO $pdo, string $event, int $ticketId, array $extra = []): void
{
// Plugins informieren (läuft isoliert, Fehler in Plugins beeinflussen das Ticket nie)
plugin_hook($event, ['event' => $event, 'ticketId' => $ticketId] + $extra);
try {
$hooks = $pdo->query('SELECT * FROM webhooks WHERE is_active = 1')->fetchAll();
$hooks = array_values(array_filter($hooks, fn($h) => in_array($event, explode(',', (string) $h['events']), true)));
if (!$hooks) return;
$payload = webhook_payload($pdo, $event, $ticketId, $extra);
if (!$payload) return;
} catch (Throwable $e) {
error_log('webhook_dispatch: ' . $e->getMessage());
return;
}
$run = function () use ($pdo, $hooks, $payload): void {
foreach ($hooks as $hook) {
try {
[$status, $error] = webhook_deliver($hook, $payload);
if ($error === null) {
$pdo->prepare('UPDATE webhooks SET last_status = ?, last_error = NULL, last_delivery_at = ?, fail_count = 0 WHERE id = ?')->execute([$status, db_now(), $hook['id']]);
} else {
$fails = (int) $hook['fail_count'] + 1;
$pdo->prepare('UPDATE webhooks SET last_status = ?, last_error = ?, last_delivery_at = ?, fail_count = ?, is_active = ? WHERE id = ?')
->execute([$status ?: null, mb_substr($error, 0, 300), db_now(), $fails, $fails >= 10 ? 0 : 1, $hook['id']]);
}
} catch (Throwable $e) {
error_log('webhook_deliver: ' . $e->getMessage());
}
}
};
if (PHP_SAPI === 'cli') { $run(); return; } // Tests/Cron: sofort
register_shutdown_function(function () use ($run) {
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
$run();
});
}
+470
View File
@@ -0,0 +1,470 @@
<?php
/**
* Mailer mit zwei Versandwegen:
* 1) Eigener SMTP-Client (includes/smtp_mailer.php), sobald unter Einstellungen
* → E-Mail-Versand ein SMTP-Server hinterlegt ist – funktioniert unabhängig
* von der Hosting-Konfiguration und ohne externe Bibliothek.
* 2) Fallback auf die einfache PHP-Funktion mail() (config.php → mail.enabled),
* falls kein SMTP-Server hinterlegt ist.
* Der Rest der Anwendung ruft ausschließlich send_mail() auf.
*/
function smtp_last_error(): string
{
static $lastError = '';
return func_num_args() > 0 ? ($lastError = func_get_arg(0)) : $lastError;
}
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool
{
$cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
$smtpHost = get_setting('smtp_host', '');
$fromHeader = $fromOverride ?: smtp_default_from();
if ($smtpHost === '' && !$cfg['enabled'] && !($fromOverride && email_address_smtp_for(get_db(), $fromHeader))) {
// Weder eigener SMTP-Server konfiguriert noch PHP mail() aktiviert.
// Wird still ignoriert, damit der Rest der Anwendung normal funktioniert.
return false;
}
// Eigener SMTP-Server der Absenderadresse (osTicket-Reiter "Abgehend") hat Vorrang vor dem globalen.
$own = $fromOverride ? email_address_smtp_for(get_db(), $fromHeader) : null;
if ($own !== null) {
$mailer = new SmtpMailer(
$own['smtp_host'],
(int) $own['smtp_port'],
$own['smtp_encryption'] ?: 'tls',
(string) $own['smtp_username'],
$own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '',
);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
error_log('DeskHub SMTP-Versand (eigener Server) fehlgeschlagen: ' . $mailer->getLastError());
}
health_record_send_result($ok, $mailer->getLastError());
return $ok;
}
if ($smtpHost !== '') {
$mailer = new SmtpMailer(
$smtpHost,
(int) get_setting('smtp_port', '587'),
get_setting('smtp_encryption', 'tls'),
get_setting('smtp_username', ''),
decrypt_secret(get_setting('smtp_password', '')),
);
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
if (!$ok) {
smtp_last_error($mailer->getLastError());
error_log('DeskHub SMTP-Versand fehlgeschlagen: ' . $mailer->getLastError());
}
health_record_send_result($ok, $mailer->getLastError());
return $ok;
}
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
$headers .= "From: {$fromHeader}\r\n";
return @mail($to, $subject, $htmlBody, $headers);
}
/**
* Wie send_mail(), protokolliert einen Fehlschlag aber zusätzlich sichtbar im
* Admin-Protokoll (staff/audit_log.php). System-Mails scheitern sonst komplett
* unbemerkt – der Kunde bekommt keine Fehlermeldung und niemand wird informiert.
*/
function email_send_and_log(string $to, string $subject, string $htmlBody, ?string $from, string $context, string $ticketNumber = ''): bool
{
$ok = send_mail($to, $subject, $htmlBody, $from);
if (!$ok) {
$ref = $ticketNumber !== '' ? " (Ticket #{$ticketNumber})" : '';
// admin_audit_log.action ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein
// zu langer Wert (z.B. eine ausführliche SMTP-Fehlermeldung) sonst eine harte Exception.
$message = mb_substr("{$context}{$ref} an {$to} fehlgeschlagen: " . smtp_last_error(), 0, 255);
audit_log(null, $message);
}
return $ok;
}
/** Standard-Absenderadresse: eigene SMTP-Absenderangabe, sonst die Werte aus config.php */
function smtp_default_from(): string
{
$cfg = [
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
$name = get_setting('smtp_from_name') ?: $cfg['from_name'];
$email = get_setting('smtp_from_email') ?: $cfg['from_email'];
return sprintf('%s <%s>', $name, $email);
}
/**
* Ermittelt die passende Absenderadresse für eine Abteilung (optional nach
* Priorität verfeinert) aus der E-Mail-Adressen-Verwaltung. Gibt null zurück,
* wenn keine passt – dann verwendet send_mail() die globale Standardadresse.
*/
function resolve_from_address(?int $departmentId, ?int $priorityId = null): ?string
{
if (!$departmentId) return null;
$pdo = get_db();
if ($priorityId) {
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND priority_id = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$departmentId, $priorityId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
}
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND is_default = 1 AND is_active = 1 LIMIT 1');
$stmt->execute([$departmentId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
$stmt = $pdo->prepare('SELECT email, label FROM email_addresses WHERE department_id = ? AND is_active = 1 ORDER BY id LIMIT 1');
$stmt->execute([$departmentId]);
if ($row = $stmt->fetch()) {
return $row['label'] ? "{$row['label']} <{$row['email']}>" : $row['email'];
}
return null;
}
/**
* Liest eine anpassbare E-Mail-Vorlage aus den Einstellungen (Adminportal → E-Mail-Vorlagen)
* und ersetzt Platzhalter wie {ticket_number}. Fällt auf die mitgegebenen Standardtexte
* zurück, solange der Admin nichts Eigenes hinterlegt hat.
*
* @param array<string,string> $vars Platzhalter ohne geschweifte Klammern, z.B. ['ticket_number' => '...']
* @return array{0:string,1:string} [Betreff, HTML-Text]
*/
function render_email_template(string $key, array $vars, string $defaultSubject, string $defaultBody): array
{
$subject = get_setting("email_template_{$key}_subject") ?: $defaultSubject;
$body = get_setting("email_template_{$key}_body") ?: $defaultBody;
$search = [];
$replace = [];
foreach ($vars as $name => $value) {
$search[] = '{' . $name . '}';
$replace[] = $value;
}
return [str_replace($search, $replace, $subject), str_replace($search, $replace, $body)];
}
// ====================================================================
// Einheitliches, modernes E-Mail-Design
// ====================================================================
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails
// gleich aussehen. email_badge()/email_button() sind wiederverwendbare
// Bausteine für die Standardtexte der einzelnen Vorlagen.
/** Branding-Rahmen (Header, weiße Karte, Footer) um einen HTML-Textkörper. */
function email_wrap(string $innerHtml): string
{
$siteName = e(get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support'));
return <<<HTML
<div style="background:#f5f7f9;padding:32px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
<tr>
<td style="background:#0f6f6a;padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;letter-spacing:-.01em;">{$siteName}</span>
</td>
</tr>
<tr>
<td style="padding:32px 28px;">
{$innerHtml}
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f5f7f9;border-top:1px solid #e2e8f0;">
<p style="margin:0;color:#94a3b8;font-size:12px;">Diese E-Mail wurde automatisch von {$siteName} versendet.</p>
</td>
</tr>
</table>
</div>
HTML;
}
/** Farbige Hervorhebungs-Box, z.B. für die Ticketnummer. $label/$value dürfen bereits ersetzte {platzhalter} enthalten. */
function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string
{
return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr>
<td style="background:{$bg};border-radius:8px;padding:14px 18px;">
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{$color};margin-bottom:2px;">{$label}</div>
<div style="font-size:20px;font-weight:700;color:{$color};font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
</td>
</tr>
</table>
HTML;
}
/** Button-artiger Call-to-Action-Link. */
function email_button(string $label, string $url, string $color = '#0f6f6a'): string
{
return <<<HTML
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="background:{$color};border-radius:8px;">
<a href="{$url}" style="display:inline-block;padding:12px 24px;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">{$label}</a>
</td>
</tr>
</table>
HTML;
}
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
function email_p(string $html, string $marginBottom = '16px'): string
{
return "<p style=\"margin:0 0 {$marginBottom};color:#16202a;font-size:15px;line-height:1.5;\">{$html}</p>";
}
/**
* Standard-Textkörper der vier anpassbaren Vorlagen (Adminportal → E-Mail-Vorlagen).
* Enthalten nur den Innenbereich – der Branding-Rahmen kommt beim Versand
* automatisch über email_wrap() dazu, auch wenn ein Admin den Text anpasst.
*/
function email_ticket_created_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px')
. email_badge('Ihre Ticketnummer', '#{ticket_number}')
. email_p('<strong>Betreff:</strong> {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}')
. '<p style="margin:24px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.</p>';
}
function email_new_reply_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:')
. '<div style="background:#f8fafc;border-left:3px solid #0f6f6a;border-radius:0 6px 6px 0;padding:14px 18px;margin:0 0 24px;color:#16202a;font-size:14px;line-height:1.6;">{message}</div>'
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_new_ticket_department_default_body(): string
{
return email_p('Ein neues Ticket wurde erstellt.')
. email_badge('Ticket', '#{ticket_number}')
. email_p('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '6px')
. email_p('<strong>Betreff:</strong> {subject}', '24px')
. email_button('Ticket ansehen', '{ticket_url}');
}
function email_ticket_overdue_default_body(): string
{
return email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
. email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2')
. email_p('<strong>Betreff:</strong> {subject}', '6px')
. email_p('<strong>Abteilung:</strong> {department_name}', '24px')
. email_button('Ticket ansehen', '{ticket_url}', '#dc2626');
}
function email_live_chat_transcript_default_body(): string
{
return email_p('Hallo {customer_name},')
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px')
. '{transcript}'
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
. email_button('Ticket ansehen', '{ticket_url}');
}
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
function email_password_reset_body(string $link): string
{
$inner = email_p('Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen. Der Link ist <strong>1 Stunde</strong> lang gültig.')
. email_button('Passwort zurücksetzen', e($link))
. '<p style="margin:20px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.</p>';
return email_wrap($inner);
}
/** @param string[] $collaboratorEmails Zusätzliche Empfänger (Kollaboratoren), die dieselbe Antwort-Mail erhalten */
function notify_customer_new_reply(array $ticket, string $message, string $signature = '', array $collaboratorEmails = []): void
{
// $message ist bereits über sanitize_staff_html() bereinigtes, sicheres HTML – nicht erneut escapen.
// $signature stammt aus der Mitarbeiter- oder Abteilungspflege und wurde dort ebenfalls bereinigt.
$messageWithSignature = $message . ($signature !== '' ? "<div style=\"margin-top:18px;color:#64748b\">{$signature}</div>" : '');
[$subject, $body] = render_email_template('new_reply', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'message' => $messageWithSignature,
'ticket_url' => e(ticket_access_url($ticket)),
], "[#{$ticket['ticket_number']}] {$ticket['subject']}", email_new_reply_default_body());
$body = email_wrap($body);
$from = resolve_from_address($ticket['department_id'] ?? null, $ticket['priority_id'] ?? null);
email_send_and_log($ticket['requester_email'], $subject, $body, $from, "Neue-Antwort-Mail", $ticket['ticket_number']);
foreach ($collaboratorEmails as $ccEmail) {
if ($ccEmail === '' || strcasecmp($ccEmail, $ticket['requester_email']) === 0) continue;
email_send_and_log($ccEmail, $subject, $body, $from, "Neue-Antwort-Mail (Kollaborateur)", $ticket['ticket_number']);
}
}
function notify_customer_ticket_created(array $ticket): void
{
[$subject, $body] = render_email_template('ticket_created', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => e(ticket_access_url($ticket)),
'lookup_url' => base_url() . '/ticket_lookup.php',
], "Ihr Ticket #{ticket_number} wurde erstellt", email_ticket_created_default_body());
$from = resolve_from_address($ticket['department_id'] ?? null, null);
email_send_and_log($ticket['requester_email'], $subject, email_wrap($body), $from, 'Ticket-erstellt-Mail', $ticket['ticket_number']);
}
/** Rendert eine Chat-Nachricht als E-Mail-taugliche (Inline-Styles statt CSS-Klassen) Zeile. */
function email_chat_transcript_row(string $authorLabel, string $message, string $time, bool $isStaff): string
{
$align = $isStaff ? 'right' : 'left';
$bg = $isStaff ? '#0f6f6a' : '#f1f5f9';
$color = $isStaff ? '#ffffff' : '#16202a';
$safeMessage = nl2br(e($message));
return <<<HTML
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 10px;">
<tr>
<td align="{$align}">
<div style="font-size:11px;color:#94a3b8;margin-bottom:3px;">{$authorLabel} · {$time}</div>
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:80%;display:inline-block;">
<tr>
<td style="background:{$bg};color:{$color};border-radius:10px;padding:8px 12px;font-size:14px;line-height:1.5;text-align:left;">{$safeMessage}</td>
</tr>
</table>
</td>
</tr>
</table>
HTML;
}
/**
* Schickt dem Kunden nach Ende eines Live-Chats (staff/live_chat.php, Aktion "end")
* den vollständigen Verlauf als E-Mail zu – abschaltbar über die Einstellung
* "live_chat_send_transcript" (Standard: an). $messages im Format wie von
* chat_messages() in live_chat.php: [['authorType'=>..,'authorName'=>..,'message'=>..,'createdAt'=>..], ...]
*/
function notify_customer_chat_transcript(array $ticket, array $messages): void
{
if (get_setting('live_chat_send_transcript', '1') !== '1') return;
if (!$messages) return;
$rows = '';
foreach ($messages as $m) {
$isStaff = $m['authorType'] === 'staff';
$label = $isStaff ? ($m['authorName'] ?: 'Support') : ($ticket['requester_name'] ?? 'Sie');
$time = substr((string) $m['createdAt'], 0, 16);
// Mitarbeiter-Nachrichten sind sanitiztes HTML (Rich-Text-Editor), Kunden-Nachrichten
// rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich
// auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen.
$rawMessage = (string) $m['message'];
$plainMessage = preg_match('/<[a-zA-Z][^>]*>/i', $rawMessage)
? trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
: $rawMessage;
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
}
$transcriptBlock = '<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:0 0 20px;">' . $rows . '</div>';
[$subject, $body] = render_email_template('live_chat_transcript', [
'ticket_number' => $ticket['ticket_number'],
'customer_name' => e($ticket['requester_name'] ?? ''),
'transcript' => $transcriptBlock,
'ticket_url' => e(ticket_access_url($ticket)),
], 'Ihr Chatverlauf – Ticket #{ticket_number}', email_live_chat_transcript_default_body());
$from = resolve_from_address($ticket['department_id'] ?? null, null);
email_send_and_log($ticket['requester_email'], $subject, email_wrap($body), $from, 'Chatverlauf-Mail', $ticket['ticket_number']);
}
function notify_department_new_ticket(array $ticket, string $departmentEmail): void
{
[$subject, $body] = render_email_template('new_ticket_department', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'customer_email' => e($ticket['requester_email'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "Neues Ticket [#{ticket_number}] {subject}", email_new_ticket_department_default_body());
email_send_and_log($departmentEmail, $subject, email_wrap($body), null, 'Neues-Ticket-Mail (Abteilung)', $ticket['ticket_number']);
}
/**
* Benachrichtigt per E-Mail: alle aktiven Admins (immer) sowie Mitarbeiter, deren
* Hauptabteilung der Ticket-Abteilung entspricht oder die für diese Abteilung als
* zusätzliche Abteilung mit aktivierter Benachrichtigung (staff_department.notify) eingetragen sind.
* Ergänzt die bestehende generische Abteilungs-E-Mail (notify_department_new_ticket), die weiterhin
* zusätzlich an die in der E-Mail-Adressen-Verwaltung hinterlegte Postfach-Adresse geht.
*/
function notify_staff_new_ticket(array $ticket): void
{
$pdo = get_db();
$deptId = $ticket['department_id'] ?? null;
if ($deptId) {
$stmt = $pdo->prepare(
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND (
role = 'admin'
OR department_id = ?
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
)"
);
$stmt->execute([$deptId, $deptId]);
} else {
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'");
}
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!$recipients) return;
[$subject, $body] = render_email_template('new_ticket_department', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'customer_name' => e($ticket['requester_name'] ?? ''),
'customer_email' => e($ticket['requester_email'] ?? ''),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "Neues Ticket [#{ticket_number}] {subject}", email_new_ticket_department_default_body());
$body = email_wrap($body);
foreach ($recipients as $email) {
email_send_and_log($email, $subject, $body, null, 'Neues-Ticket-Mail (Mitarbeiter)', $ticket['ticket_number']);
}
}
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
function notify_admins_overdue_ticket(array $ticket): void
{
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
if (!$admins) return;
[$subject, $body] = render_email_template('ticket_overdue', [
'ticket_number' => $ticket['ticket_number'],
'subject' => e($ticket['subject']),
'department_name' => e($ticket['department_name'] ?? ''),
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
$body = email_wrap($body);
foreach ($admins as $email) {
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
/**
* Discord- und Telegram-Benachrichtigungen für Mitarbeiter.
*
* Konfiguration erfolgt unter "Einstellungen" im Mitarbeiterbereich (nicht in
* config/config.php), da es sich um Werte handelt, die Admins ohne Dateizugriff
* ändern können sollen. Gespeichert in der settings-Tabelle.
*
* Datenschutz-Hinweis: Bei aktivierten Benachrichtigungen werden Ticket-
* Ausschnitte (Betreff, Kundenname/-E-Mail, Nachrichtenanfang) an Discord
* bzw. Telegram als Drittanbieter (außerhalb der EU) übermittelt. Bitte vor
* der Aktivierung die eigenen Datenschutzanforderungen prüfen.
*
* Voraussetzung: PHP-Einstellung allow_url_fopen muss aktiviert sein
* (Standard auf den meisten Hosting-Paketen).
*/
function http_post_json(string $url, array $data, int $timeoutSeconds = 5): bool
{
$opts = ['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($data),
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
]];
$result = @file_get_contents($url, false, stream_context_create($opts));
return $result !== false;
}
function http_post_form(string $url, array $data, int $timeoutSeconds = 5): bool
{
$opts = ['http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($data),
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
]];
$result = @file_get_contents($url, false, stream_context_create($opts));
return $result !== false;
}
function discord_notify(string $message): bool
{
$url = decrypt_secret(get_setting('discord_webhook_url') ?? '');
if (!$url) {
return false;
}
return http_post_json($url, ['content' => $message]);
}
function telegram_notify(string $message): bool
{
$token = decrypt_secret(get_setting('telegram_bot_token') ?? '');
$chatId = get_setting('telegram_chat_id');
if (!$token || !$chatId) {
return false;
}
$url = "https://api.telegram.org/bot{$token}/sendMessage";
return http_post_form($url, ['chat_id' => $chatId, 'text' => $message]);
}
/** Benachrichtigt E-Mail (Abteilung) + Discord + Telegram über ein neues Ticket */
function notify_new_ticket_channels(array $ticket, ?string $departmentEmail): void
{
if ($departmentEmail) {
notify_department_new_ticket($ticket, $departmentEmail);
}
$text = "🆕 Neues Ticket #{$ticket['ticket_number']}\n"
. "Betreff: {$ticket['subject']}\n"
. "Von: {$ticket['requester_name']} ({$ticket['requester_email']})";
if (!empty($ticket['department_name'])) {
$text .= "\nAbteilung: {$ticket['department_name']}";
}
$text .= "\n" . base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']);
discord_notify($text);
telegram_notify($text);
}
/** Benachrichtigt Discord + Telegram über eine neue Kundenantwort (kein Mail-Duplikat nötig) */
function notify_new_reply_channels(array $ticket, string $excerpt): void
{
$short = mb_strlen($excerpt) > 200 ? mb_substr($excerpt, 0, 200) . '…' : $excerpt;
$text = "💬 Neue Kundenantwort zu Ticket #{$ticket['ticket_number']}\n"
. "Betreff: {$ticket['subject']}\n"
. "„{$short}“\n"
. base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']);
discord_notify($text);
telegram_notify($text);
}
/**
* Benachrichtigt die Mitglieder eines Teams per E-Mail, wenn ihm ein Ticket zugewiesen wurde.
* Entfällt bei deaktivierten Teams und wenn im Team "Benachrichtigung bei Ticketzuweisung
* deaktivieren" gesetzt ist. Der auslösende Mitarbeiter ($actorId) bekommt keine Mail.
*
* @return int Anzahl verschickter Benachrichtigungen
*/
function notify_team_assignment(PDO $pdo, array $ticket, int $teamId, ?int $actorId = null): int
{
$stmt = $pdo->prepare('SELECT name, is_active, no_alerts FROM teams WHERE id = ?');
$stmt->execute([$teamId]);
$team = $stmt->fetch();
if (!$team || !$team['is_active'] || $team['no_alerts']) return 0;
$members = $pdo->prepare('SELECT s.id, s.email FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
$members->execute([$teamId]);
$sent = 0;
$subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen';
$body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>'
. e($team['name']) . '</strong> zugewiesen.</p>';
foreach ($members->fetchAll() as $m) {
if ($actorId !== null && (int) $m['id'] === $actorId) continue;
if (send_mail($m['email'], $subject, $body)) $sent++;
}
return $sent;
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
/**
* osTicket-Import (Kern): liest einen SQL-Dump (phpMyAdmin oder mysqldump, auch mit anderem Tabellenpräfix als "ost_")
* und übernimmt Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten und Anhänge in DeskHub.
*
* Ablauf (jeweils in kleinen Schritten, damit auch Dumps mit mehreren hundert MB im Browser laufen):
* 1. osticket_analyze_step(): liest die Datei blockweise, merkt sich die nötigen Daten (Zustandsordner) und schreibt
* Nachrichtentexte zeilenweise in entries.jsonl.
* 2. osticket_import_step(): pro Durchgang (users, structure, tickets, threads, attachments) werden Datensätze ab
* einem Offset übernommen. Jeder Schritt meldet next_offset, damit der Aufrufer nichts überspringt.
*/
const OSTICKET_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024;
/** Tabellen, die gelesen werden (Schlüssel = Name ohne Präfix) mit den Spalten von osTicket 1.18 für Dumps ohne Spaltenliste. */
function osticket_tables(): array
{
return [
'user' => ['id', 'org_id', 'default_email_id', 'status', 'name', 'created', 'updated'],
'user_email' => ['id', 'user_id', 'flags', 'address'],
'ticket' => ['ticket_id', 'ticket_pid', 'number', 'user_id', 'user_email_id', 'status_id', 'dept_id', 'sla_id', 'topic_id', 'staff_id', 'team_id', 'email_id', 'lock_id', 'flags', 'sort', 'ip_address', 'source', 'source_extra', 'isoverdue', 'isanswered', 'duedate', 'est_duedate', 'reopened', 'closed', 'lastupdate', 'created', 'updated'],
'ticket__cdata' => ['ticket_id', 'subject', 'priority'],
'ticket_priority' => ['priority_id', 'priority', 'priority_desc', 'priority_color', 'priority_urgency', 'ispublic'],
'ticket_status' => ['id', 'name', 'state', 'mode', 'flags', 'sort', 'properties', 'created', 'updated'],
'thread' => ['id', 'object_id', 'object_type', 'extra', 'lastresponse', 'lastmessage', 'created'],
'thread_collaborator' => ['id', 'flags', 'thread_id', 'user_id', 'role', 'created', 'updated'],
'thread_entry' => ['id', 'pid', 'thread_id', 'staff_id', 'user_id', 'type', 'flags', 'poster', 'editor', 'editor_type', 'source', 'title', 'body', 'format', 'ip_address', 'extra', 'recipients', 'created', 'updated'],
'department' => ['id', 'pid', 'tpl_id', 'sla_id', 'schedule_id', 'email_id', 'autoresp_email_id', 'manager_id', 'flags', 'name', 'signature', 'ispublic', 'group_membership', 'ticket_auto_response', 'message_auto_response', 'path', 'updated', 'created'],
'help_topic' => ['topic_id', 'topic_pid', 'ispublic', 'isactive', 'ticket_auto_response', 'noautoresp', 'flags', 'status_id', 'priority_id', 'dept_id', 'staff_id', 'team_id', 'sla_id', 'page_id', 'sequence_id', 'sort', 'topic', 'number_format', 'notes', 'created', 'updated'],
'staff' => ['staff_id', 'dept_id', 'role_id', 'username', 'firstname', 'lastname', 'passwd', 'backend', 'email'],
'attachment' => ['id', 'object_id', 'type', 'file_id', 'name', 'inline', 'lang'],
'file' => ['id', 'ft', 'bk', 'type', 'size', 'key', 'signature', 'name', 'attrs', 'created'],
'file_chunk' => ['file_id', 'chunk_id', 'filedata'],
];
}
/** Tabellenname mit beliebigem Präfix -> Schlüssel aus osticket_tables() oder null. */
function osticket_table_key(string $table): ?string
{
static $keys = null;
$keys ??= (function () { $k = array_keys(osticket_tables()); usort($k, fn($a, $b) => strlen($b) <=> strlen($a)); return $k; })();
foreach ($keys as $key) {
if ($table === $key) return $key;
if (str_ends_with($table, '_' . $key) || str_ends_with($table, $key) && strlen($table) > strlen($key) && $table[strlen($table) - strlen($key) - 1] === '_') return $key;
}
return null;
}
/** Inhalt zwischen Anführungszeichen (MySQL-Maskierung) in den echten Text umwandeln. */
function osticket_unescape(string $s): string
{
if (!str_contains($s, '\\') && !str_contains($s, "''")) return $s;
return (string) preg_replace_callback('/\\\\(.)|\'\'/s', function (array $m): string {
if ($m[0] === "''") return "'";
return match ($m[1]) { '0' => "\0", 'n' => "\n", 'r' => "\r", 't' => "\t", 'b' => "\x08", 'Z' => "\x1a", '%' => '\\%', '_' => '\\_', default => $m[1] };
}, $s);
}
/**
* Zerlegt "(a,'b'),(c,NULL)" in Wertelisten. Strings werden entmaskiert, NULL wird null, alles andere (Zahlen, 0x…) bleibt Text.
* Funktioniert für eine Zeile pro Datensatz (phpMyAdmin) genauso wie für mehrere Datensätze in einer Zeile (mysqldump).
*
* @return Generator<int, array<int, ?string>>
*/
function osticket_rows(string $s): Generator
{
$n = strlen($s);
$i = 0;
while ($i < $n) {
$i = strpos($s, '(', $i);
if ($i === false) return;
$i++;
$vals = [];
$closed = false;
while ($i < $n) {
while ($i < $n && ($s[$i] === ' ' || $s[$i] === "\t")) $i++;
if ($i >= $n) break;
if ($s[$i] === "'") {
$j = $i + 1;
$buf = '';
while (true) {
$k = $j + strcspn($s, "'\\", $j);
if ($k >= $n) { $buf .= substr($s, $j); $j = $n; break; }
$buf .= substr($s, $j, $k - $j);
if ($s[$k] === '\\') { $buf .= substr($s, $k, 2); $j = $k + 2; continue; }
if ($k + 1 < $n && $s[$k + 1] === "'") { $buf .= "''"; $j = $k + 2; continue; }
$j = $k + 1;
break;
}
$vals[] = osticket_unescape($buf);
$i = $j;
} else {
$k = $i + strcspn($s, ',)', $i);
$tok = trim(substr($s, $i, $k - $i));
$vals[] = strcasecmp($tok, 'NULL') === 0 ? null : $tok;
$i = $k;
}
while ($i < $n && ($s[$i] === ' ' || $s[$i] === "\t")) $i++;
if ($i < $n && $s[$i] === ',') { $i++; continue; }
if ($i < $n && $s[$i] === ')') { $i++; $closed = true; break; }
}
if ($closed) yield $vals;
}
}
/** "INSERT INTO `t` (`a`,`b`) VALUES (..." -> [Tabelle, Spalten|null, Rest nach VALUES] oder null. */
function osticket_parse_header(string $line): ?array
{
if (!preg_match('/^INSERT\s+(?:IGNORE\s+)?INTO\s+`?([A-Za-z0-9_]+)`?\s*(?:\(([^)]*)\))?\s*VALUES\s*(.*)$/s', $line, $m)) return null;
$cols = isset($m[2]) && $m[2] !== '' ? array_map(fn($c) => trim($c, " `\t"), explode(',', $m[2])) : null;
return [$m[1], $cols, $m[3]];
}
// ─── Zustand ─────────────────────────────────────────────────────────────────
function osticket_state_dir(): string
{
$dir = sys_get_temp_dir() . '/deskhub_import_' . preg_replace('/[^a-zA-Z0-9]/', '', session_id() ?: 'cli');
if (!is_dir($dir)) @mkdir($dir, 0700, true);
return $dir;
}
function osticket_state_empty(): array
{
return ['users' => [], 'emails' => [], 'tickets' => [], 'cdata' => [], 'thread_ticket' => [], 'ticket_thread' => [], 'collab' => [], 'first_resp' => [],
'departments' => [], 'topics' => [], 'staff' => [], 'prios' => [], 'statuses' => [], 'att' => [], 'files' => [], 'inline_att' => 0,
'entry_count' => 0, 'chunk_start' => null, 'ctx' => null, 'done' => false, 'maps' => null];
}
function osticket_state_load(string $dir): array
{
$f = $dir . '/state.json';
$st = is_file($f) ? json_decode((string) file_get_contents($f), true) : null;
return is_array($st) ? $st + osticket_state_empty() : osticket_state_empty();
}
function osticket_state_save(string $dir, array $st): void
{
file_put_contents($dir . '/state.json', json_encode($st, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), LOCK_EX);
}
function osticket_state_clear(string $dir): void
{
foreach (glob($dir . '/*') ?: [] as $f) @unlink($f);
@rmdir($dir);
}
// ─── Analyse ─────────────────────────────────────────────────────────────────
/** Liest einen Block der Datei ab $offset. Gibt ['offset' => neuer Offset, 'done' => bool, 'counts' => [...]] zurück. */
function osticket_analyze_step(string $sqlPath, string $dir, int $offset, int $maxBytes = 4 * 1024 * 1024): array
{
if ($offset === 0) { foreach (['state.json', 'entries.jsonl', 'entry_map.jsonl'] as $f) @unlink($dir . '/' . $f); }
$st = osticket_state_load($dir);
$fh = fopen($sqlPath, 'rb');
if (!$fh) throw new RuntimeException('Die SQL-Datei konnte nicht geöffnet werden.');
fseek($fh, $offset);
$entries = fopen($dir . '/entries.jsonl', 'ab');
$tables = osticket_tables();
$read = 0;
$ctx = $st['ctx']; // ['key' => Tabellenschlüssel|null, 'cols' => Spalten]
while (!feof($fh) && $read < $maxBytes) {
$lineStart = $offset + $read;
$line = fgets($fh, 64 * 1024 * 1024);
if ($line === false) break;
$read += strlen($line);
if ($line[0] === 'I' && str_starts_with($line, 'INSERT')) {
$h = osticket_parse_header(rtrim($line));
$ctx = null;
if (!$h) continue;
$key = osticket_table_key($h[0]);
if ($key === null) continue;
if ($key === 'file_chunk') { $st['chunk_start'] ??= $lineStart; continue; }
$ctx = ['key' => $key, 'cols' => $h[1] ?? $tables[$key]];
$rest = $h[2];
if ($rest !== '') osticket_consume($st, $ctx, $rest, $entries);
if (str_ends_with(rtrim($line), ';')) $ctx = null;
continue;
}
if ($ctx === null) continue;
if ($line[0] !== '(') { $ctx = null; continue; }
osticket_consume($st, $ctx, $line, $entries);
if (substr(rtrim($line), -1) === ';') $ctx = null;
}
$newOffset = $offset + $read;
$done = feof($fh) || $line === false;
fclose($fh);
fclose($entries);
$st['ctx'] = $done ? null : $ctx;
if ($done) $st['done'] = true;
osticket_state_save($dir, $st);
return ['offset' => $newOffset, 'done' => $done, 'counts' => osticket_counts($st)];
}
/** Verarbeitet die Datensätze einer Zeile für die aktuelle Tabelle. */
function osticket_consume(array &$st, array $ctx, string $text, $entriesFh): void
{
$key = $ctx['key'];
$cols = $ctx['cols'];
$nc = count($cols);
foreach (osticket_rows($text) as $vals) {
if (count($vals) !== $nc) continue;
$r = array_combine($cols, $vals);
switch ($key) {
case 'user':
$st['users'][$r['id']] = ['n' => (string) ($r['name'] ?? ''), 'd' => (int) ($r['default_email_id'] ?? 0)];
break;
case 'user_email':
$st['emails'][$r['id']] = [(int) $r['user_id'], (string) $r['address']];
break;
case 'ticket':
$st['tickets'][$r['ticket_id']] = ['n' => (string) $r['number'], 'u' => (int) $r['user_id'], 's' => (int) $r['status_id'], 'd' => (int) $r['dept_id'], 't' => (int) $r['topic_id'],
'a' => (int) $r['staff_id'], 'src' => (string) ($r['source'] ?? ''), 'c' => $r['created'], 'cl' => $r['closed'] ?? null, 'lu' => $r['lastupdate'] ?? null,
'od' => (int) ($r['isoverdue'] ?? 0), 'ip' => (string) ($r['ip_address'] ?? '')];
break;
case 'ticket__cdata':
$st['cdata'][$r['ticket_id']] = [(string) ($r['subject'] ?? ''), (int) ($r['priority'] ?? 0)];
break;
case 'thread':
if (($r['object_type'] ?? '') === 'T') { $st['thread_ticket'][$r['id']] = (int) $r['object_id']; $st['ticket_thread'][$r['object_id']] = (int) $r['id']; }
break;
case 'thread_collaborator':
if ((int) $r['user_id'] > 0) $st['collab'][$r['thread_id']][] = (int) $r['user_id'];
break;
case 'thread_entry':
$st['entry_count']++;
if (($r['type'] ?? '') === 'R') {
$tid = (string) $r['thread_id'];
if (!isset($st['first_resp'][$tid]) || strcmp((string) $r['created'], $st['first_resp'][$tid]) < 0) $st['first_resp'][$tid] = (string) $r['created'];
}
fwrite($entriesFh, json_encode(['id' => (int) $r['id'], 'th' => (int) $r['thread_id'], 'st' => (int) $r['staff_id'], 'us' => (int) $r['user_id'], 'ty' => (string) $r['type'],
'po' => (string) ($r['poster'] ?? ''), 'b' => (string) ($r['body'] ?? ''), 'f' => (string) ($r['format'] ?? 'html'), 'c' => $r['created']], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) . "\n");
break;
case 'department':
$st['departments'][$r['id']] = (string) $r['name'];
break;
case 'help_topic':
$st['topics'][$r['topic_id']] = ['n' => (string) $r['topic'], 'd' => (int) $r['dept_id'], 'p' => (int) ($r['priority_id'] ?? 0), 'on' => (int) ($r['isactive'] ?? 1), 'pub' => (int) ($r['ispublic'] ?? 1)];
break;
case 'staff':
$st['staff'][$r['staff_id']] = ['n' => trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')), 'e' => strtolower((string) ($r['email'] ?? ''))];
break;
case 'ticket_priority':
$st['prios'][$r['priority_id']] = ['n' => (string) ($r['priority'] ?? ''), 'd' => (string) ($r['priority_desc'] ?? ''), 'u' => (int) ($r['priority_urgency'] ?? 0)];
break;
case 'ticket_status':
$st['statuses'][$r['id']] = ['n' => (string) $r['name'], 's' => (string) ($r['state'] ?? '')];
break;
case 'attachment':
if (($r['type'] ?? '') === 'H') {
if ((int) ($r['inline'] ?? 0) === 1) $st['inline_att']++;
else $st['att'][$r['object_id']][] = [(int) $r['file_id'], $r['name'] === null ? null : (string) $r['name']];
}
break;
case 'file':
$st['files'][$r['id']] = ['n' => (string) $r['name'], 't' => (string) ($r['type'] ?? ''), 's' => (int) ($r['size'] ?? 0)];
break;
}
}
}
/** E-Mail-Adresse und Name je osTicket-Kunde. */
function osticket_contacts(array $st): array
{
$byUser = [];
foreach ($st['emails'] as $id => [$uid, $addr]) $byUser[$uid][(int) $id] = $addr;
$out = [];
foreach ($st['users'] as $uid => $u) {
$addr = $byUser[(int) $uid][$u['d']] ?? (isset($byUser[(int) $uid]) ? reset($byUser[(int) $uid]) : '');
$out[(int) $uid] = ['name' => $u['n'], 'email' => trim((string) $addr)];
}
// Adressen ohne passenden Kundeneintrag trotzdem verfügbar machen
foreach ($byUser as $uid => $list) if (!isset($out[$uid])) $out[$uid] = ['name' => '', 'email' => trim((string) reset($list))];
return $out;
}
/** Status eines osTicket-Tickets -> DeskHub-Status oder null (gelöschte Tickets werden nicht importiert). */
function osticket_status(array $st, int $statusId): ?string
{
$s = $st['statuses'][$statusId] ?? null;
if ($s) {
$state = strtolower($s['s']);
if ($state === 'deleted') return null;
if ($state === 'open') return 'open';
if ($state === 'archived') return 'closed';
if ($state === 'closed') return preg_match('/gelöst|geloest|resolved/i', $s['n']) ? 'resolved' : 'closed';
}
return [1 => 'open', 2 => 'resolved', 3 => 'closed', 4 => 'closed'][$statusId] ?? ($statusId === 5 ? null : 'closed');
}
function osticket_counts(array $st): array
{
$tickets = 0;
foreach ($st['tickets'] as $t) if (osticket_status($st, $t['s']) !== null) $tickets++;
$attFiles = [];
foreach ($st['att'] as $list) foreach ($list as [$fid]) $attFiles[$fid] = $st['files'][$fid]['s'] ?? 0;
return ['users' => count(array_filter(osticket_contacts($st), fn($c) => $c['email'] !== '')), 'departments' => count($st['departments']), 'topics' => count($st['topics']),
'tickets' => $tickets, 'threads' => (int) $st['entry_count'], 'attachments' => count($attFiles), 'attachment_bytes' => array_sum($attFiles)];
}
@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
/**
* osTicket-Import, zweiter Teil: Übernahme der analysierten Daten in DeskHub (Durchgänge users, structure, tickets,
* threads, attachments). Siehe osticket_import.php für Analyse und Zustand.
*/
require_once __DIR__ . '/osticket_import.php';
const OSTICKET_CHUNK = 200;
function osticket_fallback_name(string $email, ?string $poster = null): string
{
$poster = trim((string) $poster);
if ($poster !== '') return $poster;
$email = trim($email);
if ($email !== '' && str_contains($email, '@')) return trim((string) strtok($email, '@')) ?: $email;
return $email !== '' ? $email : 'Kunde';
}
function osticket_html_to_text(string $body, string $format = 'html'): string
{
$body = trim($body);
if ($body === '') return '';
if (strtolower($format) !== 'html') {
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace("/\r\n?|\n/u", "\n", $body);
$body = preg_replace('/[ \t]+/u', ' ', $body);
return preg_replace("/\n{3,}/u", "\n\n", trim($body));
}
$body = preg_replace('/<blockquote\b[^>]*>.*?<\/blockquote>/is', "\n", $body);
$body = preg_replace('/<(br|\/p|\/div|\/li|\/tr|\/h[1-6])\b[^>]*>/i', "\n", $body);
$body = preg_replace('/<(p|div|li|tr|table|ul|ol|h[1-6])\b[^>]*>/i', "\n", $body);
$body = strip_tags($body);
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace('/\r\n?|\n/u', "\n", $body);
$body = preg_replace('/[ \t]+/u', ' ', $body);
return preg_replace('/\n{3,}/u', "\n\n", trim($body));
}
function normalize_osticket_body(string $body, string $format, string $authorType): string
{
$body = trim($body);
if ($body === '') return '';
$format = strtolower(trim($format));
if ($authorType === 'staff' || $authorType === 'system') {
if ($format === 'html') return sanitize_staff_html($body);
$parts = [];
foreach (preg_split('/\R{2,}/', preg_replace('/\r\n?/', "\n", $body)) ?: [$body] as $segment) {
$segment = trim($segment);
if ($segment !== '') $parts[] = '<p>' . nl2br(e($segment)) . '</p>';
}
return implode('', $parts) ?: nl2br(e($body));
}
if ($format === 'html') return osticket_html_to_text($body);
return preg_replace('/\n{3,}/', "\n\n", trim(preg_replace('/\r\n?/', "\n", $body)));
}
/** Datum aus dem Dump prüfen; sonst Ersatzwert. */
function osticket_date(?string $d, ?string $fallback = null): ?string
{
return $d !== null && preg_match('/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?/', $d) && !str_starts_with($d, '0000') ? $d : $fallback;
}
// ─── Zuordnungen (Abteilungen, Themen, Mitarbeiter, Prioritäten) ─────────────
/**
* Baut die Zuordnung osTicket-ID -> DeskHub-ID. Mit $create legt sie fehlende Abteilungen und Hilfethemen an, sonst
* werden nur vorhandene (gleicher Name) gefunden. Das Ergebnis wird im Zustand gespeichert.
*
* @return array{dept:array, topic:array, staff:array, prio:array, created:array{departments:int, topics:int}}
*/
function osticket_maps(PDO $pdo, array &$st, bool $create): array
{
$maps = ['dept' => [], 'topic' => [], 'staff' => [], 'prio' => [], 'created' => ['departments' => 0, 'topics' => 0]];
$defaultDept = (int) (get_setting('default_department_id') ?: 0);
if (!$defaultDept) $defaultDept = (int) $pdo->query('SELECT id FROM departments ORDER BY is_default DESC, id LIMIT 1')->fetchColumn();
$maps['default_dept'] = $defaultDept;
// Prioritäten: erst über den Namen, sonst über die Rangfolge (dringendste zuerst)
$dh = $pdo->query('SELECT id, name, resolution_hours FROM priorities ORDER BY resolution_hours, id')->fetchAll();
$maps['default_prio'] = (int) ($pdo->query("SELECT id FROM priorities WHERE LOWER(name) IN ('normal','mittel') ORDER BY id LIMIT 1")->fetchColumn() ?: ($dh[(int) floor((count($dh) - 1) / 2)]['id'] ?? 1));
$ost = $st['prios'];
uasort($ost, fn($a, $b) => ($a['u'] ?: 99) <=> ($b['u'] ?: 99));
$rank = 0;
$n = max(1, count($ost));
foreach ($ost as $id => $p) {
$match = null;
foreach ($dh as $d) if (strcasecmp($d['name'], $p['d']) === 0 || strcasecmp($d['name'], $p['n']) === 0) { $match = (int) $d['id']; break; }
$maps['prio'][(int) $id] = $match ?? (int) ($dh[(int) round($rank * (count($dh) - 1) / max(1, $n - 1))]['id'] ?? $maps['default_prio']);
$rank++;
}
// Abteilungen
$byName = [];
foreach ($pdo->query('SELECT id, name FROM departments')->fetchAll() as $d) $byName[mb_strtolower($d['name'])] = (int) $d['id'];
foreach ($st['departments'] as $id => $name) {
$key = mb_strtolower(trim((string) $name));
if ($key === '') continue;
if (isset($byName[$key])) { $maps['dept'][(int) $id] = $byName[$key]; continue; }
if ($create) {
$new = department_save($pdo, 0, ['name' => trim((string) $name)]);
$byName[$key] = $new;
$maps['dept'][(int) $id] = $new;
$maps['created']['departments']++;
}
}
// Hilfethemen
$topicByName = [];
foreach ($pdo->query('SELECT id, name FROM help_topics')->fetchAll() as $t) $topicByName[mb_strtolower($t['name'])] = (int) $t['id'];
foreach ($st['topics'] as $id => $t) {
$key = mb_strtolower(trim($t['n']));
if ($key === '') continue;
if (isset($topicByName[$key])) { $maps['topic'][(int) $id] = $topicByName[$key]; continue; }
if ($create) {
$new = help_topic_save($pdo, 0, ['name' => trim($t['n']), 'department_id' => $maps['dept'][$t['d']] ?? $defaultDept, 'priority_id' => $maps['prio'][$t['p']] ?? $maps['default_prio'],
'is_active' => $t['on'] ? 1 : 0, 'is_public' => $t['pub'] ? 1 : 0]);
$topicByName[$key] = $new;
$maps['topic'][(int) $id] = $new;
$maps['created']['topics']++;
}
}
// Mitarbeiter über die E-Mail-Adresse
$staffByEmail = [];
foreach ($pdo->query('SELECT id, email FROM staff')->fetchAll() as $s) $staffByEmail[strtolower((string) $s['email'])] = (int) $s['id'];
foreach ($st['staff'] as $id => $s) if ($s['e'] !== '' && isset($staffByEmail[$s['e']])) $maps['staff'][(int) $id] = $staffByEmail[$s['e']];
$st['maps'] = $maps;
return $maps;
}
// ─── Durchgänge ──────────────────────────────────────────────────────────────
/**
* Führt einen Schritt eines Durchgangs aus.
*
* @return array{inserted:int, skipped:int, processed:int, next_offset:int, done:bool, notes:array}
*/
function osticket_import_step(PDO $pdo, string $dir, string $pass, int $offset, string $sqlPath = ''): array
{
@set_time_limit(300);
$st = osticket_state_load($dir);
$r = ['inserted' => 0, 'skipped' => 0, 'processed' => 0, 'next_offset' => $offset, 'done' => false, 'notes' => []];
switch ($pass) {
case 'users': osticket_pass_users($pdo, $st, $offset, $r); break;
case 'structure':
$maps = osticket_maps($pdo, $st, true);
$r['inserted'] = $maps['created']['departments'] + $maps['created']['topics'];
$r['notes'][] = "{$maps['created']['departments']} Abteilung(en) und {$maps['created']['topics']} Hilfethema(en) angelegt, vorhandene gleichen Namens wiederverwendet.";
$r['done'] = true;
break;
case 'tickets': osticket_pass_tickets($pdo, $st, $offset, $r); break;
case 'threads': osticket_pass_threads($pdo, $st, $dir, $offset, $r); break;
case 'attachments': osticket_pass_attachments($pdo, $st, $dir, $sqlPath, $offset, $r); break;
default: throw new InvalidArgumentException('Unbekannter Durchgang.');
}
osticket_state_save($dir, $st);
return $r;
}
function osticket_pass_users(PDO $pdo, array &$st, int $offset, array &$r): void
{
$contacts = osticket_contacts($st);
$ids = array_keys($contacts);
$slice = array_slice($ids, $offset, OSTICKET_CHUNK);
$pw = password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT);
$chk = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$ins = $pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)');
foreach ($slice as $uid) {
$c = $contacts[$uid];
if ($c['email'] === '' || !str_contains($c['email'], '@')) { $r['skipped']++; continue; }
$chk->execute([$c['email']]);
if ($chk->fetchColumn()) { $r['skipped']++; continue; }
$ins->execute([mb_substr(osticket_fallback_name($c['email'], $c['name']), 0, 120), mb_substr($c['email'], 0, 150), $pw, db_now()]);
$r['inserted']++;
}
$r['processed'] = count($slice);
$r['next_offset'] = $offset + count($slice);
$r['done'] = count($slice) < OSTICKET_CHUNK;
}
function osticket_pass_tickets(PDO $pdo, array &$st, int $offset, array &$r): void
{
$maps = $st['maps'] ?? osticket_maps($pdo, $st, false);
$contacts = osticket_contacts($st);
$prios = [];
foreach ($pdo->query('SELECT * FROM priorities')->fetchAll() as $p) $prios[(int) $p['id']] = $p;
$keys = array_keys($st['tickets']);
$slice = array_slice($keys, $offset, OSTICKET_CHUNK);
$chk = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$getUser = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$ins = $pdo->prepare('INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id, help_topic_id, assigned_staff_id, priority_id, status, source, requester_ip,
first_response_due_at, first_responded_at, resolution_due_at, resolved_at, closed_at, is_overdue, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)');
$sources = ['web' => 'web', 'email' => 'email', 'phone' => 'phone', 'api' => 'api'];
foreach ($slice as $tid) {
$t = $st['tickets'][$tid];
$status = osticket_status($st, $t['s']);
if ($status === null) { $r['skipped']++; $r['notes']['deleted'] = 'Gelöschte osTicket-Tickets wurden nicht importiert.'; continue; }
if ($t['n'] === '') { $r['skipped']++; continue; }
$chk->execute([$t['n']]);
if ($chk->fetchColumn()) { $r['skipped']++; $r['notes']['existing'] = 'Bereits vorhandene Ticketnummern wurden übersprungen.'; continue; }
$contact = $contacts[$t['u']] ?? ['name' => '', 'email' => ''];
$email = $contact['email'];
$name = mb_substr(osticket_fallback_name($email, $contact['name']), 0, 120);
$userId = null;
if ($email !== '') { $getUser->execute([$email]); $userId = $getUser->fetchColumn() ?: null; }
$cdata = $st['cdata'][$tid] ?? ['', 0];
$subject = mb_substr(trim($cdata[0]) !== '' ? trim($cdata[0]) : 'Ticket #' . $t['n'], 0, 255);
$deptId = $maps['dept'][$t['d']] ?? $maps['default_dept'];
$topicId = $maps['topic'][$t['t']] ?? null;
$staffId = $maps['staff'][$t['a']] ?? null;
if ($t['a'] > 0 && !$staffId) $r['unassigned'] = ($r['unassigned'] ?? 0) + 1;
$prioId = $maps['prio'][$cdata[1]] ?? $maps['default_prio'];
$created = osticket_date($t['c'], db_now());
$updated = osticket_date($t['lu'], $created);
$closedAt = in_array($status, ['resolved', 'closed'], true) ? osticket_date($t['cl'], $updated) : null;
$threadId = $st['ticket_thread'][$tid] ?? null;
$firstResp = $threadId !== null ? osticket_date($st['first_resp'][(string) $threadId] ?? null) : null;
$firstDue = $resDue = null;
if (!in_array($status, ['resolved', 'closed'], true) && isset($prios[$prioId])) {
$sla = calc_sla_dates($prios[$prioId], $created, $deptId, $topicId);
$firstDue = $firstResp ? null : $sla['first_response_due_at'];
$resDue = $sla['resolution_due_at'];
}
$ins->execute([$t['n'], $subject, $userId, $name, mb_substr($email, 0, 150), $deptId, $topicId, $staffId, $prioId, $status, $sources[strtolower($t['src'])] ?? 'web',
$t['ip'] !== '' ? mb_substr($t['ip'], 0, 45) : null, $firstDue, $firstResp, $resDue, $closedAt, $closedAt, ($t['od'] && $closedAt === null) ? 1 : 0, $created, $updated]);
$r['inserted']++;
}
if (!empty($r['unassigned'])) $r['notes']['unassigned'] = $r['unassigned'] . ' Ticket(s) waren Mitarbeitern zugewiesen, die es in DeskHub nicht gibt (Zuordnung über gleiche E-Mail-Adresse). Sie sind jetzt unzugewiesen.';
unset($r['unassigned']);
$r['processed'] = count($slice);
$r['next_offset'] = $offset + count($slice);
$r['done'] = count($slice) < OSTICKET_CHUNK;
}
function osticket_pass_threads(PDO $pdo, array &$st, string $dir, int $offset, array &$r): void
{
$maps = $st['maps'] ?? osticket_maps($pdo, $st, false);
$contacts = osticket_contacts($st);
$file = $dir . '/entries.jsonl';
$fh = is_file($file) ? fopen($file, 'rb') : null;
$lines = 0;
$end = true;
$ticketByNumber = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$getUser = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$exists = $pdo->prepare('SELECT id FROM ticket_threads WHERE ticket_id = ? AND created_at = ? AND author_type = ? AND message = ? LIMIT 1');
$ins = $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, user_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?,?)');
$entryMap = $fh ? fopen($dir . '/entry_map.jsonl', 'ab') : null;
$ticketCache = [];
$lastTouched = [];
if ($fh) {
fseek($fh, $offset);
while ($lines < OSTICKET_CHUNK && ($line = fgets($fh)) !== false) {
$lines++;
$e = json_decode($line, true);
if (!is_array($e)) { $r['skipped']++; continue; }
$osTid = $st['thread_ticket'][(string) $e['th']] ?? null;
$number = $osTid !== null ? ($st['tickets'][(string) $osTid]['n'] ?? null) : null;
if ($number === null || $number === '') { $r['skipped']++; continue; }
if (!array_key_exists($number, $ticketCache)) { $ticketByNumber->execute([$number]); $ticketCache[$number] = (int) $ticketByNumber->fetchColumn() ?: null; }
$dhTid = $ticketCache[$number];
if (!$dhTid) { $r['skipped']++; $r['notes']['no_ticket'] = 'Nachrichten zu nicht importierten Tickets wurden übersprungen.'; continue; }
$type = strtoupper($e['ty']);
$staffId = $e['st'] > 0 ? ($maps['staff'][$e['st']] ?? null) : null;
$userId = null;
$internal = 0;
if ($type === 'N') { $author = $staffId ? 'staff' : 'system'; $internal = 1; }
elseif ($e['st'] > 0 || $type === 'R') { $author = 'staff'; }
else {
$author = 'customer';
$email = $contacts[$e['us']]['email'] ?? '';
if ($email !== '') { $getUser->execute([$email]); $userId = $getUser->fetchColumn() ?: null; }
}
if (($e['st'] > 0 || $type === 'R') && !$staffId) $r['notes']['staff'] = 'Antworten von Mitarbeitern, die es in DeskHub nicht gibt (gleiche E-Mail-Adresse nötig), wurden ohne Mitarbeiter übernommen.';
$hasAtt = !empty($st['att'][(string) $e['id']]);
$body = normalize_osticket_body($e['b'], $e['f'], $author);
if (trim(strip_tags($body)) === '') {
if (!$hasAtt) { $r['skipped']++; continue; }
$body = $author === 'customer' ? '(Nachricht ohne Text, siehe Anhang)' : '<p>(Nachricht ohne Text, siehe Anhang)</p>';
}
$created = osticket_date($e['c'], db_now());
$exists->execute([$dhTid, $created, $author, $body]);
$threadId = $exists->fetchColumn();
if (!$threadId) {
$ins->execute([$dhTid, $author, $staffId, $userId, $body, $internal, $created]);
$threadId = (int) $pdo->lastInsertId();
$r['inserted']++;
} else {
$r['skipped']++;
}
if ($hasAtt && $entryMap) fwrite($entryMap, json_encode([(int) $e['id'], (int) $threadId]) . "\n");
}
$r['next_offset'] = ftell($fh);
$end = feof($fh) || fgets($fh) === false;
fclose($fh);
if ($entryMap) fclose($entryMap);
}
$r['processed'] = $lines;
$r['done'] = $end;
if ($end) { // zum Schluss: Mitlesende (Collaborators)
$addedCollab = 0;
$existsC = $pdo->prepare('SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)');
$insC = $pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, created_at) VALUES (?,?,?,?)');
foreach ($st['collab'] as $threadId => $uids) {
$osTid = $st['thread_ticket'][(string) $threadId] ?? null;
$number = $osTid !== null ? ($st['tickets'][(string) $osTid]['n'] ?? null) : null;
if (!$number) continue;
$ticketByNumber->execute([$number]);
$dhTid = (int) $ticketByNumber->fetchColumn();
if (!$dhTid) continue;
foreach (array_unique($uids) as $uid) {
$c = $contacts[$uid] ?? null;
if (!$c || $c['email'] === '') continue;
$existsC->execute([$dhTid, $c['email']]);
if ((int) $existsC->fetchColumn() > 0) continue;
$insC->execute([$dhTid, $c['email'], mb_substr(osticket_fallback_name($c['email'], $c['name']), 0, 120), db_now()]);
$addedCollab++;
}
}
if ($addedCollab) $r['notes']['collab'] = "{$addedCollab} Mitlesende übernommen.";
}
}
/** Dateiendung für gespeicherte Anhänge: nur bekannte, ungefährliche Typen, alles andere wird .bin. */
function osticket_safe_extension(string $name): string
{
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$safe = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'pdf', 'txt', 'log', 'csv', 'rtf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'zip', 'eml', 'msg', 'mp3', 'mp4', 'mov', 'heic'];
return in_array($ext, $safe, true) ? $ext : 'bin';
}
function osticket_pass_attachments(PDO $pdo, array &$st, string $dir, string $sqlPath, int $offset, array &$r): void
{
// Welche Dateien werden gebraucht? (nur Anhänge von Nachrichten, die importiert wurden und nicht zu groß sind)
$wanted = [];
foreach ($st['att'] as $entryId => $list) foreach ($list as [$fileId]) if (isset($st['files'][$fileId]) && $st['files'][$fileId]['s'] <= OSTICKET_ATTACHMENT_MAX_BYTES) $wanted[$fileId] = true;
$parts = $dir . '/files';
if (!is_dir($parts)) @mkdir($parts, 0700, true);
if (!$wanted || $st['chunk_start'] === null || $sqlPath === '' || !is_file($sqlPath)) {
$r['notes'][] = $wanted ? 'In der Datei wurden keine Anhangsdaten gefunden.' : 'Keine Anhänge zum Übernehmen.';
$r['done'] = true;
return;
}
$start = $offset > 0 ? $offset : (int) $st['chunk_start'];
$fh = fopen($sqlPath, 'rb');
fseek($fh, $start);
$read = 0;
$ended = false;
$inChunks = $offset > 0;
while (!feof($fh) && $read < 16 * 1024 * 1024) {
$line = fgets($fh, 64 * 1024 * 1024);
if ($line === false) break;
$read += strlen($line);
$text = null;
if ($line[0] === '(') {
$text = $line;
} elseif (str_starts_with($line, 'INSERT')) {
$h = osticket_parse_header(rtrim($line));
if (!$h || osticket_table_key($h[0]) !== 'file_chunk') { $ended = true; break; }
$inChunks = true;
$text = $h[2]; // mysqldump: die Datensätze stehen in derselben Zeile wie INSERT
}
if ($text !== null && $text !== '') {
foreach (osticket_rows($text) as $row) {
if (count($row) < 3) continue;
$fileId = (int) $row[0];
if (!isset($wanted[$fileId])) continue;
$raw = (string) $row[2];
$data = str_starts_with($raw, '0x') ? (strlen($raw) > 2 ? (string) hex2bin(strlen($raw) % 2 ? '0' . substr($raw, 2) : substr($raw, 2)) : '') : $raw;
file_put_contents($parts . '/' . $fileId . '.part', $data, FILE_APPEND);
}
continue;
}
if (str_starts_with($line, 'CREATE') || str_starts_with($line, 'ALTER') || str_starts_with($line, 'COMMIT')) { $ended = $inChunks || $ended || true; break; }
}
$next = $start + $read;
$atEof = feof($fh);
fclose($fh);
$r['processed'] = 1;
$r['next_offset'] = $next;
if (!$ended && !$atEof) return;
// Fertig mit dem Lesen: Dateien in den Upload-Ordner legen und mit den Nachrichten verknüpfen
$entryThread = [];
$mapFile = $dir . '/entry_map.jsonl';
if (is_file($mapFile)) foreach (file($mapFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) { $p = json_decode($l, true); if (is_array($p)) $entryThread[$p[0]] = $p[1]; }
$uploads = rtrim((string) config()['uploads']['path'], '/' . chr(92));
if (!is_dir($uploads)) @mkdir($uploads, 0755, true);
$ins = $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)');
$dup = $pdo->prepare('SELECT id FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ? LIMIT 1');
$broken = 0; $tooBig = 0; $noThread = 0;
foreach ($st['att'] as $entryId => $list) {
$threadId = $entryThread[(int) $entryId] ?? null;
foreach ($list as [$fileId, $attName]) {
$meta = $st['files'][$fileId] ?? null;
if (!$meta) continue;
if ($meta['s'] > OSTICKET_ATTACHMENT_MAX_BYTES) { $tooBig++; continue; }
if (!$threadId) { $noThread++; continue; }
$part = $parts . '/' . $fileId . '.part';
if (!is_file($part) || filesize($part) !== $meta['s']) { $broken++; continue; }
$orig = mb_substr(trim(basename(str_replace(chr(92), '/', ($attName !== null && $attName !== '' ? $attName : $meta['n']) ?: 'Anhang'))), 0, 255) ?: 'Anhang';
$dup->execute([$threadId, $orig, $meta['s']]);
if ($dup->fetchColumn()) { $r['skipped']++; continue; }
$stored = bin2hex(random_bytes(16)) . '.' . osticket_safe_extension($orig);
if (!copy($part, $uploads . '/' . $stored)) { $broken++; continue; }
$ins->execute([$threadId, $orig, $stored, $meta['s'], db_now()]);
$r['inserted']++;
}
}
foreach (glob($parts . '/*.part') ?: [] as $f) @unlink($f);
if ($broken) $r['notes']['broken'] = "{$broken} Anhang/Anhänge unvollständig oder nicht lesbar und übersprungen.";
if ($tooBig) $r['notes']['big'] = "{$tooBig} Anhang/Anhänge größer als " . (OSTICKET_ATTACHMENT_MAX_BYTES / 1048576) . ' MB übersprungen.';
if ($noThread) $r['notes']['nothread'] = "{$noThread} Anhang/Anhänge gehören zu Nachrichten, die nicht importiert wurden (Nachrichten zuerst importieren).";
$r['done'] = true;
}
+852
View File
@@ -0,0 +1,852 @@
<?php
declare(strict_types=1);
/**
* Plugin-System.
*
* Ein Plugin ist ein Ordner plugins/<slug>/ mit plugin.json und einer Startdatei (Standard: plugin.php).
* Die Startdatei registriert beim Laden ihre Erweiterungen:
*
* plugin_add_hook('ticket.created', function (array $e) { ... }); // auf Ereignisse reagieren
* plugin_add_filter('ticket.view.sidebar', fn(string $html, array $c) => $html . '...'); // Inhalte ergänzen
* plugin_register_menu('Mein Plugin', 'meine_seite'); // Menüpunkt -> plugins/<slug>/pages/meine_seite.php
*
* SICHERHEIT: Plugins sind PHP-Code und laufen mit allen Rechten des Systems. Es gibt KEINE Sandbox.
* - Berechtigungen (plugin.json "permissions") begrenzen, welche ERWEITERUNGSPUNKTE ein Plugin registrieren darf
* (Ticket-Ereignisse, Cron, Menü, Seitenleiste, API, eigene Tabellen). Der Admin bestätigt sie bei der Aktivierung.
* Sie verhindern NICHT, dass beliebiger PHP-Code im Plugin Dateien oder die Datenbank anfasst.
* - Signaturen (RSA-SHA256, Herausgeber-Schlüssel) belegen die Herkunft eines Pakets; das ist der eigentliche Schutz.
* - Fehler-Isolation und automatische Abschaltung verhindern, dass ein defektes Plugin das System lahmlegt.
*/
const PLUGIN_FAIL_LIMIT = 3; // so viele Fehler seit der letzten Aktivierung, dann wird das Plugin automatisch deaktiviert
const PLUGIN_MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
const PLUGIN_ALLOWED_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html'];
/** Bekannte Berechtigungen (Erweiterungspunkte), die ein Plugin in plugin.json anfordern kann. */
function plugin_known_permissions(): array
{
return [
'hooks.tickets' => 'Auf Ticket-Ereignisse reagieren (erstellt, Antwort, Status, Zuweisung)',
'hooks.cron' => 'Regelmäßig im Hintergrund laufen (etwa alle 5 Minuten)',
'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich',
'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht',
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
];
}
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
function &plugin_state(): array
{
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
return $state;
}
function plugins_dir(): string
{
return rtrim((string) (getenv('DESKHUB_PLUGINS_DIR') ?: dirname(__DIR__) . '/plugins'), '/\\');
}
function plugin_valid_slug(string $slug): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9_-]{1,40}$/', $slug);
}
/** Notausschalter: Datei plugins/.disabled anlegen oder in config.php 'plugins' => ['enabled' => false]. */
function plugins_globally_disabled(): bool
{
return is_file(plugins_dir() . '/.disabled') || (config()['plugins']['enabled'] ?? true) === false;
}
/** Liest und prüft plugin.json. Gibt [manifest|null, Fehlertext|null] zurück. */
function plugin_read_manifest(string $dir): array
{
$file = $dir . '/plugin.json';
if (!is_file($file)) return [null, 'plugin.json fehlt.'];
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return [null, 'plugin.json ist kein gültiges JSON.'];
foreach (['name', 'version'] as $required) {
if (!isset($data[$required]) || !is_string($data[$required]) || trim($data[$required]) === '') return [null, "plugin.json: Feld \"{$required}\" fehlt."];
}
if (!preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $data['version'])) return [null, 'plugin.json: ungültige Versionsnummer.'];
$entry = (string) ($data['entry'] ?? 'plugin.php');
if (!preg_match('/^[A-Za-z0-9_\-]+\.php$/', $entry)) return [null, 'plugin.json: ungültige Startdatei.'];
if (!is_file($dir . '/' . $entry)) return [null, "Startdatei {$entry} fehlt."];
$requires = (string) ($data['requires'] ?? '');
if ($requires !== '' && defined('DESKHUB_VERSION') && version_compare(DESKHUB_VERSION, $requires, '<')) {
return [null, "Benötigt DeskHub {$requires} oder neuer (installiert: " . DESKHUB_VERSION . ').'];
}
$permissions = array_values(array_unique(array_map('strval', (array) ($data['permissions'] ?? []))));
foreach ($permissions as $perm) {
if (!isset(plugin_known_permissions()[$perm])) return [null, "plugin.json: unbekannte Berechtigung \"{$perm}\". Erlaubt: " . implode(', ', array_keys(plugin_known_permissions())) . '.'];
}
sort($permissions);
return [[
'name' => mb_substr(trim($data['name']), 0, 120),
'version' => $data['version'],
'description' => mb_substr((string) ($data['description'] ?? ''), 0, 500),
'author' => mb_substr((string) ($data['author'] ?? ''), 0, 120),
'requires' => $requires,
'entry' => $entry,
'permissions' => $permissions,
], null];
}
// ─── Registrierung (wird von Plugins aufgerufen) ─────────────────────────────
/**
* Darf das gerade ladende Plugin diesen Erweiterungspunkt nutzen? Ohne laufendes Plugin (Kerncode) immer ja.
* Verweigerte Registrierungen werden am Plugin vermerkt (Fehlertext im Adminportal), zählen aber nicht als Absturz.
*/
function plugin_permission_allowed(?string $permission): bool
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || $permission === null) return true;
if (in_array($permission, $state['perms'][$slug] ?? [], true)) return true;
$msg = "Nicht deklarierte Berechtigung \"{$permission}\": Die Registrierung wurde abgelehnt. Bitte in plugin.json ergänzen.";
error_log("plugin[{$slug}]: {$msg}");
try {
get_db()->prepare('UPDATE plugins SET last_error = ? WHERE slug = ?')->execute([$msg, $slug]);
} catch (Throwable $e) {
}
return false;
}
/** Erforderliche Berechtigung für das Abonnieren eines Ereignisses. */
function plugin_hook_permission(string $hook): ?string
{
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'cron.run') return 'hooks.cron';
return null;
}
/** Erforderliche Berechtigung für einen Filter. */
function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
return null;
}
/** Reagiert auf ein Ereignis (Rückgabewert wird ignoriert). */
function plugin_add_hook(string $hook, callable $fn, int $priority = 10): void
{
if (!plugin_permission_allowed(plugin_hook_permission($hook))) return;
$state = &plugin_state();
$state['hooks'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
}
/** Verändert einen Wert (Rückgabewert ersetzt den Wert). */
function plugin_add_filter(string $hook, callable $fn, int $priority = 10): void
{
if (!plugin_permission_allowed(plugin_filter_permission($hook))) return;
$state = &plugin_state();
$state['filters'][$hook][$priority][] = ['fn' => $fn, 'plugin' => $state['current']];
}
/** Menüpunkt im Mitarbeiterbereich. $page ist der Dateiname unter plugins/<slug>/pages/ (ohne .php). */
function plugin_register_menu(string $label, string $page, ?string $permission = null): void
{
if (!plugin_permission_allowed('ui.menu')) return;
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $page)) return;
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission];
}
/**
* Eigener API-Endpunkt: GET/POST /api/staff/v1/plugin.php?p=<slug>&endpoint=<name> (Bearer-Token wie die übrige Mitarbeiter-API).
* Der Handler erhält ($staff, $request) und gibt ein Array zurück, das als JSON ausgeliefert wird.
*/
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
{
if (!plugin_permission_allowed('api.endpoints')) return;
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $endpoint)) return;
$state['api'][$slug][$endpoint] = ['fn' => $handler, 'permission' => $permission];
}
function plugin_setting(string $slug, string $key, ?string $default = null): ?string
{
return get_setting("plugin.{$slug}.{$key}", $default);
}
function plugin_set_setting(string $slug, string $key, ?string $value): void
{
set_setting("plugin.{$slug}.{$key}", $value);
}
/** Slug des Plugins, dessen Code gerade ausgeführt wird (für Einstellungen u. Ä. innerhalb des Plugins). */
function plugin_current_slug(): ?string
{
return plugin_state()['current'];
}
// ─── Ausführung mit Fehler-Isolation ─────────────────────────────────────────
/** Führt Plugin-Code aus; Fehler werden dem Plugin zugeordnet und legen nie die aufrufende Seite lahm. */
function plugin_guard(?string $slug, callable $fn, $fallback = null)
{
$state = &plugin_state();
$previous = $state['current'];
$state['current'] = $slug;
try {
return $fn();
} catch (Throwable $e) {
if ($slug !== null) plugin_record_failure($slug, $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
else error_log('plugin: ' . $e->getMessage());
return $fallback;
} finally {
$state['current'] = $previous;
}
}
function plugin_record_failure(string $slug, string $message): void
{
error_log("plugin[{$slug}]: {$message}");
try {
$pdo = get_db();
$pdo->prepare('UPDATE plugins SET fail_count = fail_count + 1, last_error = ? WHERE slug = ?')->execute([mb_substr($message, 0, 500), $slug]);
$fails = (int) $pdo->query('SELECT fail_count FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn();
if ($fails >= PLUGIN_FAIL_LIMIT) {
$pdo->prepare('UPDATE plugins SET is_active = 0 WHERE slug = ?')->execute([$slug]);
error_log("plugin[{$slug}]: nach {$fails} Fehlern automatisch deaktiviert");
}
} catch (Throwable $e) {
// Die Fehlerbehandlung selbst darf nie einen weiteren Fehler auslösen.
}
}
/** Löst ein Ereignis aus. Alle registrierten Plugin-Funktionen laufen isoliert. */
function plugin_hook(string $hook, array $payload = []): void
{
$state = &plugin_state();
if (empty($state['hooks'][$hook])) return;
$byPriority = $state['hooks'][$hook];
ksort($byPriority);
foreach ($byPriority as $list) {
foreach ($list as $entry) plugin_guard($entry['plugin'], fn() => ($entry['fn'])($payload));
}
}
/** Reicht einen Wert durch alle Filter. Bei Fehlern bleibt der bisherige Wert erhalten. */
function plugin_filter(string $hook, $value, array $context = [])
{
$state = &plugin_state();
if (empty($state['filters'][$hook])) return $value;
$byPriority = $state['filters'][$hook];
ksort($byPriority);
foreach ($byPriority as $list) {
foreach ($list as $entry) $value = plugin_guard($entry['plugin'], fn() => ($entry['fn'])($value, $context), $value);
}
return $value;
}
// ─── Laden ───────────────────────────────────────────────────────────────────
/** Lädt alle aktiven Plugins (einmal pro Request). */
function plugins_boot(): void
{
$state = &plugin_state();
if ($state['booted']) return;
$state['booted'] = true;
if (plugins_globally_disabled()) return;
try {
$rows = get_db()->query('SELECT slug, approved_permissions, signature_ok FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll();
} catch (Throwable $e) {
return; // Tabelle fehlt (Datenbank noch nicht aktualisiert)
}
if (!$rows) return;
// Harte Fehler (Fatal Error) lassen sich nicht abfangen: Sie dem Plugin zuordnen und es abschalten.
if (!$state['shutdown']) {
$state['shutdown'] = true;
register_shutdown_function(function () {
$err = error_get_last();
$current = plugin_state()['current'];
if ($current !== null && $err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
try {
get_db()->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute([mb_substr('Schwerer Fehler: ' . $err['message'], 0, 500), $current]);
} catch (Throwable $e) {
}
}
});
}
foreach ($rows as $row) {
$slug = (string) $row['slug'];
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
[$manifest, $error] = plugin_read_manifest($dir);
if (!$manifest) { plugin_record_failure($slug, (string) $error); continue; }
// Nur laden, wenn der Admin alle angeforderten Berechtigungen bestätigt hat (z. B. nach einem Update mit neuen Rechten)
$approved = array_filter(explode(',', (string) ($row['approved_permissions'] ?? '')));
$missing = array_diff($manifest['permissions'], $approved);
if ($missing) {
get_db()->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Neue Berechtigungen müssen bestätigt werden: ' . implode(', ', $missing) . '. Das Plugin wurde deaktiviert.', $slug]);
continue;
}
$state['perms'][$slug] = $manifest['permissions'];
plugin_guard($slug, function () use ($dir, $manifest, $slug, &$state) {
require $dir . '/' . $manifest['entry']; // einmal pro Request (plugins_boot läuft nur einmal)
$state['loaded'][$slug] = $manifest;
});
}
}
/** Menüpunkte der geladenen Plugins, gefiltert nach Rechten des Mitarbeiters. */
function plugin_menu_items(?array $staff): array
{
$items = [];
foreach (plugin_state()['menus'] as $m) {
if ($m['permission'] !== null && (!$staff || !has_permission($staff, $m['permission']))) continue;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
// ─── Verwaltung (Installieren, Aktivieren, Entfernen) ────────────────────────
function plugins_installed(PDO $pdo): array
{
$rows = [];
try {
foreach ($pdo->query('SELECT * FROM plugins ORDER BY name') as $r) $rows[$r['slug']] = $r;
} catch (Throwable $e) {
return [];
}
return $rows;
}
// ─── Signaturen ──────────────────────────────────────────────────────────────
/**
* Offizielle Vorgaben dieser DeskHub-Version (includes/catalog_defaults.php, Teil des Programmcodes).
* Es gibt KEINE Einstellung, Umgebungsvariable oder Oberfläche, die sie überschreibt. Nur Tests dürfen sie ersetzen, und nur
* wenn die Umgebungsvariable DESKHUB_TESTING=1 gesetzt ist (auf einem Server nie der Fall).
*/
function catalog_defaults(): array
{
static $file = null;
$testing = getenv('DESKHUB_TESTING') === '1';
$override = $testing ? getenv('DESKHUB_CATALOG_DEFAULTS') : false;
$d = ($testing ? ($GLOBALS['deskhub_catalog_defaults'] ?? null) : null)
?? ($override && is_file($override) ? (array) require $override : ($file ??= (array) require __DIR__ . '/catalog_defaults.php'));
return ['plugins_url' => trim((string) ($d['plugins_url'] ?? '')), 'themes_url' => trim((string) ($d['themes_url'] ?? '')), 'publisher_key' => trim((string) ($d['publisher_key'] ?? '')),
'access_key' => trim((string) ($d['access_key'] ?? ''))];
}
/**
* Zusätzliche Header für einen Abruf beim offiziellen Katalog-Server: der feste Zugangsschlüssel. Er wird NUR an diesen Server
* geschickt (gleicher Host wie die offiziellen Adressen, nur https), nie an andere Adressen.
*/
function catalog_access_headers(string $url): array
{
$d = catalog_defaults();
if ($d['access_key'] === '' || !str_starts_with($url, 'https://')) return [];
$host = strtolower((string) parse_url($url, PHP_URL_HOST));
foreach ([$d['plugins_url'], $d['themes_url']] as $official) {
if ($official !== '' && $host !== '' && $host === strtolower((string) parse_url($official, PHP_URL_HOST))) return ['X-DeskHub-Key: ' . $d['access_key']];
}
return [];
}
/** Hat der Herausgeber dieser Version einen offiziellen, geprüften Katalog eingetragen? */
function plugin_official_configured(): bool
{
$d = catalog_defaults();
return $d['plugins_url'] !== '' && $d['publisher_key'] !== '';
}
/**
* "Katalog fest": gilt immer, sobald ein offizieller Katalog eingetragen ist. Katalog-Adresse und Herausgeber-Schlüssel sind dann
* fest vorgegeben, und aus dem Katalog lässt sich nur installieren, was der Herausgeber signiert hat. Der Admin kann das nicht
* ändern. Eigene Plugins (manueller Upload, nicht signiert) bleiben ausdrücklich erlaubt, sie gelten aber als "nicht geprüft".
*/
function plugin_strict_mode(): bool
{
return plugin_official_configured();
}
/** Öffentlicher Herausgeber-Schlüssel (PEM). Im strengen Modus immer der offizielle. Leer = Signaturen können nicht geprüft werden. */
function plugin_publisher_key(): string
{
if (plugin_strict_mode()) return catalog_defaults()['publisher_key'];
$own = trim((string) get_setting('plugin_publisher_key', ''));
return $own !== '' ? $own : catalog_defaults()['publisher_key'];
}
/**
* Muss das Paket signiert sein? Aus dem Katalog: immer, sobald der offizielle Katalog fest eingebaut ist. Eigene Uploads dürfen
* unsigniert sein, es sei denn, ohne offiziellen Katalog hat der Admin "Nur signierte Plugins" eingeschaltet.
*/
function plugin_signatures_required(string $source = 'upload'): bool
{
$setting = get_setting('plugin_require_signature', '0') === '1';
if ($source === 'catalog') return plugin_strict_mode() || $setting;
return !plugin_strict_mode() && $setting;
}
/** Die signierte Nachricht bindet Name, Version und Inhalt (SHA-256) des Pakets aneinander. */
function plugin_signature_message(string $slug, string $version, string $sha256): string
{
return "deskhub-plugin\n{$slug}\n{$version}\n" . strtolower($sha256);
}
/** Prüft eine Base64-Signatur (RSA, SHA-256) mit einem öffentlichen PEM-Schlüssel. */
function plugin_verify_signature(string $message, string $signatureBase64, string $publicKeyPem): bool
{
if (!function_exists('openssl_verify')) return false;
$sig = base64_decode(trim($signatureBase64), true);
$key = @openssl_pkey_get_public($publicKeyPem);
if ($sig === false || $sig === '' || $key === false) return false;
return openssl_verify($message, $sig, $key, OPENSSL_ALGO_SHA256) === 1;
}
/** Ist das ein brauchbarer öffentlicher RSA-Schlüssel (PEM)? */
function plugin_public_key_valid(string $pem): bool
{
$key = @openssl_pkey_get_public($pem);
if ($key === false) return false;
$details = openssl_pkey_get_details($key);
return ($details['type'] ?? null) === OPENSSL_KEYTYPE_RSA && ($details['bits'] ?? 0) >= 2048;
}
// ─── Katalog (Plugin-Webseite) ───────────────────────────────────────────────
function plugin_catalog_url(): string
{
if (plugin_strict_mode()) return catalog_defaults()['plugins_url'];
$own = trim((string) get_setting('plugin_catalog_url', ''));
return $own !== '' ? $own : catalog_defaults()['plugins_url'];
}
/**
* Katalog mit Zwischenspeicher (1 Stunde), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss.
* $refresh = true erzwingt einen neuen Abruf. Fehler werden nicht zwischengespeichert.
*
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int}
*/
function plugin_catalog_cached(bool $refresh = false): array
{
$url = plugin_catalog_url();
$key = hash('sha256', $url);
if (!$refresh) {
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
}
}
$res = plugin_catalog_fetch();
if ($res['ok']) {
set_setting('plugin_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'plugins' => $res['plugins']], JSON_UNESCAPED_UNICODE));
$res['cached_at'] = time();
}
return $res;
}
/**
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung alle 30 Minuten aufgerufen), damit die Seiten sofort
* anzeigen und nie auf den Katalog-Server warten müssen. Fehler (Server nicht erreichbar) lassen den alten Stand stehen.
*/
function catalog_refresh_background(): void
{
if (!plugin_official_configured()) return;
try {
if (plugin_catalog_url() !== '') plugin_catalog_cached(true);
if (function_exists('theme_catalog_cached') && theme_catalog_url() !== '') theme_catalog_cached(true);
} catch (Throwable $e) {
error_log('catalog_refresh_background: ' . $e->getMessage());
}
}
/**
* Installierte Plugins, für die der (zwischengespeicherte) Katalog eine neuere Version kennt. Fragt nie den Server ab,
* ist also in jeder Seite billig.
*
* @return array<int, array{slug:string, name:string, installed:string, available:string}>
*/
function plugin_updates_available(PDO $pdo): array
{
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (!is_array($cached) || empty($cached['plugins'])) return [];
$installed = plugins_installed($pdo);
$out = [];
foreach ($cached['plugins'] as $c) {
$have = $installed[$c['slug']] ?? null;
if ($have && version_compare($c['version'], $have['version'], '>')) $out[] = ['slug' => $c['slug'], 'name' => $c['name'], 'installed' => $have['version'], 'available' => $c['version']];
}
return $out;
}
/** Vorschau-Adresse aus dem Katalog: nur https und nur auf demselben Server wie der Katalog, sonst leer. */
function catalog_preview_url(string $catalogUrl, string $previewUrl): string
{
if ($previewUrl === '' || !str_starts_with($previewUrl, 'https://') || strlen($previewUrl) > 500) return '';
return strcasecmp((string) parse_url($previewUrl, PHP_URL_HOST), (string) parse_url($catalogUrl, PHP_URL_HOST)) === 0 ? $previewUrl : '';
}
/**
* Vorschaubild eines Katalogeintrags (Plugin oder Theme). DeskHub lässt im Browser nur eigene Bilder zu, deshalb wird das Bild
* hier geladen, geprüft (PNG/JPEG/WebP, höchstens 1 MB) und einen Tag lang zwischengespeichert.
*
* @return array{mime:string, data:string}|null
*/
function catalog_preview_image(string $kind, string $slug): ?array
{
if (!plugin_valid_slug($slug) || !in_array($kind, ['plugin', 'theme'], true)) return null;
// Die Adresse kommt aus dem (zwischengespeicherten) Katalog; der Zwischenspeicher des Bildes hängt an dieser Adresse,
// ein neues Bild wird also sofort erkannt. Fehlschläge werden nur kurz gemerkt (5 Minuten), Erfolge einen Tag.
$catalog = $kind === 'theme' ? theme_catalog_cached() : plugin_catalog_cached();
$list = $catalog['ok'] ? ($catalog[$kind === 'theme' ? 'themes' : 'plugins'] ?? []) : [];
$url = '';
foreach ($list as $e) if ($e['slug'] === $slug) { $url = (string) ($e['preview'] ?? ''); break; }
if ($url === '') return null;
$cacheFile = sys_get_temp_dir() . '/deskhub_preview_' . hash('sha256', $url) . '.bin';
if (is_file($cacheFile)) {
$c = json_decode((string) file_get_contents($cacheFile), true);
$ttl = is_array($c) && !empty($c['data']) ? 86400 : 300;
if (is_array($c) && time() - filemtime($cacheFile) < $ttl) return !empty($c['data']) ? ['mime' => $c['mime'], 'data' => base64_decode($c['data'])] : null;
}
$result = null;
if (plugin_url_check($url) === null) {
[, $body, $error] = plugin_http_get($url, 1048576, 8);
$info = $error === null && $body !== null ? @getimagesizefromstring($body) : false;
$types = [IMAGETYPE_PNG => 'image/png', IMAGETYPE_JPEG => 'image/jpeg', IMAGETYPE_WEBP => 'image/webp'];
if ($info && isset($types[$info[2]])) $result = ['mime' => $types[$info[2]], 'data' => $body];
}
@file_put_contents($cacheFile, json_encode($result ? ['mime' => $result['mime'], 'data' => base64_encode($result['data'])] : ['data' => '']), LOCK_EX);
return $result;
}
/** Prüft eine Katalog-/Download-Adresse: nur https, kein internes Ziel. Gibt einen Fehlertext oder null zurück. */
function plugin_url_check(string $url): ?string
{
$parts = parse_url($url);
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return 'Bitte eine https://-Adresse angeben.';
$host = $parts['host'];
// Tests ersetzen den Abruf (deskhub_http_get) und brauchen keine Namensauflösung; IP-Adressen werden immer geprüft
if (!filter_var($host, FILTER_VALIDATE_IP) && isset($GLOBALS['deskhub_http_get'])) return null;
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return 'Die Adresse zeigt auf ein internes oder nicht auflösbares Ziel.';
}
return null;
}
/**
* Lädt eine Adresse (nur https, keine Weiterleitungen, Größenlimit, Zertifikatsprüfung).
* Tests können $GLOBALS['deskhub_http_get'] = fn(string $url, int $maxBytes): array setzen.
*
* @return array{0:int,1:?string,2:?string} [HTTP-Status, Inhalt, Fehlertext]
*/
function plugin_http_get(string $url, int $maxBytes, int $timeout = 10): array
{
$extraHeaders = catalog_access_headers($url);
if (isset($GLOBALS['deskhub_http_get']) && is_callable($GLOBALS['deskhub_http_get'])) return ($GLOBALS['deskhub_http_get'])($url, $maxBytes, $extraHeaders);
if (!function_exists('curl_init')) return [0, null, 'Die PHP-Erweiterung curl fehlt.'];
$body = '';
$tooBig = false;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_USERAGENT => 'DeskHub-Plugins/1', CURLOPT_HTTPHEADER => array_merge(['Accept: application/json, application/zip, */*'], $extraHeaders),
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$body, &$tooBig, $maxBytes): int {
$body .= $chunk;
if (strlen($body) > $maxBytes) { $tooBig = true; return 0; }
return strlen($chunk);
},
]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($tooBig) return [$status, null, 'Die Antwort ist größer als erlaubt.'];
if ($err !== '') return [$status, null, $err];
return [$status, $body, $status >= 200 && $status < 300 ? null : "HTTP {$status}"];
}
/**
* Lädt den Katalog. Erwartetes JSON: {"plugins":[{slug,name,version,description,author,requires,permissions[],downloadUrl,sha256,signature}]}
*
* @return array{ok:bool, error?:string, plugins?:array}
*/
function plugin_catalog_fetch(): array
{
$url = plugin_catalog_url();
if ($url === '') return ['ok' => false, 'error' => 'Es ist keine Katalog-Adresse hinterlegt.'];
if ($err = plugin_url_check($url)) return ['ok' => false, 'error' => $err];
[$status, $body, $error] = plugin_http_get($url, 1048576);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Der Katalog konnte nicht geladen werden: ' . ($error ?? 'unbekannter Fehler')];
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['plugins']) || !is_array($data['plugins'])) return ['ok' => false, 'error' => 'Der Katalog hat ein ungültiges Format.'];
$known = plugin_known_permissions();
$plugins = [];
foreach ($data['plugins'] as $e) {
if (!is_array($e)) continue;
$slug = (string) ($e['slug'] ?? '');
$version = (string) ($e['version'] ?? '');
$sha = strtolower((string) ($e['sha256'] ?? ''));
$dl = (string) ($e['downloadUrl'] ?? '');
$perms = array_values(array_unique(array_map('strval', (array) ($e['permissions'] ?? []))));
if (!plugin_valid_slug($slug) || !preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $version) || !preg_match('/^[a-f0-9]{64}$/', $sha)) continue;
if (!str_starts_with($dl, 'https://') || array_diff($perms, array_keys($known))) continue;
$plugins[] = [
'slug' => $slug, 'name' => mb_substr(trim((string) ($e['name'] ?? $slug)), 0, 120), 'version' => $version,
'description' => mb_substr((string) ($e['description'] ?? ''), 0, 500), 'author' => mb_substr((string) ($e['author'] ?? ''), 0, 120),
'requires' => (string) ($e['requires'] ?? ''), 'permissions' => $perms, 'downloadUrl' => $dl, 'sha256' => $sha,
'signature' => trim((string) ($e['signature'] ?? '')), 'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')),
];
}
return ['ok' => true, 'plugins' => $plugins];
}
/** Installiert ein Plugin aus dem Katalog: Download, Prüfsumme, Signatur, Installation. Aktiviert wird nicht. */
function plugin_catalog_install(PDO $pdo, string $slug): array
{
$catalog = plugin_catalog_fetch();
if (!$catalog['ok']) return $catalog;
$entry = null;
foreach ($catalog['plugins'] as $e) if ($e['slug'] === $slug) { $entry = $e; break; }
if (!$entry) return ['ok' => false, 'error' => 'Dieses Plugin steht nicht im Katalog.'];
$have = plugins_installed($pdo)[$slug] ?? null;
if ($have && version_compare($entry['version'], $have['version'], '<=')) return ['ok' => false, 'error' => 'Dieses Plugin ist bereits in der aktuellen Version installiert.'];
if ($err = plugin_url_check($entry['downloadUrl'])) return ['ok' => false, 'error' => 'Download-Adresse nicht erlaubt: ' . $err];
if ($entry['requires'] !== '' && defined('DESKHUB_VERSION') && version_compare(DESKHUB_VERSION, $entry['requires'], '<')) {
return ['ok' => false, 'error' => "Benötigt DeskHub {$entry['requires']} oder neuer (installiert: " . DESKHUB_VERSION . ').'];
}
[$status, $body, $error] = plugin_http_get($entry['downloadUrl'], PLUGIN_MAX_PACKAGE_BYTES, 60);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($error ?? 'unbekannter Fehler')];
$tmp = tempnam(sys_get_temp_dir(), 'dhp_');
file_put_contents($tmp, $body);
try {
return plugin_install_zip($pdo, $tmp, $entry['sha256'], $entry['signature'], 'catalog', ['slug' => $entry['slug'], 'version' => $entry['version']]);
} finally {
@unlink($tmp);
}
}
/** Prüft den Dateinamen im Paket. */
function plugin_package_path_ok(string $rel): bool
{
$rel = str_replace(chr(92), '/', $rel);
if ($rel === '' || $rel[0] === '/' || str_contains($rel, "\0") || preg_match('#(^|/)\.\.(/|$)#', $rel) || preg_match('#^[A-Za-z]:#', $rel)) return false;
foreach (explode('/', $rel) as $part) {
if ($part === '' ) continue;
if ($part[0] === '.' ) return false; // keine versteckten Dateien (.htaccess usw.)
}
$ext = strtolower(pathinfo($rel, PATHINFO_EXTENSION));
return in_array($ext, PLUGIN_ALLOWED_EXTENSIONS, true);
}
/**
* Installiert (oder aktualisiert) ein Plugin aus einer ZIP-Datei. Das Plugin wird NICHT automatisch aktiviert.
*
* Mit Signatur (Base64, RSA-SHA256 über plugin_signature_message) und hinterlegtem Herausgeber-Schlüssel wird die Herkunft
* geprüft; ist "Signatur erforderlich" eingestellt, werden unsignierte Pakete abgelehnt. $expect = ['slug'=>…,'version'=>…]
* lässt das Paket scheitern, wenn sein Inhalt nicht zu dem passt, was der Katalog versprochen hat.
*
* @return array{ok:bool, error?:string, slug?:string, name?:string, version?:string, updated?:bool, hash?:string, signed?:bool, needs_reapproval?:array}
*/
function plugin_install_zip(PDO $pdo, string $zipPath, ?string $expectedSha256 = null, ?string $signature = null, string $source = 'upload', ?array $expect = null): array
{
if (!class_exists('ZipArchive')) return ['ok' => false, 'error' => 'Die PHP-Erweiterung zip fehlt.'];
$size = (int) @filesize($zipPath);
if ($size <= 0 || $size > PLUGIN_MAX_PACKAGE_BYTES) return ['ok' => false, 'error' => 'Das Paket ist leer oder größer als ' . (PLUGIN_MAX_PACKAGE_BYTES / 1048576) . ' MB.'];
$hash = hash_file('sha256', $zipPath);
if ($expectedSha256 !== null && $expectedSha256 !== '' && !hash_equals(strtolower(trim($expectedSha256)), $hash)) {
return ['ok' => false, 'error' => "Die Prüfsumme (SHA-256) stimmt nicht überein. Erhalten: {$hash}"];
}
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) return ['ok' => false, 'error' => 'ZIP-Datei konnte nicht geöffnet werden.'];
// Gemeinsames Wurzelverzeichnis erkennen (plugin.json liegt entweder im Wurzelordner oder in einem Unterordner)
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) $names[] = str_replace(chr(92), '/', (string) $zip->getNameIndex($i));
$prefix = '';
if (!in_array('plugin.json', $names, true)) {
foreach ($names as $n) {
if (preg_match('#^([^/]+/)plugin\.json$#', $n, $m)) { $prefix = $m[1]; break; }
}
}
if ($prefix === '' && !in_array('plugin.json', $names, true)) { $zip->close(); return ['ok' => false, 'error' => 'plugin.json nicht im Paket gefunden.']; }
$files = [];
$total = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $names[$i];
if (substr($name, -1) === '/') continue;
if ($prefix !== '' && strpos($name, $prefix) !== 0) continue;
$rel = substr($name, strlen($prefix));
if (!plugin_package_path_ok($rel)) { $zip->close(); return ['ok' => false, 'error' => "Unzulässige Datei im Paket: {$rel}"]; }
$stat = $zip->statIndex($i);
$total += (int) ($stat['size'] ?? 0);
if ($total > PLUGIN_MAX_PACKAGE_BYTES * 4) { $zip->close(); return ['ok' => false, 'error' => 'Das entpackte Paket ist zu groß.']; }
$files[$i] = $rel;
}
// In ein temporäres Verzeichnis entpacken und dort prüfen, bevor etwas Installiertes verändert wird
$tmp = plugins_dir() . '/.tmp_' . bin2hex(random_bytes(6));
if (!@mkdir($tmp, 0755, true)) { $zip->close(); return ['ok' => false, 'error' => 'Das Plugin-Verzeichnis ist nicht beschreibbar.']; }
try {
foreach ($files as $i => $rel) {
$dest = $tmp . '/' . $rel;
if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true);
file_put_contents($dest, (string) $zip->getFromIndex($i));
}
$zip->close();
[$manifest, $error] = plugin_read_manifest($tmp);
if (!$manifest) return ['ok' => false, 'error' => (string) $error];
$raw = json_decode((string) file_get_contents($tmp . '/plugin.json'), true);
$slug = (string) ($raw['slug'] ?? '');
if (!plugin_valid_slug($slug)) return ['ok' => false, 'error' => 'plugin.json: "slug" fehlt oder ist ungültig (a–z, 0–9, _ und -; 2–41 Zeichen).'];
if (in_array($slug, ['core', 'system', 'admin', 'staff', 'api'], true)) return ['ok' => false, 'error' => 'Dieser Plugin-Name ist reserviert.'];
if ($expect && (($expect['slug'] ?? $slug) !== $slug || ($expect['version'] ?? $manifest['version']) !== $manifest['version'])) {
return ['ok' => false, 'error' => 'Das Paket passt nicht zur Katalog-Angabe (Name oder Version weichen ab).'];
}
// Herkunft prüfen
$signed = false;
$signature = trim((string) $signature);
if ($signature !== '') {
if (plugin_publisher_key() === '') return ['ok' => false, 'error' => 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'];
if (!plugin_verify_signature(plugin_signature_message($slug, $manifest['version'], $hash), $signature, plugin_publisher_key())) {
return ['ok' => false, 'error' => 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber.'];
}
$signed = true;
} elseif (plugin_signatures_required($source)) {
return ['ok' => false, 'error' => 'Plugins müssen signiert sein (Einstellung „Signatur erforderlich“). Diesem Paket fehlt die Signatur.'];
}
$target = plugins_dir() . '/' . $slug;
$existing = plugins_installed($pdo)[$slug] ?? null;
$backup = null;
if (is_dir($target)) {
$backup = plugins_dir() . '/.old_' . $slug . '_' . bin2hex(random_bytes(3));
if (!@rename($target, $backup)) return ['ok' => false, 'error' => 'Das vorhandene Plugin konnte nicht ersetzt werden.'];
}
if (!@rename($tmp, $target)) {
if ($backup) @rename($backup, $target);
return ['ok' => false, 'error' => 'Das Plugin konnte nicht installiert werden.'];
}
if ($backup) plugin_rmdir($backup);
$now = db_now();
$needsReapproval = [];
if ($existing) {
$pdo->prepare('UPDATE plugins SET name = ?, version = ?, package_hash = ?, updated_at = ?, fail_count = 0, last_error = NULL, signature_ok = ?, source = ? WHERE slug = ?')
->execute([$manifest['name'], $manifest['version'], $hash, $now, $signed ? 1 : 0, $source, $slug]);
// Fordert die neue Version Berechtigungen an, die nie bestätigt wurden, wird ein aktives Plugin sofort angehalten
$needsReapproval = array_values(array_diff($manifest['permissions'], array_filter(explode(',', (string) ($existing['approved_permissions'] ?? '')))));
if ($needsReapproval && $existing['is_active']) {
$pdo->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $needsReapproval) . '). Bitte prüfen und erneut aktivieren.', $slug]);
}
} else {
$pdo->prepare('INSERT INTO plugins (slug, name, version, is_active, package_hash, installed_at, updated_at, signature_ok, source) VALUES (?,?,?,0,?,?,?,?,?)')
->execute([$slug, $manifest['name'], $manifest['version'], $hash, $now, $now, $signed ? 1 : 0, $source]);
}
$tmp = null;
return ['ok' => true, 'slug' => $slug, 'name' => $manifest['name'], 'version' => $manifest['version'], 'updated' => (bool) $existing, 'hash' => $hash, 'signed' => $signed, 'needs_reapproval' => $needsReapproval];
} finally {
if ($tmp !== null && is_dir($tmp)) plugin_rmdir($tmp);
}
}
function plugin_rmdir(string $dir): void
{
if (!is_dir($dir)) return;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $f) {
$f->isDir() && !$f->isLink() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
}
@rmdir($dir);
}
/** Datenbank-Änderungen eines Plugins (plugins/<slug>/migrations/*.sql) einspielen; Protokoll in schema_migrations. */
function plugin_run_migrations(PDO $pdo, string $slug): array
{
$applied = 0;
$errors = [];
$files = glob(plugins_dir() . '/' . $slug . '/migrations/*.sql') ?: [];
natsort($files);
$done = $pdo->query('SELECT migration FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
foreach ($files as $file) {
// Dialekt-spezifische Dateien: *.sqlite.sql / *.mysql.sql laufen nur auf der passenden Datenbank
if (preg_match('/\.(sqlite|mysql)\.sql$/', $file, $dm) && ($dm[1] === 'sqlite') !== ($driver === 'sqlite')) continue;
$key = 'plugin_' . $slug . '_' . basename($file);
if (in_array($key, $done, true)) continue;
$sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file));
try {
$pdo->beginTransaction();
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) $pdo->exec($stmt);
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute([$key]);
$pdo->commit();
$applied++;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$errors[] = basename($file) . ': ' . $e->getMessage();
break;
}
}
return ['applied' => $applied, 'errors' => $errors];
}
/**
* Schaltet ein Plugin ein/aus. Beim Aktivieren muss der Admin die angeforderten Berechtigungen bestätigen
* ($approvedPermissions muss alle enthalten); danach werden die Datenbank-Änderungen des Plugins eingespielt.
*/
function plugin_set_active(PDO $pdo, string $slug, bool $active, ?array $approvedPermissions = null): array
{
$row = plugins_installed($pdo)[$slug] ?? null;
if (!$row) return ['ok' => false, 'error' => 'Plugin nicht gefunden.'];
$declared = [];
if ($active) {
[$manifest, $error] = plugin_read_manifest(plugins_dir() . '/' . $slug);
if (!$manifest) return ['ok' => false, 'error' => (string) $error];
$declared = $manifest['permissions'];
if ($declared && ($approvedPermissions === null || array_diff($declared, $approvedPermissions))) {
return ['ok' => false, 'error' => 'Bitte die angeforderten Berechtigungen bestätigen.', 'needs_confirmation' => $declared];
}
$hasMigrations = (bool) glob(plugins_dir() . '/' . $slug . '/migrations/*.sql');
if ($hasMigrations && !in_array('db.migrations', $declared, true)) {
return ['ok' => false, 'error' => 'Das Plugin bringt Datenbank-Änderungen mit, fordert aber die Berechtigung "db.migrations" nicht an.'];
}
$mig = plugin_run_migrations($pdo, $slug);
if ($mig['errors']) return ['ok' => false, 'error' => 'Datenbank-Änderung des Plugins fehlgeschlagen: ' . implode('; ', $mig['errors'])];
}
if ($active) {
$pdo->prepare('UPDATE plugins SET is_active = 1, fail_count = 0, last_error = NULL, approved_permissions = ? WHERE slug = ?')->execute([implode(',', $declared), $slug]);
} else {
$pdo->prepare('UPDATE plugins SET is_active = 0, fail_count = 0, last_error = NULL WHERE slug = ?')->execute([$slug]);
}
return ['ok' => true];
}
/** Entfernt ein Plugin. $removeData löst vorher den Hook "plugin.uninstall.<slug>" aus, damit es seine Daten löschen kann. */
function plugin_uninstall(PDO $pdo, string $slug, bool $removeData = false): array
{
if (!plugin_valid_slug($slug) || !isset(plugins_installed($pdo)[$slug])) return ['ok' => false, 'error' => 'Plugin nicht gefunden.'];
if ($removeData) plugin_hook('plugin.uninstall.' . $slug, ['slug' => $slug]);
$pdo->prepare('DELETE FROM plugins WHERE slug = ?')->execute([$slug]);
$pdo->prepare("DELETE FROM settings WHERE setting_key LIKE ?")->execute(['plugin.' . $slug . '.%']);
plugin_rmdir(plugins_dir() . '/' . $slug);
return ['ok' => true];
}
/** Mini-Vorschau (96x54) für Katalogeinträge: das Bild aus dem Katalog oder ein Platzhalter mit dem Anfangsbuchstaben. */
function catalog_thumb(string $kind, array $c): string
{
$box = 'width:96px;height:54px;border-radius:6px;flex:none;background:var(--bg-soft,#eef2f5);border:1px solid var(--border,#e2e8f0);';
if (!empty($c['preview'])) {
return '<img src="/staff/catalog_preview.php?kind=' . e($kind) . '&amp;slug=' . e($c['slug']) . '" alt="" loading="lazy" width="96" height="54" style="' . $box . 'object-fit:cover">';
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
}
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* includes/pseudo_cron.php
*
* "Eingebauter" Ersatz für echte Betriebssystem-Cronjobs, für Hosting-Umgebungen
* ohne Crontab-Zugriff (z.B. viele Shared-Hosting-Pakete). Funktioniert komplett
* ohne externe Dienste: Bei jeder authentifizierten Anfrage im Mitarbeiterbereich
* (Web-Backend UND mobile App) wird nach dem Ausliefern der Antwort im Hintergrund
* geprüft, ob einer der 6 Cronjobs fällig ist – und falls ja, direkt ausgeführt.
*
* Ablauf, ohne die eigentliche Seite/den API-Request zu verlangsamen:
* 1. pseudo_cron_schedule_background() wird beim Seiten-/API-Aufruf registriert.
* 2. register_shutdown_function() sorgt dafür, dass der Code erst NACH dem Senden
* der Antwort läuft. Wo verfügbar (PHP-FPM) beendet fastcgi_finish_request()
* die Verbindung zum Browser sofort – der Nutzer merkt nichts von der Wartezeit.
* 3. pseudo_cron_dispatch() prüft für jeden Job das letzte Ausführungsdatum
* (Setting "cron_<job>_last_run") gegen sein empfohlenes Intervall und führt
* ihn bei Bedarf aus – über dieselbe run_*()-Funktion wie der "Jetzt ausführen"-
* Button und der echte Cronjob.
*
* Nebenläufigkeitsschutz: Ein Job wird nur ausgeführt, wenn das UPDATE des
* last_run-Settings tatsächlich eine Zeile ändert (atomares "Compare-and-Swap").
* Laufen zwei Anfragen gleichzeitig, "gewinnt" nur eine davon den Job.
*
* Kann in Einstellungen → Cron-Jobs deaktiviert werden (Setting "pseudo_cron_enabled"),
* falls echte Server-Cronjobs eingerichtet sind und der eingebaute Ersatz nicht
* gebraucht wird.
*/
/** Empfohlene Mindestabstände in Sekunden, angelehnt an die Crontab-Beispiele. */
function pseudo_cron_job_definitions(): array
{
return [
'fetch_emails' => ['setting' => 'cron_fetch_emails_last_run', 'interval' => 300, 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'],
'check_sla' => ['setting' => 'cron_check_sla_last_run', 'interval' => 900, 'file' => 'check_sla.php', 'fn' => 'run_check_sla'],
'check_escalations' => ['setting' => 'cron_check_escalations_last_run', 'interval' => 3600, 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'],
'process_backups' => ['setting' => 'cron_process_backups_last_run', 'interval' => 3600, 'file' => 'process_backups.php', 'fn' => 'run_process_backups'],
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
];
}
/**
* Versucht, die "Ausführungs-Berechtigung" für ein Setting atomar zu übernehmen:
* Nur wenn der gespeicherte Zeitstempel älter als das Intervall ist, wird er auf
* "jetzt" gesetzt und true zurückgegeben. Verhindert doppelte Ausführung bei
* gleichzeitigen Anfragen (kein SELECT-dann-UPDATE, sondern ein atomares UPDATE).
*/
function pseudo_cron_try_claim(PDO $pdo, string $settingKey, int $intervalSeconds): bool
{
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
// Zeile anlegen falls noch nie vorhanden (fernes Datum = sofort fällig).
if ($driver === 'sqlite') {
$pdo->prepare("INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
->execute([$settingKey]);
} else {
$pdo->prepare("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')")
->execute([$settingKey]);
}
$now = date('Y-m-d H:i:s');
$threshold = date('Y-m-d H:i:s', time() - $intervalSeconds);
$stmt = $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ? AND setting_value < ?');
$stmt->execute([$now, $settingKey, $threshold]);
return $stmt->rowCount() > 0;
}
/** Prüft alle Jobs und führt fällige direkt aus. Läuft NACH dem Senden der Antwort. */
function pseudo_cron_dispatch(): void
{
try {
$pdo = get_db();
if (get_setting('pseudo_cron_enabled', '1') !== '1') return;
// Grobes Gate: höchstens alle 20 Sekunden eine echte Prüfung, um bei viel
// Traffic nicht bei jeder einzelnen Anfrage 6 zusätzliche Abfragen zu machen.
if (!pseudo_cron_try_claim($pdo, 'pseudo_cron_gate', 20)) return;
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
foreach (pseudo_cron_job_definitions() as $key => $job) {
if (!pseudo_cron_try_claim($pdo, $job['setting'], $job['interval'])) continue;
try {
require_once __DIR__ . '/../cron/' . $job['file'];
$fn = $job['fn'];
$fn($pdo, false);
} catch (Throwable $e) {
error_log("pseudo_cron [$key]: " . $e->getMessage());
}
}
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 30 Minuten)
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 1800)) {
catalog_refresh_background();
}
// Plugins: wiederkehrende Aufgaben (höchstens alle 5 Minuten)
if (pseudo_cron_try_claim($pdo, 'plugins_cron_last_run', 300)) {
plugin_hook('cron.run', ['time' => time()]);
}
// Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php,
// lebt bereits in bootstrap.php – kein require_once aus cron/ nötig).
if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) {
try {
finalize_stale_work_sessions($pdo, false);
} catch (Throwable $e) {
error_log('pseudo_cron [time_tracking_sweep]: ' . $e->getMessage());
}
}
} catch (Throwable $e) {
error_log('pseudo_cron_dispatch: ' . $e->getMessage());
}
}
/**
* Registriert die Hintergrundprüfung für das Ende des aktuellen Requests.
* Von require_staff_login() (Web) und api_require_staff() (mobile App) aufgerufen –
* läuft also bei jeder authentifizierten Mitarbeiter-Anfrage, ganz ohne dass
* jemand manuell etwas klicken oder ein externer Dienst konfiguriert werden muss.
*/
function pseudo_cron_schedule_background(): void
{
static $scheduled = false;
if ($scheduled) return; // pro Request nur einmal registrieren
$scheduled = true;
if (php_sapi_name() === 'cli') return; // im echten Cron nicht nötig
register_shutdown_function(function () {
// Verbindung zum Client möglichst sofort beenden, damit die Prüfung
// die eigentliche Seite/den API-Aufruf nicht spürbar verlangsamt.
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
} else {
if (function_exists('ob_get_level')) {
while (ob_get_level() > 0) { @ob_end_flush(); }
}
@flush();
}
pseudo_cron_dispatch();
});
}
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* includes/push.php
*
* Browser Push Notifications via Web Push API (VAPID).
*
* Kein externer Composer-Paket nötig – minimale VAPID-Implementierung
* mit PHP-OpenSSL und JWT.
*
* Einrichtung:
* 1. VAPID-Keys einmalig generieren: php includes/push.php --generate-keys
* 2. Keys in config/config.php eintragen:
* 'push' => ['public_key' => '...', 'private_key' => '...']
* 3. Service Worker erweitert (sw-staff.js) – bereits erledigt
*/
// ── VAPID Key Generator (CLI) ─────────────────────────────────────────────────
if (php_sapi_name() === 'cli' && in_array('--generate-keys', $argv ?? [])) {
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
$details = openssl_pkey_get_details($key);
$pub = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']);
openssl_pkey_export($key, $privPem);
$priv = base64url_encode($details['ec']['d']);
echo "VAPID Keys generiert – in config/config.php eintragen:\n\n";
echo "'push' => [\n";
echo " 'public_key' => '$pub',\n";
echo " 'private_key' => '$priv',\n";
echo "],\n\n";
echo "Public Key für Frontend (push_subscribe.php):\n$pub\n";
exit(0);
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64url_decode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
/**
* Sendet eine Web-Push-Benachrichtigung an einen Subscriber.
*
* @param array $sub ['endpoint'=>..., 'p256dh'=>..., 'auth'=>...]
* @param string $title Titel der Benachrichtigung
* @param string $body Nachrichtentext
* @param string $url Ziel-URL beim Klick
* @param string $icon Icon-URL (optional)
*/
/**
* Gibt die VAPID-Keys zurück. Existieren sie noch nicht, werden sie
* automatisch generiert und in der settings-Tabelle gespeichert.
* Kein manueller Einrichtungsschritt nötig.
*/
function get_or_create_vapid_keys(): ?array
{
// 1. Config hat explizite Keys → Vorrang
$cfg = config()['push'] ?? [];
if (!empty($cfg['public_key']) && !empty($cfg['private_key'])) {
return $cfg;
}
// 2. Keys aus DB laden
try {
$pub = get_setting('vapid_public_key');
$priv = get_setting('vapid_private_key');
if ($pub && $priv) {
return ['public_key' => $pub, 'private_key' => $priv];
}
// 3. Neu generieren und speichern
if (!function_exists('openssl_pkey_new')) return null;
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
if (!$key) return null;
$details = openssl_pkey_get_details($key);
$pubKey = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']);
$privKey = base64url_encode($details['ec']['d']);
set_setting('vapid_public_key', $pubKey);
set_setting('vapid_private_key', $privKey);
return ['public_key' => $pubKey, 'private_key' => $privKey];
} catch (Throwable $e) {
return null;
}
}
function send_push(array $sub, string $title, string $body, string $url = '/staff/', string $icon = '/assets/icons/icon-192.png'): bool
{
$keys = get_or_create_vapid_keys();
if (!$keys) return false;
$payload = json_encode([
'title' => $title,
'body' => $body,
'url' => $url,
'icon' => $icon,
'badge' => '/assets/icons/icon-192.png',
]);
// Payload mit AES-128-GCM verschlüsseln (RFC 8291)
$encrypted = encrypt_push_payload($payload, $sub['p256dh'], $sub['auth']);
if (!$encrypted) return false;
// VAPID JWT erstellen
$endpoint = $sub['endpoint'];
$parsedUrl = parse_url($endpoint);
$audience = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
$jwt = create_vapid_jwt($audience, $keys['public_key'], $keys['private_key']);
$headers = [
'Authorization: vapid t=' . $jwt . ',k=' . $keys['public_key'],
'Content-Type: application/octet-stream',
'Content-Encoding: aes128gcm',
'TTL: 86400',
'Content-Length: ' . strlen($encrypted['ciphertext']),
];
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encrypted['ciphertext'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 410 Gone = Subscription abgelaufen, aus DB löschen
if ($status === 410) {
try {
get_db()->prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')->execute([$endpoint]);
} catch (Throwable $e) {}
}
return $status >= 200 && $status < 300;
}
/**
* Sendet Push an alle Subscriptions eines Mitarbeiters.
*/
function push_to_staff(int $staffId, string $title, string $body, string $url = '/staff/'): void
{
if (!get_or_create_vapid_keys()) return;
try {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM push_subscriptions WHERE staff_id = ?');
$stmt->execute([$staffId]);
foreach ($stmt->fetchAll() as $sub) {
send_push($sub, $title, $body, $url);
}
} catch (Throwable $e) {}
}
/**
* Sendet Push an alle aktiven Mitarbeiter einer Abteilung (neues Ticket).
*/
function push_new_ticket(array $ticket): void
{
if (!get_or_create_vapid_keys()) return;
try {
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN staff s ON s.id = ps.staff_id
LEFT JOIN staff_department sd ON sd.staff_id = s.id
WHERE s.is_active = 1
AND (s.role = 'admin' OR sd.department_id = ?)"
);
$stmt->execute([$ticket['department_id']]);
foreach ($stmt->fetchAll() as $sub) {
send_push(
$sub,
'Neues Ticket: ' . mb_strimwidth($ticket['subject'], 0, 60, '…'),
'Von: ' . $ticket['requester_name'],
'/staff/ticket_view.php?n=' . $ticket['ticket_number']
);
}
} catch (Throwable $e) {}
}
/**
* Sendet Push bei neuer Antwort an den zugewiesenen Mitarbeiter.
*/
function push_new_reply(array $ticket, string $authorName): void
{
if (empty($ticket['assigned_staff_id']) || empty(config()['push']['public_key'])) return;
push_to_staff(
(int)$ticket['assigned_staff_id'],
'Neue Antwort: #' . $ticket['ticket_number'],
'Von: ' . $authorName . ' – ' . mb_strimwidth($ticket['subject'], 0, 50, '…'),
'/staff/ticket_view.php?n=' . $ticket['ticket_number']
);
}
/**
* Push bei neuer Live-Chat-Nachricht: an den zugewiesenen Mitarbeiter, oder – solange
* der Chat noch niemandem zugewiesen ist – an alle aktiven Mitarbeiter der Abteilung
* (wie bei einem neuen Ticket), damit ein wartender Chat niemals unbemerkt bleibt.
*/
function push_new_chat_message(int $departmentId, ?int $assignedStaffId, string $ticketNumber, string $requesterName): void
{
if (!get_or_create_vapid_keys()) return;
$title = 'Neue Chat-Nachricht: #' . $ticketNumber;
$body = 'Von: ' . $requesterName;
$url = '/staff/live_chat.php?n=' . urlencode($ticketNumber);
if ($assignedStaffId) {
push_to_staff($assignedStaffId, $title, $body, $url);
return;
}
try {
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN staff s ON s.id = ps.staff_id
LEFT JOIN staff_department sd ON sd.staff_id = s.id
WHERE s.is_active = 1
AND (s.role = 'admin' OR sd.department_id = ?)"
);
$stmt->execute([$departmentId]);
foreach ($stmt->fetchAll() as $sub) {
send_push($sub, $title, $body, $url);
}
} catch (Throwable $e) {}
}
// ── Crypto ────────────────────────────────────────────────────────────────────
function create_vapid_jwt(string $audience, string $publicKey, string $privateKeyB64): string
{
$header = base64url_encode(json_encode(['typ' => 'JWT', 'alg' => 'ES256']));
$payload = base64url_encode(json_encode([
'aud' => $audience,
'exp' => time() + 43200,
'sub' => 'mailto:' . (get_setting('admin_email') ?: 'admin@localhost'),
]));
$signing = $header . '.' . $payload;
// Private Key aus base64url laden
$privDer = base64url_decode($privateKeyB64);
// Reconstruct PEM from raw EC private key bytes
$privPem = ec_private_key_to_pem($privDer, base64url_decode($publicKey));
$key = openssl_pkey_get_private($privPem);
openssl_sign($signing, $sig, $key, OPENSSL_ALGO_SHA256);
// DER-Signatur in r||s umwandeln (P-256 = 32 Bytes je)
$sig = der_to_raw_ec_sig($sig);
return $signing . '.' . base64url_encode($sig);
}
function ec_private_key_to_pem(string $d, string $pubPoint): string
{
// SEC1 EC private key DER für prime256v1
$oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // prime256v1
$pub = "\x03" . chr(strlen($pubPoint) + 1) . "\x00" . $pubPoint;
$prv = "\x04" . chr(strlen($d)) . $d;
$seq = "\x02\x01\x01" . $prv . "\xa1" . chr(strlen($pub)) . $pub;
$inner = "\x30" . chr(strlen($seq)) . $seq;
$wrap = "\x30\x77\x02\x01\x00" . "\x30" . chr(strlen($oid)) . $oid . "\x04" . chr(strlen($inner)) . $inner;
return "-----BEGIN PRIVATE KEY-----\n" . chunk_split(base64_encode($wrap), 64) . "-----END PRIVATE KEY-----\n";
}
function der_to_raw_ec_sig(string $der): string
{
// Parse DER SEQUENCE { INTEGER r, INTEGER s }
$pos = 2; // skip SEQUENCE tag + length
$rLen = ord($der[$pos + 1]);
$r = substr($der, $pos + 2, $rLen);
$pos += 2 + $rLen;
$sLen = ord($der[$pos + 1]);
$s = substr($der, $pos + 2, $sLen);
// Pad to 32 bytes
return str_pad(ltrim($r, "\x00"), 32, "\x00", STR_PAD_LEFT)
. str_pad(ltrim($s, "\x00"), 32, "\x00", STR_PAD_LEFT);
}
function encrypt_push_payload(string $payload, string $p256dhB64, string $authB64): ?array
{
// RFC 8291 aes128gcm content encoding
$recipientPubKey = base64url_decode($p256dhB64);
$authSecret = base64url_decode($authB64);
// Generate ephemeral key pair
$eph = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
if (!$eph) return null;
$ephDetails = openssl_pkey_get_details($eph);
$ephPublicKey = "\x04" . $ephDetails['ec']['x'] . $ephDetails['ec']['y'];
// Recipient public key as EC key
$recipientKey = ec_public_key_from_uncompressed($recipientPubKey);
if (!$recipientKey) return null;
// ECDH
if (!openssl_dh_compute_key($sharedSecret, $recipientKey, $eph)) return null;
// HKDF to derive content encryption key and nonce (RFC 8291)
$salt = random_bytes(16);
$prk = hkdf_sha256($authSecret, $sharedSecret, "WebPush: info\x00" . $recipientPubKey . $ephPublicKey, 32);
$cek = hkdf_sha256($salt, $prk, "Content-Encoding: aes128gcm\x00", 16);
$nonce = hkdf_sha256($salt, $prk, "Content-Encoding: nonce\x00", 12);
// Encrypt with AES-128-GCM
$tag = '';
$ciphertext = openssl_encrypt($payload . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
if ($ciphertext === false) return null;
// RFC 8291 header: salt(16) + rs(4) + keyid_len(1) + keyid
$keyId = $ephPublicKey;
$rs = pack('N', strlen($payload) + 16 + 2);
$header = $salt . $rs . chr(strlen($keyId)) . $keyId;
return ['ciphertext' => $header . $ciphertext . $tag];
}
function ec_public_key_from_uncompressed(string $point): mixed
{
// Build PEM from uncompressed EC point
$oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07";
$bitStr = "\x03" . chr(strlen($point) + 1) . "\x00" . $point;
$algId = "\x30" . chr(strlen($oid) + 2) . "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" . $oid;
$spki = "\x30" . chr(strlen($algId) + strlen($bitStr)) . $algId . $bitStr;
$pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64) . "-----END PUBLIC KEY-----\n";
return openssl_pkey_get_public($pem);
}
function hkdf_sha256(string $salt, string $ikm, string $info, int $length): string
{
$prk = hash_hmac('sha256', $ikm, $salt, true);
$t = '';
$okm = '';
for ($i = 1; strlen($okm) < $length; $i++) {
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
$okm .= $t;
}
return substr($okm, 0, $length);
}
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* Gespeicherte Ansichten (Warteschlangen) für die Ticketliste.
*
* Gespeichert werden neutrale Filter-Schlüssel, die Web, Desktop-App und Android-App gleichermaßen
* verstehen: q, status, assigned, departmentId, priorityId, sla, tab, quick, spam, tagId.
* Eine Ansicht gehört einem Mitarbeiter; "geteilte" Ansichten sehen alle Mitarbeiter.
*/
const QUEUE_FILTER_KEYS = ['q', 'status', 'assigned', 'departmentId', 'priorityId', 'sla', 'tab', 'quick', 'spam', 'tagId'];
/** Übersetzt die GET-Parameter der Weboberfläche in neutrale Filter (leere Werte entfallen). */
function queue_filters_from_web(array $get): array
{
$map = ['q' => 'q', 'status' => 'status', 'assigned' => 'assigned', 'department_id' => 'departmentId', 'priority_id' => 'priorityId',
'sla' => 'sla', 'tab' => 'tab', 'quick' => 'quick', 'spam' => 'spam', 'tag_id' => 'tagId'];
$out = [];
foreach ($map as $webKey => $key) {
$v = isset($get[$webKey]) && is_scalar($get[$webKey]) ? trim((string) $get[$webKey]) : '';
if ($v !== '') $out[$key] = mb_substr($v, 0, 200);
}
return $out;
}
/** Wandelt neutrale Filter zurück in GET-Parameter der Weboberfläche. */
function queue_filters_to_web(array $filters): array
{
$map = ['q' => 'q', 'status' => 'status', 'assigned' => 'assigned', 'departmentId' => 'department_id', 'priorityId' => 'priority_id',
'sla' => 'sla', 'tab' => 'tab', 'quick' => 'quick', 'spam' => 'spam', 'tagId' => 'tag_id'];
$out = [];
foreach ($map as $key => $webKey) {
if (isset($filters[$key]) && $filters[$key] !== '') $out[$webKey] = (string) $filters[$key];
}
// Apps speichern mehrere Status als Liste; die Weboberfläche kennt dafür die Reiter
if (isset($out['status']) && str_contains($out['status'], ',')) {
$out['tab'] = str_contains($out['status'], 'resolved') ? ($out['tab'] ?? 'all') : ($out['tab'] ?? 'open');
unset($out['status']);
}
return $out;
}
/** Übersetzt neutrale Filter in die Abfrageparameter der Mobile-API (tickets.php): status, assigned, sla, q, departmentId, priorityId. */
function queue_filters_to_api(array $filters): array
{
$out = [];
foreach (['q', 'assigned', 'sla', 'departmentId', 'priorityId'] as $k) {
if (isset($filters[$k]) && $filters[$k] !== '') $out[$k] = $filters[$k];
}
$status = $filters['status'] ?? '';
if ($status === '') {
$status = match ($filters['tab'] ?? '') {
'on_hold' => 'on_hold', 'resolved' => 'resolved', 'closed' => 'closed',
'all' => 'open,pending,answered,on_hold,resolved,closed',
default => 'open,pending,answered',
};
}
$out['status'] = $status;
switch ($filters['quick'] ?? '') {
case 'me': $out['assigned'] = 'me'; break;
case 'unassigned': $out['assigned'] = 'unassigned'; break;
case 'overdue': $out['sla'] = 'overdue'; break;
}
return $out;
}
/** Bereinigt beliebige Eingaben auf die erlaubten Filter-Schlüssel. */
function queue_clean_filters($filters): array
{
$out = [];
if (is_array($filters)) { // ältere Ansichten nutzten 'department'/'priority'
if (isset($filters['department']) && !isset($filters['departmentId'])) $filters['departmentId'] = $filters['department'];
if (isset($filters['priority']) && !isset($filters['priorityId'])) $filters['priorityId'] = $filters['priority'];
}
foreach (QUEUE_FILTER_KEYS as $key) {
if (is_array($filters) && isset($filters[$key]) && is_scalar($filters[$key]) && trim((string) $filters[$key]) !== '') {
$out[$key] = mb_substr(trim((string) $filters[$key]), 0, 200);
}
}
return $out;
}
/** Eigene und geteilte Ansichten, eigene zuerst. Jede Zeile bekommt 'filters' (Array) und 'mine' (bool). */
function queues_for(PDO $pdo, int $staffId): array
{
$stmt = $pdo->prepare(
'SELECT q.*, s.name AS owner_name FROM saved_queues q LEFT JOIN staff s ON s.id = q.staff_id
WHERE q.staff_id = ? OR q.is_shared = 1 ORDER BY (q.staff_id = ?) DESC, q.sort_order, q.name'
);
$stmt->execute([$staffId, $staffId]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
$r['filters'] = queue_clean_filters(json_decode((string) $r['filters'], true));
$r['mine'] = (int) $r['staff_id'] === $staffId;
}
return $rows;
}
/** Legt eine Ansicht an. Gibt die ID zurück. */
function queue_create(PDO $pdo, int $staffId, string $name, array $filters, bool $shared): int
{
$name = trim($name);
if ($name === '') throw new AdminModelException('Bitte einen Namen für die Ansicht angeben.', 'NAME_REQUIRED');
if (mb_strlen($name) > 80) throw new AdminModelException('Der Name ist zu lang (höchstens 80 Zeichen).', 'NAME_TOO_LONG');
$dup = $pdo->prepare('SELECT COUNT(*) FROM saved_queues WHERE staff_id = ? AND LOWER(name) = LOWER(?)');
$dup->execute([$staffId, $name]);
if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Du hast bereits eine Ansicht mit diesem Namen.', 'NAME_TAKEN');
$max = $pdo->prepare('SELECT COALESCE(MAX(sort_order), 0) + 1 FROM saved_queues WHERE staff_id = ?');
$max->execute([$staffId]);
$now = db_now();
$pdo->prepare('INSERT INTO saved_queues (staff_id, name, filters, is_shared, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
->execute([$staffId, $name, json_encode(queue_clean_filters($filters), JSON_UNESCAPED_UNICODE), $shared ? 1 : 0, (int) $max->fetchColumn(), $now, $now]);
return (int) $pdo->lastInsertId();
}
/** Darf der Mitarbeiter die Ansicht ändern/löschen? Eigene Ansichten immer; fremde nur Administratoren. */
function queue_can_edit(array $queueRow, array $staff): bool
{
return (int) $queueRow['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin';
}
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* Bericht-Baukasten: frei konfigurierbare Auswertungen über Tickets.
* Alle SQL-Bausteine stammen aus festen Listen (kein Nutzereingabe-SQL), Werte laufen über Parameter.
*/
/** Gruppierungen: key => [Bezeichnung, SQL-Ausdruck-Generator, JOIN, Anzeige-Spalte]. */
function report_dimensions(PDO $pdo): array
{
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
$bucket = fn(string $fmtSqlite, string $fmtMysql) => $sqlite ? "strftime('{$fmtSqlite}', t.created_at)" : "DATE_FORMAT(t.created_at, '{$fmtMysql}')";
return [
'department' => ['Abteilung', 'COALESCE(d.name, \'–\')', 'LEFT JOIN departments d ON d.id = t.department_id'],
'help_topic' => ['Hilfethema', 'COALESCE(h.name, \'ohne Thema\')', 'LEFT JOIN help_topics h ON h.id = t.help_topic_id'],
'agent' => ['Mitarbeiter', 'COALESCE(s.name, \'nicht zugewiesen\')', 'LEFT JOIN staff s ON s.id = t.assigned_staff_id'],
'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'],
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
'status' => ['Status', 't.status', ''],
'source' => ['Quelle', 't.source', ''],
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''],
'month' => ['Monat', $bucket('%Y-%m', '%Y-%m'), ''],
];
}
/** Kennzahlen: key => [Bezeichnung, SQL-Ausdruck, Einheit]. */
function report_metrics(PDO $pdo): array
{
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
$hours = fn(string $from, string $to) => $sqlite
? "(julianday({$to}) - julianday({$from})) * 24"
: "TIMESTAMPDIFF(MINUTE, {$from}, {$to}) / 60";
return [
'total' => ['Tickets gesamt', 'COUNT(*)', ''],
'open' => ['Offen', "SUM(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 ELSE 0 END)", ''],
'resolved' => ['Gelöst/Geschlossen', "SUM(CASE WHEN t.status IN ('resolved','closed') THEN 1 ELSE 0 END)", ''],
'overdue' => ['Überfällig', "SUM(CASE WHEN t.is_overdue = 1 AND t.status NOT IN ('resolved','closed') THEN 1 ELSE 0 END)", ''],
'avg_response' => ['Ø Erste Antwort', 'AVG(CASE WHEN t.first_responded_at IS NOT NULL THEN ' . $hours('t.created_at', 't.first_responded_at') . ' END)', 'Std'],
'avg_resolve' => ['Ø Lösungszeit', 'AVG(CASE WHEN t.resolved_at IS NOT NULL THEN ' . $hours('t.created_at', 't.resolved_at') . ' END)', 'Std'],
'sla_response' => ['SLA Erste Antwort', 'AVG(CASE WHEN t.first_responded_at IS NOT NULL AND t.first_response_due_at IS NOT NULL THEN (CASE WHEN t.first_responded_at <= t.first_response_due_at THEN 100.0 ELSE 0.0 END) END)', '%'],
'sla_resolve' => ['SLA Lösung', 'AVG(CASE WHEN t.resolved_at IS NOT NULL AND t.resolution_due_at IS NOT NULL THEN (CASE WHEN t.resolved_at <= t.resolution_due_at THEN 100.0 ELSE 0.0 END) END)', '%'],
];
}
/** Zulässige Filter: key => SQL-Spalte. */
function report_filter_columns(): array
{
return ['departmentId' => 't.department_id', 'priorityId' => 't.priority_id', 'agentId' => 't.assigned_staff_id',
'helpTopicId' => 't.help_topic_id', 'status' => 't.status', 'source' => 't.source'];
}
/** Bereinigt eine Berichtskonfiguration auf erlaubte Werte (mit sinnvollen Standards). */
function report_clean_config(PDO $pdo, array $in): array
{
$dims = report_dimensions($pdo); $metrics = report_metrics($pdo);
$dimension = isset($dims[$in['dimension'] ?? '']) ? $in['dimension'] : 'department';
$chosen = array_values(array_filter((array) ($in['metrics'] ?? []), fn($m) => is_string($m) && isset($metrics[$m])));
$valid = fn($d) => is_string($d) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false;
$to = $valid($in['to'] ?? null) ? $in['to'] : date('Y-m-d');
$from = $valid($in['from'] ?? null) ? $in['from'] : date('Y-m-d', strtotime('-30 days'));
if ($from > $to) [$from, $to] = [$to, $from];
$filters = [];
foreach (array_keys(report_filter_columns()) as $k) {
if (isset($in['filters'][$k]) && is_scalar($in['filters'][$k]) && trim((string) $in['filters'][$k]) !== '') $filters[$k] = trim((string) $in['filters'][$k]);
}
$sort = $in['sort'] ?? 'label';
$sort = ($sort === 'label' || isset($metrics[$sort])) ? $sort : 'label';
return [
'dimension' => $dimension, 'metrics' => $chosen ?: ['total', 'open', 'resolved'],
'from' => $from, 'to' => $to, 'filters' => $filters, 'sort' => $sort, 'desc' => !empty($in['desc']),
];
}
/**
* Führt einen Bericht aus. $deptScope = erlaubte Abteilungs-IDs des Mitarbeiters (null = alle).
*
* @return array{columns: array<int,array{key:string,label:string,unit:string}>, rows: array<int,array>, totals: array}
*/
function report_run(PDO $pdo, array $config, ?array $deptScope): array
{
$config = report_clean_config($pdo, $config);
$dims = report_dimensions($pdo); $metrics = report_metrics($pdo);
[$dimLabel, $dimExpr, $dimJoin] = $dims[$config['dimension']];
$select = ["{$dimExpr} AS label"];
foreach ($config['metrics'] as $m) $select[] = $metrics[$m][1] . " AS m_{$m}";
$where = ['t.created_at >= ?', 't.created_at < ?'];
$params = [$config['from'] . ' 00:00:00', date('Y-m-d', strtotime($config['to'] . ' +1 day')) . ' 00:00:00'];
foreach ($config['filters'] as $k => $v) {
$where[] = report_filter_columns()[$k] . ' = ?';
$params[] = $v;
}
[$scopeSql, $scopeParams] = dept_filter_sql($deptScope, 't.department_id');
$joins = [$dimJoin];
$sql = 'SELECT ' . implode(', ', $select) . ' FROM tickets t ' . implode(' ', array_filter($joins))
. ' WHERE ' . implode(' AND ', $where) . $scopeSql . ' AND t.is_spam = 0 GROUP BY label';
$stmt = $pdo->prepare($sql);
$stmt->execute([...$params, ...$scopeParams]);
$rows = $stmt->fetchAll();
foreach ($rows as &$r) {
foreach ($config['metrics'] as $m) {
$v = $r["m_{$m}"];
$r[$m] = $v === null ? null : ($metrics[$m][2] === '' ? (int) $v : round((float) $v, 1));
unset($r["m_{$m}"]);
}
}
unset($r);
$key = $config['sort'];
usort($rows, function ($a, $b) use ($key) {
$c = $key === 'label' ? strnatcasecmp((string) $a['label'], (string) $b['label']) : (($a[$key] ?? -1) <=> ($b[$key] ?? -1));
return $c;
});
if ($config['desc']) $rows = array_reverse($rows);
$totals = [];
foreach ($config['metrics'] as $m) {
$vals = array_filter(array_column($rows, $m), fn($v) => $v !== null);
if ($metrics[$m][2] === '') $totals[$m] = array_sum($vals);
else $totals[$m] = $vals ? round(array_sum($vals) / count($vals), 1) : null; // Durchschnitt der Zeilen
}
$columns = [['key' => 'label', 'label' => $dimLabel, 'unit' => '']];
foreach ($config['metrics'] as $m) $columns[] = ['key' => $m, 'label' => $metrics[$m][0], 'unit' => $metrics[$m][2]];
return ['config' => $config, 'columns' => $columns, 'rows' => $rows, 'totals' => $totals];
}
/** Bericht als CSV (Semikolon-getrennt mit UTF-8-BOM für Excel). */
function report_to_csv(array $report): string
{
$out = fopen('php://temp', 'r+');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(fn($c) => $c['label'] . ($c['unit'] ? " ({$c['unit']})" : ''), $report['columns']), ';');
foreach ($report['rows'] as $row) {
$line = [];
foreach ($report['columns'] as $c) {
$v = $row[$c['key']] ?? '';
// Schutz vor Formel-Injektion in Tabellenkalkulationen
if ($c['key'] === 'label' && is_string($v) && preg_match('/^[=+\-@]/', $v)) $v = "'" . $v;
$line[] = is_float($v) ? str_replace('.', ',', (string) $v) : $v;
}
fputcsv($out, $line, ';');
}
$tot = ['Gesamt'];
foreach (array_slice($report['columns'], 1) as $c) $tot[] = $report['totals'][$c['key']] ?? '';
fputcsv($out, $tot, ';');
rewind($out);
return (string) stream_get_contents($out);
}
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* Sicherheitsprüfung für den Betrieb (Adminportal → Sicherheitsprüfung).
*
* Prüft Einstellungen (Schlüssel, HTTPS, Konten ohne 2FA, Dateirechte, Proxy-Konfiguration) und ob interne Dateien
* (Konfiguration, Datenbank, Backups, Quelltext, Tests) vom Internet aus abrufbar sind. Das ist die häufigste Lücke
* bei Installationen ohne Apache-.htaccess (z. B. nginx), deshalb ruft DeskHub diese Adressen selbst ab.
*
* Jede Prüfung liefert ['level' => 'ok'|'warn'|'error'|'info', 'title' => ..., 'detail' => ...].
*/
/** Adressen, die niemals öffentlich abrufbar sein dürfen. */
function security_exposure_paths(): array
{
return [
'/config/config.php' => 'Konfiguration mit Datenbank-Passwort und Schlüssel',
'/database/deskhub.sqlite' => 'Datenbank-Datei',
'/database/schema.sql' => 'Datenbank-Schema',
'/backups/' => 'Backups (enthalten die komplette Datenbank)',
'/includes/functions.php' => 'Programmcode',
'/cron/fetch_emails.log' => 'Cron-Protokolle',
'/tests/run_all.php' => 'Testskripte',
'/version.php' => 'Versionsdatei',
'/.git/config' => 'Git-Verzeichnis',
'/install/.installed' => 'Installations-Sperrdatei',
];
}
/**
* Standard-Abruf über cURL (kein Weiterleiten, kurze Zeitgrenze). Gibt [HTTP-Status, Anfang des Inhalts] zurück
* oder [0, ''] bei Verbindungsfehler.
*/
function security_http_probe(string $url): array
{
if (!function_exists('curl_init')) return [0, ''];
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_RANGE => '0-511', CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_USERAGENT => 'DeskHub-Sicherheitspruefung/1']);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$body === false ? 0 : $status, is_string($body) ? $body : ''];
}
/**
* Prüft per Selbstabruf, ob interne Adressen erreichbar sind.
* $probe: fn(string $url): array{0:int,1:string}; austauschbar für Tests.
*/
function security_check_exposure(string $baseUrl, ?callable $probe = null): array
{
$probe ??= 'security_http_probe';
$baseUrl = rtrim($baseUrl, '/');
$out = [];
// Kontrollabruf: liefert der Server für eine nicht existierende Adresse "200", sagt ein 200 bei den echten Adressen nichts aus
[$controlStatus] = $probe($baseUrl . '/deskhub-pruefung-' . bin2hex(random_bytes(4)) . '.txt');
if ($controlStatus === 0) {
return [['level' => 'info', 'title' => 'Erreichbarkeit interner Dateien', 'detail' => 'Nicht prüfbar: Die eigene Adresse (' . $baseUrl . ') ließ sich vom Server aus nicht abrufen. Prüfen Sie "Website-URL" in den Einstellungen oder testen Sie die Adressen von Hand (siehe README, Abschnitt Sicherheit).']];
}
if ($controlStatus >= 200 && $controlStatus < 300) {
return [['level' => 'info', 'title' => 'Erreichbarkeit interner Dateien', 'detail' => 'Nicht prüfbar: Der Server antwortet auf jede Adresse mit "200". Testen Sie die Adressen von Hand (siehe README, Abschnitt Sicherheit).']];
}
$exposed = [];
$unknown = 0;
foreach (security_exposure_paths() as $path => $what) {
[$status] = $probe($baseUrl . $path);
if ($status === 0) { $unknown++; continue; }
if ($status >= 200 && $status < 300) $exposed[] = $path . ' (' . $what . ')';
}
if ($exposed) {
$out[] = ['level' => 'error', 'title' => 'Interne Dateien sind aus dem Internet abrufbar', 'detail' => 'Diese Adressen liefern Inhalt aus, obwohl sie gesperrt sein müssen: ' . implode('; ', $exposed) . '. Auf nginx und anderen Servern ohne .htaccess-Unterstützung fehlen die Sperrregeln (README, Abschnitt Sicherheit → nginx). Bis zur Behebung: Passwörter und Schlüssel als kompromittiert betrachten.'];
} else {
$out[] = ['level' => 'ok', 'title' => 'Interne Dateien sind gesperrt', 'detail' => 'Konfiguration, Datenbank, Backups, Programmcode und Tests sind von außen nicht abrufbar.' . ($unknown ? " ({$unknown} Adresse(n) ließen sich nicht abrufen.)" : '')];
}
return $out;
}
/** Prüft, ob hochgeladene PHP-Dateien ausgeführt würden (schreibt eine kurze Testdatei, ruft sie ab, löscht sie wieder). */
function security_check_upload_execution(string $baseUrl, string $uploadsDir, ?callable $probe = null): array
{
$probe ??= 'security_http_probe';
$name = 'pruefung-' . bin2hex(random_bytes(6)) . '.php';
$file = rtrim($uploadsDir, '/' . chr(92)) . '/' . $name;
$marker = bin2hex(random_bytes(8));
if (@file_put_contents($file, '<?php echo "' . $marker . '";') === false) {
return ['level' => 'info', 'title' => 'Ausführung hochgeladener Dateien', 'detail' => 'Nicht prüfbar: Der Ordner uploads/ ist nicht beschreibbar.'];
}
try {
[$status, $body] = $probe(rtrim($baseUrl, '/') . '/uploads/' . $name);
} finally {
@unlink($file);
}
if ($status === 0) return ['level' => 'info', 'title' => 'Ausführung hochgeladener Dateien', 'detail' => 'Nicht prüfbar: die eigene Adresse ließ sich nicht abrufen.'];
if (str_contains($body, $marker) && !str_contains($body, '<?php')) { // Ausgabe ohne Quelltext = der Code wurde ausgeführt (wird nur der Quelltext geliefert, läuft er nicht)
return ['level' => 'error', 'title' => 'Hochgeladene PHP-Dateien würden ausgeführt', 'detail' => 'Im Ordner uploads/ läuft PHP-Code. Wer eine Datei hochladen kann, könnte den Server übernehmen. Sperren Sie die PHP-Ausführung in uploads/ (README, Abschnitt Sicherheit → nginx).'];
}
return ['level' => 'ok', 'title' => 'Hochgeladene Dateien werden nicht ausgeführt', 'detail' => 'PHP-Code im Ordner uploads/ wird nicht ausgeführt.'];
}
/** Einstellungs- und Konfigurationsprüfungen (ohne Netzwerkzugriffe). */
function security_check_settings(PDO $pdo): array
{
$cfg = config();
$out = [];
// Verschlüsselungsschlüssel
$hasKey = !empty($cfg['app_key']) || (string) get_setting('app_key', '') !== '';
$out[] = $hasKey
? ['level' => 'ok', 'title' => 'Verschlüsselungsschlüssel gesetzt', 'detail' => 'Gespeicherte Zugangsdaten (SMTP, IMAP, Webhooks) sind mit einem eigenen Schlüssel verschlüsselt.']
: ['level' => 'error', 'title' => 'Kein Verschlüsselungsschlüssel (app_key)', 'detail' => 'Ohne app_key wird ein schwacher Ersatzschlüssel aus den Datenbank-Zugangsdaten abgeleitet. Eintragen in config/config.php: php -r "echo bin2hex(random_bytes(32));". Danach nie mehr ändern.'];
// HTTPS
$siteUrl = base_url();
if (!str_starts_with($siteUrl, 'https://')) {
$out[] = ['level' => 'error', 'title' => 'Website-URL nutzt kein HTTPS', 'detail' => 'Anmeldedaten und Sitzungen würden unverschlüsselt übertragen. Stellen Sie die Website-URL auf https:// um und richten Sie ein Zertifikat ein.'];
} elseif (!request_is_https()) {
$out[] = ['level' => 'warn', 'title' => 'Diese Seite wurde nicht per HTTPS aufgerufen', 'detail' => 'Die Website-URL ist https, aber DeskHub erkennt die Verbindung nicht als verschlüsselt. Läuft DeskHub hinter einem Reverse-Proxy, tragen Sie dessen Adresse in config/config.php unter site → trusted_proxies ein.'];
} else {
$out[] = ['level' => 'ok', 'title' => 'HTTPS aktiv', 'detail' => 'Die Seite wird verschlüsselt ausgeliefert, Sitzungs-Cookies sind als "secure" markiert.'];
}
// Reverse-Proxy ohne Konfiguration: alle Besucher teilen sich eine IP (gemeinsame Login-Sperre)
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && !request_from_trusted_proxy()) {
$out[] = ['level' => 'warn', 'title' => 'Reverse-Proxy nicht eingetragen', 'detail' => 'Die Anfrage kam über einen Proxy (X-Forwarded-For), aber dessen Adresse ist nicht als vertrauenswürdig eingetragen. DeskHub sieht dadurch für alle Besucher dieselbe IP: Anmelde-Sperren treffen dann alle gleichzeitig. Eintragen unter site → trusted_proxies in config/config.php (Adresse oder Bereich des Proxys, z. B. \'172.16.0.0/12\').'];
}
// Fehlerausgabe
if (filter_var(ini_get('display_errors'), FILTER_VALIDATE_BOOLEAN)) {
$out[] = ['level' => 'warn', 'title' => 'PHP-Fehler werden im Browser angezeigt', 'detail' => 'display_errors ist aktiv. Fehlermeldungen können Pfade und Zugangsdaten verraten. In php.ini auf "Off" stellen.'];
}
// Installationsassistent
$installDir = dirname(__DIR__) . '/install';
if (is_dir($installDir) && !is_file($installDir . '/.installed')) {
$out[] = ['level' => 'error', 'title' => 'Installationsassistent ist nicht gesperrt', 'detail' => 'Der Ordner install/ existiert ohne Sperrdatei. Löschen Sie den Ordner install/ vom Server.'];
} elseif (is_dir($installDir)) {
$out[] = ['level' => 'info', 'title' => 'Ordner install/ vorhanden', 'detail' => 'Der Assistent ist gesperrt. Zusätzlich empfohlen: Ordner install/ vom Server löschen.'];
}
// Konfigurationsdatei nicht durch den Webserver beschreibbar
$configFile = dirname(__DIR__) . '/config/config.php';
if (is_file($configFile) && is_writable($configFile) && DIRECTORY_SEPARATOR === '/') {
$out[] = ['level' => 'warn', 'title' => 'config/config.php ist für den Webserver beschreibbar', 'detail' => 'Nach der Installation sollte die Datei nur lesbar sein (chmod 640, Besitzer root/Deploy-Benutzer, Gruppe des Webservers).'];
}
// Datenbanktyp
if (($cfg['db']['driver'] ?? 'sqlite') === 'sqlite') {
$out[] = ['level' => 'warn', 'title' => 'Datenbank: SQLite', 'detail' => 'SQLite ist für Tests und kleine Installationen gedacht. Für den Produktivbetrieb mit mehreren Mitarbeitern wird MySQL/MariaDB empfohlen.'];
}
// Konten ohne Zwei-Faktor-Anmeldung
$noTotp = $pdo->query("SELECT name FROM staff WHERE is_active = 1 AND role = 'admin' AND (totp_enabled IS NULL OR totp_enabled = 0)")->fetchAll(PDO::FETCH_COLUMN);
if ($noTotp) {
$out[] = ['level' => 'warn', 'title' => 'Administratoren ohne Zwei-Faktor-Anmeldung', 'detail' => implode(', ', array_map('strval', $noTotp)) . '. Unter Profil → Sicherheit lässt sich 2FA aktivieren.'];
} else {
$out[] = ['level' => 'ok', 'title' => 'Alle Administratoren nutzen Zwei-Faktor-Anmeldung', 'detail' => ''];
}
// Backups
$bk = json_decode((string) get_setting('backup_last_check', ''), true);
if (!is_array($bk)) {
$out[] = ['level' => 'warn', 'title' => 'Noch kein geprüftes Backup', 'detail' => 'Erstellen Sie unter Backup eines und prüfen Sie es.'];
} elseif (empty($bk['ok'])) {
$out[] = ['level' => 'error', 'title' => 'Letztes Backup nicht wiederherstellbar', 'detail' => implode(' ', (array) ($bk['errors'] ?? []))];
} elseif (!empty($bk['at']) && strtotime((string) $bk['at']) < time() - 8 * 86400) {
$out[] = ['level' => 'warn', 'title' => 'Letztes geprüftes Backup ist älter als 8 Tage', 'detail' => 'Stand: ' . $bk['at'] . '. Automatisches Backup einrichten (Backup → Zeitplan).'];
} else {
$out[] = ['level' => 'ok', 'title' => 'Aktuelles Backup geprüft', 'detail' => 'Stand: ' . ($bk['at'] ?? '')];
}
// Plugins
$unsigned = $pdo->query("SELECT name FROM plugins WHERE is_active = 1 AND (signature_ok IS NULL OR signature_ok = 0)")->fetchAll(PDO::FETCH_COLUMN);
if ($unsigned) {
$out[] = ['level' => 'info', 'title' => 'Aktive Plugins ohne Herausgeber-Signatur', 'detail' => implode(', ', array_map('strval', $unsigned)) . ': Eigene Plugins laufen mit allen Rechten des Systems. Nur Code aus vertrauenswürdiger Quelle einsetzen.'];
}
return $out;
}
/** Führt alle Prüfungen aus. $withNetwork = false überspringt die Selbstabrufe (z. B. in Tests). */
function security_check_all(PDO $pdo, bool $withNetwork = true, ?callable $probe = null): array
{
$results = security_check_settings($pdo);
if ($withNetwork) {
$base = base_url();
$results = array_merge(security_check_exposure($base, $probe), [security_check_upload_execution($base, (string) (config()['uploads']['path'] ?? dirname(__DIR__) . '/uploads'), $probe)], $results);
}
$order = ['error' => 0, 'warn' => 1, 'info' => 2, 'ok' => 3];
usort($results, fn($a, $b) => $order[$a['level']] <=> $order[$b['level']]);
return $results;
}
+128
View File
@@ -0,0 +1,128 @@
<?php
/**
* Eigenständiger ZIP-Writer ohne php_zip Erweiterung.
* Implementiert ZIP-Format (PKZIP 2.0) direkt in PHP.
*/
class SimpleZip
{
private string $path;
private $fh;
private array $entries = [];
private int $offset = 0;
public function __construct(string $path)
{
$this->path = $path;
$this->fh = fopen($path, 'wb');
if (!$this->fh) throw new RuntimeException("Kann ZIP nicht erstellen: $path");
}
public function addFromString(string $localName, string $data): void
{
$this->addData($localName, $data, time());
}
public function addFile(string $filePath, string $localName): void
{
$data = file_get_contents($filePath);
$mtime = filemtime($filePath);
$this->addData($localName, $data, $mtime);
}
private function addData(string $localName, string $data, int $mtime): void
{
$localName = str_replace('\\', '/', $localName);
$crc = crc32($data);
$size = strlen($data);
$compressed = gzdeflate($data, 6);
$compSize = strlen($compressed);
// Lokaler Header
$dosTime = $this->dosTime(filemtime($filePath));
$nameLen = strlen($localName);
$localHeader = pack('VvvvvVVVvv',
0x04034b50, // Signatur
20, // Version needed
0, // Flags
8, // Deflate
$dosTime, // Mod time
$crc,
$compSize,
$size,
$nameLen,
0 // Extra length
) . $localName;
fwrite($this->fh, $localHeader);
fwrite($this->fh, $compressed);
$this->entries[] = [
'name' => $localName,
'crc' => $crc,
'size' => $size,
'compSize' => $compSize,
'offset' => $this->offset,
'dosTime' => $dosTime,
];
$this->offset += strlen($localHeader) + $compSize;
}
public function close(): void
{
$cdOffset = $this->offset;
$cdSize = 0;
foreach ($this->entries as $e) {
$nameLen = strlen($e['name']);
$cd = pack('VvvvvVVVvvvvvVV',
0x02014b50, // CD Signatur
20, // Version made by
20, // Version needed
0, // Flags
8, // Deflate
$e['dosTime'],
$e['crc'],
$e['compSize'],
$e['size'],
$nameLen,
0, // Extra length
0, // Comment length
0, // Disk start
0, // Internal attr
0, // External attr
$e['offset']
) . $e['name'];
fwrite($this->fh, $cd);
$cdSize += strlen($cd);
}
// End of central directory
$count = count($this->entries);
fwrite($this->fh, pack('VvvvvVVv',
0x06054b50, // EOCD Signatur
0, // Disk number
0, // Disk with CD
$count, // Entries on disk
$count, // Total entries
$cdSize,
$cdOffset,
0 // Comment length
));
fclose($this->fh);
}
private function dosTime(int $unixTime): int
{
$d = getdate($unixTime);
return (($d['year'] - 1980) << 25)
| ($d['mon'] << 21)
| ($d['mday'] << 16)
| ($d['hours'] << 11)
| ($d['minutes'] << 5)
| ($d['seconds'] >> 1);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
/**
* Einfacher SMTP-Client ohne externe Abhängigkeiten (kein Composer/PHPMailer).
*
* Grund: PHP mail() ist auf vielen Hosting-Paketen unzuverlässig (hängt vom
* lokal konfigurierten Sendmail/Postfix ab, landet oft im Spam-Ordner ohne
* korrekte SPF/DKIM-Anbindung des Hosters). Mit eigenen SMTP-Zugangsdaten
* (z.B. vom E-Mail-Provider der Domain) wird stattdessen direkt und
* zuverlässig über deren Mailserver versendet.
*
* Unterstützt: SMTP mit STARTTLS (Port 587), implizites SSL (Port 465),
* unverschlüsselt (Port 25, nicht empfohlen) sowie AUTH LOGIN.
*/
class SmtpMailer
{
private string $host;
private int $port;
private string $encryption; // 'none' | 'tls' | 'ssl'
private string $username;
private string $password;
private int $timeout;
private $socket = null;
private string $lastError = '';
public function __construct(string $host, int $port, string $encryption, string $username, string $password, int $timeout = 12)
{
$this->host = $host;
$this->port = $port;
$this->encryption = $encryption;
$this->username = $username;
$this->password = $password;
$this->timeout = $timeout;
}
public function getLastError(): string
{
return $this->lastError;
}
/**
* Sendet eine E-Mail. $to/$from können "Name <adresse>" oder nur die
* Adresse sein. $htmlBody wird als text/html (UTF-8, Base64) versendet.
*/
public function send(string $to, string $from, string $subject, string $htmlBody): bool
{
try {
$this->connect();
$this->ehlo();
if ($this->encryption === 'tls') {
$this->startTls();
$this->ehlo(); // EHLO muss nach STARTTLS wiederholt werden
}
if ($this->username !== '') {
$this->authLogin();
}
$fromEmail = $this->extractEmail($from);
$toEmail = $this->extractEmail($to);
$this->command("MAIL FROM:<{$fromEmail}>", [250]);
$this->command("RCPT TO:<{$toEmail}>", [250, 251]);
$this->command('DATA', [354]);
$headers = $this->buildHeaders($to, $from, $subject);
$encodedBody = chunk_split(base64_encode($htmlBody), 76, "\r\n");
$payload = $headers . "\r\n" . $encodedBody . "\r\n.";
$this->write($payload);
$this->readResponse([250]);
$this->command('QUIT', [221], false);
$this->close();
return true;
} catch (\Throwable $e) {
$this->lastError = $e->getMessage();
$this->close();
return false;
}
}
private function connect(): void
{
$prefix = $this->encryption === 'ssl' ? 'ssl://' : 'tcp://';
$this->socket = @stream_socket_client(
"{$prefix}{$this->host}:{$this->port}",
$errno, $errstr, $this->timeout,
STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
);
if (!$this->socket) {
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$errstr} ({$errno})");
}
stream_set_timeout($this->socket, $this->timeout);
$this->readResponse([220]);
}
private function ehlo(): void
{
$domain = $_SERVER['SERVER_NAME'] ?? 'localhost';
$this->command("EHLO {$domain}", [250]);
}
private function startTls(): void
{
$this->command('STARTTLS', [220]);
$ok = @stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$ok) {
throw new \RuntimeException('STARTTLS-Verschlüsselung konnte nicht aktiviert werden.');
}
}
private function authLogin(): void
{
$this->command('AUTH LOGIN', [334]);
$this->command(base64_encode($this->username), [334]);
$this->command(base64_encode($this->password), [235]);
}
private function buildHeaders(string $to, string $from, string $subject): string
{
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$headers = [];
$headers[] = "Date: " . date('r');
$headers[] = "To: {$to}";
$headers[] = "From: {$from}";
$headers[] = "Subject: {$encodedSubject}";
$headers[] = "MIME-Version: 1.0";
$headers[] = "Content-Type: text/html; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: base64";
return implode("\r\n", $headers) . "\r\n";
}
/** Extrahiert nur die reine Adresse aus "Name <adresse@example.com>" für die SMTP-Hüllkommandos */
private function extractEmail(string $addr): string
{
if (preg_match('/<([^>]+)>/', $addr, $m)) {
return trim($m[1]);
}
return trim($addr);
}
private function command(string $cmd, array $expectedCodes, bool $expectResponse = true): void
{
$this->write($cmd);
if ($expectResponse) {
$this->readResponse($expectedCodes);
}
}
private function write(string $data): void
{
if (@fwrite($this->socket, $data . "\r\n") === false) {
throw new \RuntimeException('Schreiben zum SMTP-Server fehlgeschlagen.');
}
}
private function readResponse(array $expectedCodes): string
{
$response = '';
$code = null;
while (!feof($this->socket)) {
$line = fgets($this->socket, 515);
if ($line === false) break;
$response .= $line;
// Letzte Zeile einer mehrzeiligen Antwort hat ein Leerzeichen statt "-" nach dem Code
if (preg_match('/^(\d{3})([ -])/', $line, $m)) {
$code = (int) $m[1];
if ($m[2] === ' ') break;
} else {
break;
}
}
if ($code === null || !in_array($code, $expectedCodes, true)) {
throw new \RuntimeException("Unerwartete SMTP-Antwort: " . trim($response));
}
return $response;
}
private function close(): void
{
if ($this->socket) {
@fclose($this->socket);
$this->socket = null;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
/**
* Themes aus dem Katalog (Plugin-/Theme-Webseite) installieren. Nutzt dieselben Sicherheitsbausteine wie die Plugins:
* https-only, Prüfsumme, optionale Signatur mit dem hinterlegten Herausgeber-Schlüssel.
*
* Katalogformat: {"themes":[{"slug","name","version","description","author","downloadUrl","sha256","signature"}]}
* Signiert wird plugin_signature_message('theme-<slug>', <version>, <sha256>).
*/
function theme_catalog_url(): string
{
if (plugin_strict_mode()) return catalog_defaults()['themes_url'];
$own = trim((string) get_setting('theme_catalog_url', ''));
return $own !== '' ? $own : catalog_defaults()['themes_url'];
}
/** @return array{ok:bool, error?:string, themes?:array} */
function theme_catalog_fetch(): array
{
$url = theme_catalog_url();
if ($url === '') return ['ok' => false, 'error' => 'Es ist keine Katalog-Adresse für Themes hinterlegt.'];
if ($err = plugin_url_check($url)) return ['ok' => false, 'error' => $err];
[, $body, $error] = plugin_http_get($url, 1048576);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Der Katalog konnte nicht geladen werden: ' . ($error ?? 'unbekannter Fehler')];
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['themes']) || !is_array($data['themes'])) return ['ok' => false, 'error' => 'Der Katalog hat ein ungültiges Format.'];
$themes = [];
foreach ($data['themes'] as $e) {
if (!is_array($e)) continue;
$slug = (string) ($e['slug'] ?? '');
$version = (string) ($e['version'] ?? '');
$sha = strtolower((string) ($e['sha256'] ?? ''));
$dl = (string) ($e['downloadUrl'] ?? '');
if (!plugin_valid_slug($slug) || !preg_match('/^\d+(\.\d+){0,3}([-+][0-9A-Za-z.]+)?$/', $version) || !preg_match('/^[a-f0-9]{64}$/', $sha) || !str_starts_with($dl, 'https://')) continue;
$themes[] = [
'slug' => $slug, 'name' => mb_substr(trim((string) ($e['name'] ?? $slug)), 0, 120), 'version' => $version,
'description' => mb_substr((string) ($e['description'] ?? ''), 0, 500), 'author' => mb_substr((string) ($e['author'] ?? ''), 0, 120),
'downloadUrl' => $dl, 'sha256' => $sha, 'signature' => trim((string) ($e['signature'] ?? '')),
'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')),
];
}
return ['ok' => true, 'themes' => $themes];
}
/**
* Theme-Katalog mit Zwischenspeicher (1 Stunde), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen Abruf.
*
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int}
*/
function theme_catalog_cached(bool $refresh = false): array
{
$key = hash('sha256', theme_catalog_url());
if (!$refresh) {
$cached = json_decode((string) get_setting('theme_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
}
}
$res = theme_catalog_fetch();
if ($res['ok']) {
set_setting('theme_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'themes' => $res['themes']], JSON_UNESCAPED_UNICODE));
$res['cached_at'] = time();
}
return $res;
}
/**
* Installierte Version eines Katalog-Themes (Vergleich über den Anzeigenamen) oder null, wenn es nicht installiert ist.
* Hat das installierte Theme keine Versionsangabe, gilt es als aktuell ("0"), damit es nicht endlos zum Update angeboten wird.
*/
function theme_installed_version(string $catalogName): ?string
{
$wanted = normalize_custom_theme_name($catalogName);
foreach (list_custom_themes() as $t) {
if (normalize_custom_theme_name((string) $t['name']) === $wanted) return (string) ($t['theme_version'] ?? '') !== '' ? (string) $t['theme_version'] : '0';
}
return null;
}
/**
* Vorschau für ein installiertes Theme: bevorzugt das Bild aus dem Katalog (gleicher Name), sonst eine preview.png/.jpg/.webp
* im Theme-Ordner (wer ein eigenes Theme hochlädt, kann so selbst eine Vorschau mitgeben). Ohne beides null.
*
* @return array{kind:string, slug?:string, url?:string}|null
*/
function theme_installed_preview(array $theme, ?array $catalogThemes = null): ?array
{
$wanted = normalize_custom_theme_name((string) $theme['name']);
foreach ($catalogThemes ?? [] as $c) {
if (!empty($c['preview']) && normalize_custom_theme_name((string) $c['name']) === $wanted) return ['kind' => 'catalog', 'slug' => (string) $c['slug']];
}
foreach (['png', 'jpg', 'webp'] as $ext) {
if (is_file(custom_themes_directory() . '/' . $theme['id'] . '/preview.' . $ext)) return ['kind' => 'file', 'url' => '/assets/themes/' . rawurlencode((string) $theme['id']) . '/preview.' . $ext];
}
return null;
}
/** Mini-Vorschau (96x54) eines installierten Themes oder Platzhalter mit Anfangsbuchstaben. */
function installed_theme_thumb(array $theme, ?array $catalogThemes = null): string
{
$box = 'width:96px;height:54px;border-radius:6px;flex:none;background:var(--bg-soft,#eef2f5);border:1px solid var(--border,#e2e8f0);';
$p = theme_installed_preview($theme, $catalogThemes);
if ($p) {
$src = $p['kind'] === 'catalog' ? '/staff/catalog_preview.php?kind=theme&amp;slug=' . e($p['slug']) : e($p['url']);
return '<img src="' . $src . '" alt="" loading="lazy" width="96" height="54" style="' . $box . 'object-fit:cover">';
}
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $theme['name'], 0, 1))) . '</div>';
}
/** Ist das Katalog-Theme schon installiert, und braucht es ein Update? ['installed' => bool, 'update' => bool, 'version' => ?string] */
function theme_catalog_state(array $entry): array
{
$have = theme_installed_version($entry['name']);
if ($have === null) return ['installed' => false, 'update' => false, 'version' => null];
return ['installed' => true, 'update' => $have !== '0' && version_compare($entry['version'], $have, '>'), 'version' => $have];
}
/** Installiert ein Theme aus dem Katalog (aktiviert es nicht). */
function theme_catalog_install(string $slug): array
{
$catalog = theme_catalog_fetch();
if (!$catalog['ok']) return $catalog;
$entry = null;
foreach ($catalog['themes'] as $t) if ($t['slug'] === $slug) { $entry = $t; break; }
if (!$entry) return ['ok' => false, 'error' => 'Dieses Theme steht nicht im Katalog.'];
$state = theme_catalog_state($entry);
if ($state['installed'] && !$state['update']) return ['ok' => false, 'error' => 'Dieses Theme ist bereits in der aktuellen Version installiert.'];
if ($err = plugin_url_check($entry['downloadUrl'])) return ['ok' => false, 'error' => 'Download-Adresse nicht erlaubt: ' . $err];
[, $body, $error] = plugin_http_get($entry['downloadUrl'], CUSTOM_THEME_MAX_ARCHIVE_SIZE, 60);
if ($error !== null || $body === null) return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($error ?? 'unbekannter Fehler')];
$sha = hash('sha256', $body);
if (!hash_equals($entry['sha256'], $sha)) return ['ok' => false, 'error' => 'Die Prüfsumme (SHA-256) des Downloads stimmt nicht mit dem Katalog überein.'];
$signed = false;
if ($entry['signature'] !== '') {
if (plugin_publisher_key() === '') return ['ok' => false, 'error' => 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.'];
if (!plugin_verify_signature(plugin_signature_message('theme-' . $slug, $entry['version'], $sha), $entry['signature'], plugin_publisher_key())) {
return ['ok' => false, 'error' => 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber.'];
}
$signed = true;
} elseif (plugin_signatures_required('catalog')) {
return ['ok' => false, 'error' => 'Themes müssen signiert sein (Einstellung „Signatur erforderlich“). Diesem Paket fehlt die Signatur.'];
}
$tmp = tempnam(sys_get_temp_dir(), 'dht_');
file_put_contents($tmp, $body);
try {
$theme = install_custom_theme_archive($tmp, normalize_custom_theme_name($entry['name']));
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
} finally {
@unlink($tmp);
}
return ['ok' => true, 'theme' => $theme, 'signed' => $signed, 'hash' => $sha];
}
+412
View File
@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
const CUSTOM_THEME_MAX_ARCHIVE_SIZE = 10 * 1024 * 1024;
const CUSTOM_THEME_MAX_EXTRACTED_SIZE = 20 * 1024 * 1024;
const CUSTOM_THEME_MAX_FILES = 100;
function custom_themes_directory(): string
{
return rtrim((string) (getenv('DESKHUB_THEMES_DIR') ?: dirname(__DIR__) . '/assets/themes'), '/' . chr(92));
}
/**
* @return array<int, array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string}>
*/
function list_custom_themes(?string $themesDirectory = null): array
{
$themesDirectory ??= custom_themes_directory();
if (!is_dir($themesDirectory)) {
return [];
}
$themes = [];
foreach (scandir($themesDirectory) ?: [] as $id) {
if (!preg_match('/^[a-f0-9]{24}$/', $id)) {
continue;
}
$directory = $themesDirectory . DIRECTORY_SEPARATOR . $id;
$metadataPath = $directory . DIRECTORY_SEPARATOR . 'theme.json';
$stylesheetPath = $directory . DIRECTORY_SEPARATOR . 'theme.css';
if (!is_dir($directory) || !is_file($metadataPath) || !is_file($stylesheetPath)) {
continue;
}
$metadata = json_decode((string) file_get_contents($metadataPath), true);
if (!is_array($metadata) || !isset($metadata['name'], $metadata['installed_at'], $metadata['version'])) {
continue;
}
$themes[] = [
'id' => $id,
'name' => (string) $metadata['name'],
'author' => (string) ($metadata['author'] ?? 'DeskHub'),
'installed_at' => (string) $metadata['installed_at'],
'version' => (string) $metadata['version'],
'theme_version' => (string) ($metadata['theme_version'] ?? '1.0.0'),
];
}
usort($themes, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
return $themes;
}
function active_custom_theme_stylesheet(): ?string
{
$activeId = get_setting('active_custom_theme', '');
if (!$activeId || !preg_match('/^[a-f0-9]{24}$/', $activeId)) {
return null;
}
foreach (list_custom_themes() as $theme) {
if ($theme['id'] === $activeId) {
return '/assets/themes/' . rawurlencode($theme['id']) . '/theme.css?v=' . rawurlencode($theme['version']);
}
}
return null;
}
function normalize_custom_theme_name(string $name): string
{
return trim((string) preg_replace(
'/\s+/',
' ',
str_replace(['_', '-'], ' ', $name)
));
}
/**
* @return array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string, replaced: bool}
*/
function install_custom_theme_upload(array $file, ?string $themesDirectory = null): array
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('Die PHP-Erweiterung ZIP ist auf diesem Server nicht installiert.');
}
if (empty($file['name'])) {
throw new RuntimeException('Bitte eine ZIP-Datei auswählen.');
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Der Theme-Upload ist fehlgeschlagen.');
}
if (($file['size'] ?? 0) > CUSTOM_THEME_MAX_ARCHIVE_SIZE) {
throw new RuntimeException('Das Theme darf maximal 10 MB groß sein.');
}
if (strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION)) !== 'zip') {
throw new RuntimeException('Bitte ein Theme als ZIP-Datei hochladen.');
}
$mimeType = null;
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo !== false) {
$mimeType = finfo_file($finfo, (string) $file['tmp_name']) ?: null;
finfo_close($finfo);
}
}
if ($mimeType !== null && !in_array($mimeType, [
'application/zip',
'application/x-zip',
'application/x-zip-compressed',
'application/octet-stream',
], true)) {
throw new RuntimeException('Die hochgeladene Datei ist kein gültiges ZIP-Archiv.');
}
$name = normalize_custom_theme_name(pathinfo((string) $file['name'], PATHINFO_FILENAME));
if ($name === '') {
throw new RuntimeException('Der Theme-Dateiname ist ungültig.');
}
return install_custom_theme_archive((string) $file['tmp_name'], $name, $themesDirectory);
}
/**
* @return array{id: string, name: string, author: string, installed_at: string, version: string, theme_version: string, replaced: bool}
*/
function install_custom_theme_archive(
string $archivePath,
string $displayName,
?string $themesDirectory = null
): array {
if (!class_exists('ZipArchive')) {
throw new RuntimeException('Die PHP-Erweiterung ZIP ist auf diesem Server nicht installiert.');
}
$displayName = normalize_custom_theme_name($displayName);
if ($displayName === '') {
throw new RuntimeException('Bitte einen Namen für das Theme angeben.');
}
if (mb_strlen($displayName) > 80) {
throw new RuntimeException('Der Theme-Name darf maximal 80 Zeichen lang sein.');
}
$themesDirectory ??= custom_themes_directory();
if (!is_dir($themesDirectory) && !mkdir($themesDirectory, 0755, true) && !is_dir($themesDirectory)) {
throw new RuntimeException('Das Theme-Verzeichnis konnte nicht erstellt werden.');
}
$zip = new ZipArchive();
if ($zip->open($archivePath) !== true) {
throw new RuntimeException('Das ZIP-Archiv konnte nicht geöffnet werden.');
}
$matchingThemes = array_values(array_filter(
list_custom_themes($themesDirectory),
static fn(array $theme): bool => strcasecmp(
normalize_custom_theme_name($theme['name']),
$displayName
) === 0
));
$activeThemeId = function_exists('get_setting')
? (string) get_setting('active_custom_theme', '')
: '';
$existingTheme = null;
foreach ($matchingThemes as $matchingTheme) {
if ($matchingTheme['id'] === $activeThemeId) {
$existingTheme = $matchingTheme;
break;
}
}
$existingTheme ??= $matchingThemes[0] ?? null;
$replaced = $existingTheme !== null;
$themeId = $existingTheme['id'] ?? bin2hex(random_bytes(12));
$operationId = bin2hex(random_bytes(12));
$stagingDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.tmp-' . $operationId;
$finalDirectory = $themesDirectory . DIRECTORY_SEPARATOR . $themeId;
$backupDirectory = $themesDirectory . DIRECTORY_SEPARATOR . '.backup-' . $operationId;
$allowedExtensions = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2'];
$fileCount = 0;
$totalSize = 0;
$hasRootStylesheet = false;
try {
if (!mkdir($stagingDirectory, 0755, true)) {
throw new RuntimeException('Das Theme konnte nicht vorbereitet werden.');
}
for ($index = 0; $index < $zip->numFiles; $index++) {
$entry = $zip->statIndex($index);
if ($entry === false) {
throw new RuntimeException('Das ZIP-Archiv enthält einen ungültigen Eintrag.');
}
$entryName = str_replace('\\', '/', (string) $entry['name']);
if (!is_safe_custom_theme_path($entryName)) {
throw new RuntimeException('Das ZIP-Archiv enthält einen unsicheren Dateipfad.');
}
if (str_ends_with($entryName, '/')) {
continue;
}
$fileCount++;
$declaredSize = (int) ($entry['size'] ?? 0);
$totalSize += $declaredSize;
if ($fileCount > CUSTOM_THEME_MAX_FILES || $totalSize > CUSTOM_THEME_MAX_EXTRACTED_SIZE) {
throw new RuntimeException('Das Theme enthält zu viele oder zu große Dateien.');
}
$extension = strtolower(pathinfo($entryName, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
throw new RuntimeException("Dateityp nicht erlaubt: {$entryName}");
}
if ($entryName === 'theme.css') {
$hasRootStylesheet = true;
}
$destination = $stagingDirectory . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $entryName);
$destinationDirectory = dirname($destination);
if (!is_dir($destinationDirectory)
&& !mkdir($destinationDirectory, 0755, true)
&& !is_dir($destinationDirectory)) {
throw new RuntimeException('Ein Theme-Unterverzeichnis konnte nicht erstellt werden.');
}
$input = $zip->getStream((string) $entry['name']);
$output = fopen($destination, 'xb');
if ($input === false || $output === false) {
if (is_resource($input)) {
fclose($input);
}
if (is_resource($output)) {
fclose($output);
}
throw new RuntimeException("Theme-Datei konnte nicht entpackt werden: {$entryName}");
}
$copied = stream_copy_to_stream($input, $output, CUSTOM_THEME_MAX_EXTRACTED_SIZE + 1);
fclose($input);
fclose($output);
if ($copied === false || $copied !== $declaredSize) {
throw new RuntimeException("Theme-Datei ist beschädigt: {$entryName}");
}
}
if (!$hasRootStylesheet) {
throw new RuntimeException('Im Stammverzeichnis des ZIP-Archivs fehlt die Datei theme.css.');
}
$stylesheetMetadata = read_custom_theme_stylesheet_metadata(
$stagingDirectory . DIRECTORY_SEPARATOR . 'theme.css'
);
$metadata = [
'name' => $displayName,
'author' => $stylesheetMetadata['author'],
'theme_version' => $stylesheetMetadata['version'],
'installed_at' => date('Y-m-d H:i:s'),
'version' => time() . '-' . bin2hex(random_bytes(4)),
];
$metadataJson = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (file_put_contents($stagingDirectory . DIRECTORY_SEPARATOR . 'theme.json', $metadataJson) === false) {
throw new RuntimeException('Die Theme-Metadaten konnten nicht gespeichert werden.');
}
if ($replaced) {
if (!rename($finalDirectory, $backupDirectory)) {
throw new RuntimeException('Das vorhandene Theme konnte nicht für die Aktualisierung vorbereitet werden.');
}
if (!rename($stagingDirectory, $finalDirectory)) {
if (!rename($backupDirectory, $finalDirectory)) {
throw new RuntimeException(
'Das Theme konnte nicht aktualisiert und die vorherige Version nicht wiederhergestellt werden.'
);
}
throw new RuntimeException('Das Theme konnte nicht aktualisiert werden.');
}
remove_custom_theme_directory($backupDirectory);
} elseif (!rename($stagingDirectory, $finalDirectory)) {
throw new RuntimeException('Das Theme konnte nicht installiert werden.');
}
foreach ($matchingThemes as $matchingTheme) {
if ($matchingTheme['id'] !== $themeId
&& !delete_custom_theme($matchingTheme['id'], $themesDirectory)) {
throw new RuntimeException(
'Das Theme wurde aktualisiert, aber ein vorhandenes Duplikat konnte nicht entfernt werden.'
);
}
}
return ['id' => $themeId] + $metadata + ['replaced' => $replaced];
} finally {
$zip->close();
if (is_dir($stagingDirectory)) {
remove_custom_theme_directory($stagingDirectory);
}
}
}
/**
* @return array{author: string, version: string}
*/
function read_custom_theme_stylesheet_metadata(string $stylesheetPath): array
{
$header = (string) file_get_contents($stylesheetPath, false, null, 0, 4096);
$author = preg_match('/^\s*\*\s*Author:\s*(.+?)\s*$/mi', $header, $match)
? trim($match[1])
: 'Nicht angegeben';
$version = preg_match('/^\s*\*\s*Version:\s*([0-9]+(?:\.[0-9]+){0,2})\s*$/mi', $header, $match)
? $match[1]
: '1.0.0';
if (mb_strlen($author) > 80) {
throw new RuntimeException('Der Theme-Autor darf maximal 80 Zeichen lang sein.');
}
return [
'author' => $author !== '' ? $author : 'Nicht angegeben',
'version' => $version,
];
}
function delete_custom_theme(string $themeId, ?string $themesDirectory = null): bool
{
if (!preg_match('/^[a-f0-9]{24}$/', $themeId)) {
return false;
}
$themesDirectory ??= custom_themes_directory();
$directory = $themesDirectory . DIRECTORY_SEPARATOR . $themeId;
if (!is_dir($directory)) {
return false;
}
$trashDirectory = $themesDirectory . DIRECTORY_SEPARATOR
. '.delete-' . $themeId . '-' . bin2hex(random_bytes(4));
for ($attempt = 0; $attempt < 4; $attempt++) {
if (@rename($directory, $trashDirectory)) {
return remove_custom_theme_directory($trashDirectory);
}
if ($attempt < 3) {
usleep(250000);
}
}
return remove_custom_theme_directory($directory);
}
function is_safe_custom_theme_path(string $path): bool
{
if ($path === '' || str_contains($path, "\0") || str_starts_with($path, '/') || str_contains($path, ':')) {
return false;
}
foreach (explode('/', rtrim($path, '/')) as $part) {
if ($part === '' || $part === '.' || $part === '..' || str_starts_with($part, '.')) {
return false;
}
}
return true;
}
function remove_custom_theme_directory(string $directory): bool
{
if (!is_dir($directory)) {
return true;
}
foreach (scandir($directory) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory . DIRECTORY_SEPARATOR . $entry;
if (is_dir($path) && !is_link($path)) {
if (!remove_custom_theme_directory($path)) {
return false;
}
} else {
$deleted = false;
for ($attempt = 0; $attempt < 4; $attempt++) {
@chmod($path, 0666);
if (@unlink($path) || !file_exists($path)) {
$deleted = true;
break;
}
clearstatcache(true, $path);
if ($attempt < 3) {
usleep(250000);
}
}
if (!$deleted) {
return false;
}
}
}
for ($attempt = 0; $attempt < 4; $attempt++) {
if (@rmdir($directory) || !is_dir($directory)) {
return true;
}
clearstatcache(true, $directory);
if ($attempt < 3) {
usleep(250000);
}
}
return false;
}
+110
View File
@@ -0,0 +1,110 @@
<?php
/**
* includes/time_tracking.php
*
* Automatische Zeiterfassung pro Ticket: Sobald ein Mitarbeiter ein Ticket
* öffnet, startet eine "Arbeits-Sitzung" (ticket_work_sessions). Beendet er
* die Bearbeitung (Antwort/Notiz senden, anderes Ticket öffnen, Logout) oder
* bleibt länger als WORK_SESSION_STALE_MINUTES untätig, wird die Sitzung
* automatisch als Eintrag in ticket_time_entries protokolliert – ganz ohne
* dass jemand manuell die Minuten eintragen muss.
*
* Kann in Einstellungen über das Setting "auto_time_tracking_enabled"
* komplett abgeschaltet werden (Standard: aktiv).
*/
const WORK_SESSION_STALE_MINUTES = 10;
/** Startet eine neue Arbeits-Sitzung, oder frischt eine bestehende auf (Startzeit bleibt erhalten). */
function start_or_touch_work_session(PDO $pdo, int $ticketId, int $staffId): void
{
if (get_setting('auto_time_tracking_enabled', '1') !== '1') return;
$now = db_now();
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$pdo->prepare(
'INSERT INTO ticket_work_sessions (ticket_id, staff_id, started_at, last_seen_at) VALUES (?,?,?,?)
ON CONFLICT(ticket_id, staff_id) DO UPDATE SET last_seen_at = excluded.last_seen_at'
)->execute([$ticketId, $staffId, $now, $now]);
} else {
$pdo->prepare(
'INSERT INTO ticket_work_sessions (ticket_id, staff_id, started_at, last_seen_at) VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at)'
)->execute([$ticketId, $staffId, $now, $now]);
}
}
/**
* Schließt eine offene Arbeits-Sitzung ab und protokolliert die verstrichene Zeit
* (auf ganze Minuten gerundet, mindestens 1). Sitzungen unter 1 Minute werden
* verworfen statt als "0 Min."-Rauschen gespeichert. $endAt erlaubt, statt "jetzt"
* den letzten bekannten Aktivitätszeitpunkt zu verwenden (Stale-Sweep).
*/
function finalize_work_session(PDO $pdo, int $ticketId, int $staffId, ?string $endAt = null): void
{
$stmt = $pdo->prepare('SELECT * FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?');
$stmt->execute([$ticketId, $staffId]);
$session = $stmt->fetch();
if (!$session) return;
$pdo->prepare('DELETE FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?')->execute([$ticketId, $staffId]);
$end = $endAt ?? db_now();
$minutes = (int) round((strtotime($end) - strtotime($session['started_at'])) / 60);
if ($minutes < 1) return;
$pdo->prepare(
'INSERT INTO ticket_time_entries (ticket_id, staff_id, minutes, note, logged_at) VALUES (?,?,?,?,?)'
)->execute([$ticketId, $staffId, $minutes, 'Automatisch erfasst', db_now()]);
log_activity($pdo, $ticketId, $staffId, "Zeit automatisch erfasst: {$minutes} Min.");
}
/** Schließt alle offenen Sitzungen eines Mitarbeiters außer der auf $exceptTicketId ab (Ticket gewechselt). */
function finalize_other_open_sessions(PDO $pdo, int $staffId, int $exceptTicketId): void
{
$stmt = $pdo->prepare('SELECT ticket_id FROM ticket_work_sessions WHERE staff_id = ? AND ticket_id != ?');
$stmt->execute([$staffId, $exceptTicketId]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $otherTicketId) {
finalize_work_session($pdo, (int) $otherTicketId, $staffId);
}
}
/** Schließt ALLE offenen Sitzungen eines Mitarbeiters ab (z.B. beim Logout). */
function finalize_all_sessions_for_staff(PDO $pdo, int $staffId): void
{
$stmt = $pdo->prepare('SELECT ticket_id FROM ticket_work_sessions WHERE staff_id = ?');
$stmt->execute([$staffId]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $ticketId) {
finalize_work_session($pdo, (int) $ticketId, $staffId);
}
}
/**
* Räumt "verwaiste" Sitzungen auf (Tab geschlossen, Browser zu, Rechner aus –
* ohne dass jemand explizit geantwortet oder sich abgemeldet hat). Wird
* regelmäßig vom Auto-Cron (includes/pseudo_cron.php) aufgerufen. Nutzt den
* letzten bekannten Aktivitätszeitpunkt (last_seen_at) als Sitzungsende,
* nicht "jetzt" – die erfasste Zeit spiegelt also die tatsächliche Aktivität
* wider, nicht die Zeit bis zum nächsten Cron-Durchlauf.
*
* @return array{finalized: int, log: string[]}
*/
function finalize_stale_work_sessions(PDO $pdo, bool $verbose = false): array
{
$log = [];
$threshold = date('Y-m-d H:i:s', time() - WORK_SESSION_STALE_MINUTES * 60);
$stmt = $pdo->prepare('SELECT ticket_id, staff_id, last_seen_at FROM ticket_work_sessions WHERE last_seen_at < ?');
$stmt->execute([$threshold]);
$stale = $stmt->fetchAll();
foreach ($stale as $row) {
finalize_work_session($pdo, (int) $row['ticket_id'], (int) $row['staff_id'], $row['last_seen_at']);
}
if ($verbose) {
$log[] = "[" . date('Y-m-d H:i:s') . "] " . count($stale) . " inaktive Zeiterfassungs-Sitzung(en) abgeschlossen.";
}
return ['finalized' => count($stale), 'log' => $log];
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Minimale TOTP-Implementierung (RFC 6238) ohne externe Abhängigkeiten.
* Kompatibel mit Google Authenticator, Authy, etc.
*/
function totp_generate_secret(int $length = 16): string
{
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = '';
$bytes = random_bytes($length);
for ($i = 0; $i < $length; $i++) {
$secret .= $chars[ord($bytes[$i]) & 31];
}
return $secret;
}
function totp_get_code(string $secret, int $timeSlot = 0): string
{
$time = floor(time() / 30) + $timeSlot;
$key = base32_decode($secret);
$msg = pack('N*', 0) . pack('N*', $time);
$hash = hash_hmac('sha1', $msg, $key, true);
$offset = ord($hash[19]) & 0xF;
$code = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
((ord($hash[$offset + 3]) & 0xFF))
) % 1000000;
return str_pad((string) $code, 6, '0', STR_PAD_LEFT);
}
function totp_verify(string $secret, string $code, int $window = 1): bool
{
$code = preg_replace('/\s+/', '', $code);
if (!preg_match('/^\d{6}$/', $code)) return false;
for ($i = -$window; $i <= $window; $i++) {
if (hash_equals(totp_get_code($secret, $i), $code)) return true;
}
return false;
}
function totp_qr_url(string $secret, string $email, string $issuer = 'DeskHub'): string
{
$label = rawurlencode($issuer . ':' . $email);
$params = http_build_query([
'secret' => $secret,
'issuer' => $issuer,
'algorithm' => 'SHA1',
'digits' => 6,
'period' => 30,
]);
$otpauth = 'otpauth://totp/' . $label . '?' . $params;
return 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' . rawurlencode($otpauth);
}
function base32_decode(string $input): string
{
$map = array_flip(str_split('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'));
$input = strtoupper(rtrim($input, '='));
$binary = '';
$buffer = 0;
$bits = 0;
foreach (str_split($input) as $char) {
if (!isset($map[$char])) continue;
$buffer = ($buffer << 5) | $map[$char];
$bits += 5;
if ($bits >= 8) {
$binary .= chr(($buffer >> ($bits - 8)) & 0xFF);
$bits -= 8;
}
}
return $binary;
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* Sicherung des System-Updates.
*
* Ein Update ersetzt Programmcode. Würde das Konto beim Update-Server übernommen, könnten sonst Schadcode-Updates an
* alle Installationen gehen. Deshalb prüft DeskHub bei hinterlegtem Herausgeber-Schlüssel (includes/catalog_defaults.php)
* eine Signatur: Der Herausgeber signiert jedes Release-Paket mit seinem privaten Schlüssel (tools/release-sign.php),
* ohne passende Signatur wird nichts installiert. Der Schlüssel ist derselbe wie bei Plugins und Themes.
*
* Release-Dateien (Anhänge des Releases): deskhub-<Version>.zip und deskhub-<Version>.zip.sig
*/
/** Wird für Updates eine Herausgeber-Signatur verlangt? Ja, sobald ein Herausgeber-Schlüssel eingebaut ist. */
function update_signature_required(): bool
{
return function_exists('plugin_publisher_key') && plugin_publisher_key() !== '';
}
/** Zu signierende Nachricht: bindet Signatur an Version und Dateiinhalt. */
function update_signature_message(string $tag, string $sha256): string
{
return "deskhub-update\n" . ltrim(trim($tag), 'vV') . "\n" . strtolower($sha256);
}
/** Prüft die Signatur eines heruntergeladenen Pakets. Gibt null bei Erfolg zurück, sonst eine Fehlermeldung. */
function update_verify_package(string $tag, string $zipPath, string $signature): ?string
{
$signature = trim($signature);
if ($signature === '') return 'Dem Release fehlt die Signatur des Herausgebers.';
$key = plugin_publisher_key();
if ($key === '') return 'Es ist kein Herausgeber-Schlüssel hinterlegt, die Signatur kann nicht geprüft werden.';
$sha = hash_file('sha256', $zipPath);
if ($sha === false) return 'Das Paket konnte nicht gelesen werden.';
if (!plugin_verify_signature(update_signature_message($tag, $sha), $signature, $key)) {
return 'Die Signatur ist ungültig. Das Paket wurde verändert oder stammt nicht vom Herausgeber. Es wird nicht installiert.';
}
return null;
}
/**
* Sucht in den Anhängen eines Releases (Gitea-API) das Paket und die Signatur.
*
* @return array{zip: ?string, sig: ?string} Download-Adressen oder null
*/
function update_find_assets(array $release, string $tag): array
{
$version = ltrim(trim($tag), 'vV');
$names = ['zip' => "deskhub-{$version}.zip", 'sig' => "deskhub-{$version}.zip.sig"];
$out = ['zip' => null, 'sig' => null];
foreach ((array) ($release['assets'] ?? []) as $asset) {
$name = (string) ($asset['name'] ?? '');
$url = (string) ($asset['browser_download_url'] ?? '');
foreach ($names as $kind => $expected) {
if ($name === $expected && str_starts_with($url, 'https://')) $out[$kind] = $url;
}
}
return $out;
}
/** Kopfzeilen für Abrufe beim Update-Server; ein Zugangstoken ist nur nötig, wenn das Repository nicht öffentlich ist. */
function update_http_headers(): array
{
$token = (string) (config()['update']['token'] ?? '');
return $token !== '' ? ['Authorization: token ' . $token] : [];
}