Upload via GUI (258 Dateien)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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">×</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 © <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> – 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">×</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; ?>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) . '&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>';
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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&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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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] : [];
|
||||
}
|
||||
Reference in New Issue
Block a user