Upload via GUI (267 Dateien)

This commit is contained in:
2026-10-19 18:05:57 +00:00
parent f3e53e6182
commit c800be9693
12 changed files with 2694 additions and 2082 deletions
+62 -7
View File
@@ -22,9 +22,18 @@ function backup_excluded_fragments(): array
* @param string|null $rootDir Ordner, dessen Dateien gesichert werden (Standard: DeskHub-Hauptordner)
* @param string|null $backupDir Zielordner (Standard: backups/ im Hauptordner)
* @param PDO|null $pdo Datenbankverbindung (Standard: get_db())
* @param array $options Optional: 'with_database' (bool, Standard true), 'with_files' (bool, Standard true),
* 'only' (Liste von Pfaden relativ zum Hauptordner, nur diese Dateien/Ordner sichern),
* 'exclude' (Liste von Pfaden relativ zum Hauptordner, die nicht gesichert werden),
* 'verify' (bool, Standard true: Backup sofort automatisch auf Wiederherstellbarkeit prüfen und das Ergebnis merken)
*/
function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null): array
function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null, array $options = []): array
{
$withDb = $options['with_database'] ?? true;
$withFiles = $options['with_files'] ?? true;
$norm = fn(string $p) => trim(str_replace(chr(92), '/', $p), '/');
$onlyRel = array_values(array_filter(array_map($norm, (array) ($options['only'] ?? []))));
$excludeRel = array_values(array_filter(array_map($norm, (array) ($options['exclude'] ?? []))));
$rootDir = realpath($rootDir ?? __DIR__ . '/..');
$backupDir = $backupDir ?? $rootDir . '/backups';
$pdo = $pdo ?? get_db();
@@ -93,7 +102,7 @@ Deny from all
);
$files = [];
foreach ($iter as $file) {
foreach ($withFiles ? $iter : [] as $file) {
if (!$file->isFile()) continue;
$filePath = $file->getRealPath();
$norm = str_replace('\\', '/', $filePath);
@@ -106,7 +115,18 @@ Deny from all
}
if ($liveDb && ($filePath === $liveDb || $filePath === $liveDb . '-wal' || $filePath === $liveDb . '-shm' || $filePath === $liveDb . '-journal')) $skip = true;
if ($skip) continue;
$files[$filePath] = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
$rel = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
// Auswahl und Ausschlüsse (relativ zum Hauptordner, ganze Ordner oder einzelne Dateien)
$under = fn(array $list) => (bool) array_filter($list, fn($p) => $rel === $p || str_starts_with($rel, $p . '/'));
if ($onlyRel && !$under($onlyRel)) continue;
if ($excludeRel && $under($excludeRel)) continue;
$files[$filePath] = $rel;
}
// Dateien, die der Webserver-Benutzer nicht lesen kann (fremde Besitzer/Rechte), würden das ganze Backup scheitern lassen: überspringen und melden
$skipped = [];
foreach ($files as $filePath => $relativePath) {
if (!is_readable($filePath)) { $skipped[] = $relativePath; unset($files[$filePath]); }
}
// Abzug erzeugen
@@ -116,7 +136,7 @@ Deny from all
$tableCount = 0;
$dbError = null;
if ($driver === 'sqlite') {
if ($withDb && $driver === 'sqlite') {
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
@unlink($snapshot);
try {
@@ -130,7 +150,7 @@ Deny from all
if ($sqliteSnapshot) {
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn();
}
} elseif ($driver === 'mysql') {
} elseif ($withDb && $driver === 'mysql') {
try {
$dbName = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
$dump = "-- DeskHub MySQL Backup\n-- Erstellt: " . date('Y-m-d H:i:s') . "\n-- Datenbank: " . $dbName . "\n\nSET FOREIGN_KEY_CHECKS=0;\n\n";
@@ -175,7 +195,7 @@ Deny from all
$manifest = json_encode([
'created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null,
'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files),
'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files), 'skipped' => array_slice($skipped, 0, 50),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung
@@ -191,7 +211,32 @@ Deny from all
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
$zip->addFromString('backup_manifest.json', $manifest);
$zip->close();
$closed = @$zip->close();
if (!$closed || !file_exists($backupPath)) {
$zipReason = trim((string) $zip->getStatusString());
// Zweiter Versuch: nur Dateien, die sich wirklich öffnen lassen
@unlink($backupPath);
$retry = new ZipArchive();
if ($retry->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
foreach ($files as $filePath => $relativePath) {
$h = @fopen($filePath, 'rb');
if ($h === false) { $skipped[] = $relativePath; continue; }
fclose($h);
$retry->addFile($filePath, $relativePath);
}
if ($sqlDump && $sqlFile) $retry->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $retry->addFile($sqliteSnapshot, 'database_backup.sqlite');
$retry->addFromString('backup_manifest.json', json_encode(['created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null, 'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files) - count($skipped), 'skipped' => array_slice($skipped, 0, 50)], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
@$retry->close();
if (!file_exists($backupPath)) $zipReason = trim($zipReason . ' ' . $retry->getStatusString());
}
if (!file_exists($backupPath)) {
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
$free = @disk_free_space($backupDir);
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt' . ($zipReason !== '' ? ' (' . $zipReason . ')' : '')
. '. Prüfen: Ist der Ordner „' . basename($backupDir) . '“ beschreibbar' . ($free !== false ? ' und ist genug Speicherplatz frei (aktuell ' . round($free / 1048576) . ' MB)' : '') . '?'];
}
}
} else {
// Eigene ZIP-Implementierung (kein php_zip nötig)
$zip = new SimpleZip($backupPath);
@@ -213,11 +258,21 @@ Deny from all
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt.'];
}
// Automatische Prüfung: Ein Backup, das sich nicht wiederherstellen lässt, ist wertlos. Das Ergebnis steht in der Liste und im Hinweis im System.
// (Backups ohne Datenbank, zum Beispiel reine Datei-Sicherungen von Plugins, lassen sich so nicht prüfen und werden hier ausgelassen.)
$verify = null;
if ($withDb && ($options['verify'] ?? true)) {
if (!function_exists('backup_verify_and_record')) require_once __DIR__ . '/backup_restore.php';
$verify = backup_verify_and_record($backupPath);
}
return [
'ok' => true,
'filename' => $backupName,
'path' => $backupPath,
'files' => count($files),
'skipped' => $skipped,
'verify' => $verify,
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
];
}
+46 -1
View File
@@ -293,6 +293,51 @@ function backup_path_for(string $filename, ?string $backupDir = null): ?string
function backup_verify_and_record(string $zipPath): array
{
$r = backup_verify($zipPath);
set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => date('Y-m-d H:i:s'), 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
$now = date('Y-m-d H:i:s');
$wasNotified = !empty((json_decode((string) get_setting('backup_check_map', ''), true) ?: [])[basename($zipPath)]['notified']);
set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => $now, 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
// Ergebnis je Datei (für die Liste auf der Backup-Seite), nur die letzten 40
$map = json_decode((string) get_setting('backup_check_map', ''), true);
$map = is_array($map) ? $map : [];
$map[basename($zipPath)] = ['ok' => (bool) $r['ok'], 'at' => $now, 'notified' => $wasNotified];
// Defektes Backup: Administrator einmal per E-Mail warnen
if (!$r['ok'] && !$wasNotified && function_exists('send_mail')) {
$to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? ''));
if ($to !== '') {
$map[basename($zipPath)]['notified'] = true;
@send_mail($to, 'DeskHub: Backup nicht wiederherstellbar', '<p>Die automatische Prüfung hat ein Problem gefunden:</p><p><strong>' . htmlspecialchars(basename($zipPath)) . '</strong></p><p>' . htmlspecialchars(implode(' ', (array) $r['errors'])) . '</p><p>Erstelle ein neues Backup und prüfe die Einstellungen unter Adminportal → Backup.</p>');
}
}
if (count($map) > 40) $map = array_slice($map, -40, null, true);
set_setting('backup_check_map', json_encode($map));
return $r;
}
/**
* Prüft Backups automatisch nach: solche, die noch nie geprüft wurden, und solche, deren letzte Prüfung über $maxAgeDays Tage her ist
* (um spätere Beschädigungen zu bemerken). Pro Aufruf höchstens $limit Backups, neueste zuerst.
*
* @return array{checked:int,bad:int}
*/
function backup_verify_pending(int $limit = 2, int $maxAgeDays = 7, ?string $backupDir = null): array
{
$dir = realpath($backupDir ?? __DIR__ . '/../backups');
$out = ['checked' => 0, 'bad' => 0];
if (!$dir) return $out;
$map = json_decode((string) get_setting('backup_check_map', ''), true);
$map = is_array($map) ? $map : [];
$files = glob($dir . '/backup_*.zip') ?: [];
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
$stale = time() - $maxAgeDays * 86400;
foreach ($files as $f) {
if ($out['checked'] >= $limit) break;
$name = basename($f);
if (!preg_match('/^backup_[\d\-_a-f]+\.zip$/', $name)) continue;
$entry = $map[$name] ?? null;
if ($entry && strtotime((string) $entry['at']) > $stale && strtotime((string) $entry['at']) >= filemtime($f)) continue; // frisch geprüft
$r = backup_verify_and_record($f);
$out['checked']++;
if (empty($r['ok'])) $out['bad']++;
}
return $out;
}
+35
View File
@@ -15,10 +15,38 @@ function config(): array
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
$config = array_replace_recursive($config, $extra);
}
// Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank
// (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei.
if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') {
$testDb = getenv('DESKHUB_SQLITE_PATH');
if (PHP_SAPI === 'cli' || ($testDb !== false && $testDb !== '' && $testDb !== ':memory:')) {
$config['db']['driver'] = 'sqlite';
$config['db']['sqlite_path'] = $testDb !== false && $testDb !== '' ? $testDb : ':memory:';
}
}
}
return $config;
}
/**
* Die Datenbank ist nicht eingerichtet: Mit vorhandenem Installer dorthin weiterleiten, sonst eine verständliche Meldung.
* Auf der Kommandozeile (Cron, Skripte) wird ein Fehler geworfen.
*/
function app_not_installed(): never
{
$message = 'DeskHub ist noch nicht eingerichtet: Die Datenbank fehlt. Bitte die Installation unter /install/ abschließen.';
if (PHP_SAPI === 'cli') throw new RuntimeException($message);
$installerOpen = is_file(__DIR__ . '/../install/index.php') && !is_file(__DIR__ . '/../install/.installed');
if ($installerOpen && !headers_sent()) {
header('Location: /install/');
exit;
}
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
exit($message . "
");
}
function get_db(): PDO
{
static $pdo = null;
@@ -32,6 +60,13 @@ function get_db(): PDO
$dbPath = $cfg['sqlite_path'];
$needInit = !file_exists($dbPath) || filesize($dbPath) === 0;
// Eine SQLite-Datenbank wird nie nebenbei angelegt (z. B. durch den ersten Seitenaufruf mit einer mitgelieferten Konfiguration):
// Das macht allein der Installer, wenn SQLite gewählt wurde. Nur Tests und Entwickler, die den Pfad ausdrücklich per
// DESKHUB_SQLITE_PATH angeben, dürfen eine neue Datenbank erzeugen lassen.
if ($needInit && $dbPath !== ':memory:' && (string) getenv('DESKHUB_SQLITE_PATH') === '') {
app_not_installed();
}
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA foreign_keys = ON;');
+215
View File
@@ -282,6 +282,99 @@ function time_until(?string $dt): string
return $diff >= 0 ? "in {$label}" : "vor {$label} (überfällig)";
}
/**
* Die zur Wahl stehenden Signaturen für eine Antwort in einem Ticket (Web, Windows- und Android-App):
* eigene und die der Abteilung (bereinigtes HTML), Abteilungsname und die Voreinstellung ('own', 'dept' oder 'none').
*
* @return array{own: string, dept: string, deptName: string, default: string}
*/
function reply_signature_options(PDO $pdo, array $staff, int $departmentId): array
{
$own = trim((string) ($staff['signature'] ?? ''));
$stmt = $pdo->prepare('SELECT name, signature FROM departments WHERE id = ?');
$stmt->execute([$departmentId]);
$row = $stmt->fetch() ?: ['name' => '', 'signature' => ''];
$dept = trim((string) $row['signature']);
return [
'own' => $own !== '' ? sanitize_staff_html($own) : '',
'dept' => $dept !== '' ? sanitize_staff_html($dept) : '',
'deptName' => (string) $row['name'],
'default' => $own !== '' ? 'own' : ($dept !== '' ? 'dept' : 'none'),
];
}
/**
* Bestimmt die Signatur, die an eine öffentliche Antwort angehängt wird.
* $choice: 'own' = Signatur des Mitarbeiters, 'dept' = Signatur der Abteilung des Tickets, 'none' = keine,
* sonst (oder leer) das bisherige Verhalten: eigene Signatur, ersatzweise die der Abteilung.
* Gibt bereinigtes HTML zurück.
*/
function resolve_reply_signature(PDO $pdo, array $staff, int $departmentId, ?string $choice = null): string
{
$own = trim((string) ($staff['signature'] ?? ''));
$deptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$deptStmt->execute([$departmentId]);
$dept = trim((string) $deptStmt->fetchColumn());
$signature = match ($choice) {
'own' => $own,
'dept' => $dept,
'none' => '',
default => $own !== '' ? $own : $dept,
};
return $signature === '' ? '' : sanitize_staff_html($signature);
}
/** Unterstützte Favicon-Formate: Endung => MIME-Typ. */
function favicon_types(): array
{
return ['ico' => 'image/x-icon', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml'];
}
/** Prüft, ob die Datei wirklich ein Bild des angegebenen Formats ist (nicht nur die Endung). Gibt null oder eine Fehlermeldung zurück. */
function favicon_validate_file(string $path, string $ext): ?string
{
$head = (string) @file_get_contents($path, false, null, 0, 16);
switch ($ext) {
case 'ico':
return str_starts_with($head, "\x00\x00\x01\x00") || str_starts_with($head, "\x00\x00\x02\x00") ? null : 'Das ist keine gültige ICO-Datei.';
case 'png': return str_starts_with($head, "\x89PNG") ? null : 'Das ist keine gültige PNG-Datei.';
case 'jpg':
case 'jpeg': return str_starts_with($head, "\xFF\xD8\xFF") ? null : 'Das ist keine gültige JPG-Datei.';
case 'gif': return str_starts_with($head, 'GIF8') ? null : 'Das ist keine gültige GIF-Datei.';
case 'webp': return str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP' ? null : 'Das ist keine gültige WebP-Datei.';
case 'svg':
$svg = (string) @file_get_contents($path, false, null, 0, 1048576);
if (!preg_match('/<svg\b/i', $svg)) return 'Das ist keine gültige SVG-Datei.';
// Als Favicon läuft eine SVG im Bild-Kontext ohne Skripte; trotzdem keine Datei mit Skripten oder Ereignis-Handlern annehmen
if (preg_match('/<script\b|<foreignObject\b|\son[a-z]+\s*=|javascript:/i', $svg)) return 'Die SVG enthält Skripte oder aktive Inhalte und wird nicht angenommen.';
return null;
}
return 'Dieses Format wird nicht unterstützt.';
}
/**
* Alle <link>-Tags für das Browser-Symbol (Tab, Lesezeichen, Startbildschirm). Ein eigenes Favicon (Einstellungen → Design) ersetzt alle
* Standard-Symbole, damit der Browser nicht ein anderes auswählt. Ohne Favicon gelten die Standard-Symbole und, falls vorhanden, das Logo.
*/
function site_icon_links(): string
{
$favicon = (string) get_setting('favicon_path', '');
$types = favicon_types();
if ($favicon !== '') {
$ext = strtolower(pathinfo((string) parse_url($favicon, PHP_URL_PATH), PATHINFO_EXTENSION));
$type = $types[$ext] ?? 'image/png';
$href = e($favicon);
$out = '<link rel="icon" href="' . $href . '" type="' . $type . '">' . "\n" . '<link rel="shortcut icon" href="' . $href . '">' . "\n";
$out .= in_array($ext, ['png', 'jpg', 'jpeg'], true) ? '<link rel="apple-touch-icon" href="' . $href . '">' : '<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">';
return $out . "\n";
}
$out = '<link rel="icon" href="/favicon.ico" sizes="any">' . "\n" . '<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">' . "\n"
. '<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">' . "\n";
$logo = (string) get_setting('logo_path', '');
if ($logo !== '') $out .= '<link rel="icon" href="' . e($logo) . '">' . "\n";
return $out;
}
function base_url(): string
{
return rtrim(get_setting('site_url', config()['site']['url'] ?? 'http://localhost'), '/');
@@ -1264,6 +1357,7 @@ function evaluate_ticket_rules(PDO $pdo, array $ticketData): array
'assign_staff' => null,
'add_tags' => [],
'auto_reply' => null,
'close' => false,
'matched_rules' => [],
];
@@ -1300,6 +1394,7 @@ function evaluate_ticket_rules(PDO $pdo, array $ticketData): array
foreach ($actStmt->fetchAll() as $act) {
switch ($act['action_type']) {
case 'reject': $result['reject'] = true; break;
case 'close': $result['close'] = true; break;
case 'set_department': $result['set_department'] = (int) $act['action_value']; break;
case 'set_priority': $result['set_priority'] = (int) $act['action_value']; break;
case 'set_help_topic': $result['set_help_topic'] = (int) $act['action_value']; break;
@@ -1315,6 +1410,19 @@ function evaluate_ticket_rules(PDO $pdo, array $ticketData): array
return $result;
}
/**
* Schließt ein neu angelegtes Ticket, wenn eine Regel die Aktion "Ticket schließen" enthält. Gibt true zurück, wenn geschlossen wurde;
* dann sollten keine Benachrichtigungen an Mitarbeiter oder Kunden mehr verschickt werden.
*/
function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
{
if (empty($ruleResult['close'])) return false;
$now = db_now();
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
return true;
}
/**
* Verschickt automatisch einen hinterlegten Textbaustein als Antwort, wenn eine
* Ticket-Regel eine "Auto-Antwort senden"-Aktion ausgelöst hat (siehe
@@ -1755,3 +1863,110 @@ function ticket_total_minutes(PDO $pdo, int $ticketId): int
$stmt->execute([$ticketId]);
return (int) $stmt->fetchColumn();
}
/**
* Eigenes Ticket-Formular einer Abteilung. Gibt null zurück, wenn die Abteilung das Standardformular nutzt.
* Sonst die Texte/Optionen (mit Standardwerten ergänzt) und die Felder der Abteilung.
*
* @return array{title:string,intro:string,subject_show:bool,subject_label:string,message_label:string,message_hint:string,attachment_show:bool,submit_label:string,fields:array}|null
*/
function department_form(PDO $pdo, ?int $departmentId): ?array
{
if (!$departmentId) return null;
$stmt = $pdo->prepare('SELECT name, own_form, form_config FROM departments WHERE id = ?');
$stmt->execute([$departmentId]);
$d = $stmt->fetch();
if (!$d || empty($d['own_form'])) return null;
$cfg = json_decode((string) ($d['form_config'] ?? ''), true);
$cfg = is_array($cfg) ? $cfg : [];
$str = fn(string $k, string $def) => (isset($cfg[$k]) && is_string($cfg[$k]) && trim($cfg[$k]) !== '') ? trim($cfg[$k]) : $def;
$fields = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? AND is_active = 1 ORDER BY sort_order, label');
$fields->execute([$departmentId]);
$out = [
'title' => $str('title', ''),
'intro' => $str('intro', ''),
'subject_show' => !array_key_exists('subject_show', $cfg) || !empty($cfg['subject_show']),
'subject_label' => $str('subject_label', 'Betreff'),
'message_label' => $str('message_label', 'Beschreibung'),
'message_show' => !array_key_exists('message_show', $cfg) || !empty($cfg['message_show']),
'subject_width' => in_array($cfg['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['subject_width'] : 'full',
'message_width' => in_array($cfg['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['message_width'] : 'full',
'message_hint' => $str('message_hint', 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…'),
'attachment_show' => !array_key_exists('attachment_show', $cfg) || !empty($cfg['attachment_show']),
'submit_label' => $str('submit_label', 'Ticket erstellen'),
'department_name' => (string) $d['name'],
'contact_title' => $str('contact_title', 'Ihre Kontaktdaten'),
'issue_title' => $str('issue_title', 'Ihr Anliegen'),
'details_title' => $str('details_title', 'Ihr Anliegen'),
'fields' => [],
];
$form = $out;
foreach ($fields->fetchAll() as $f) {
$sec = trim((string) ($f['form_section'] ?? ''));
if ($sec === '') $f['_section'] = 'extra';
elseif ($sec === '@topic') $f['_section'] = 'topic';
elseif ($sec === '@topic_top') $f['_section'] = 'topic_top';
elseif ($sec === '@contact_top') $f['_section'] = 'contact_top';
elseif ($sec === '@contact') $f['_section'] = 'contact';
elseif ($sec === '@details') $f['_section'] = 'issue';
elseif ($sec === '@details_top') $f['_section'] = 'issue_top';
elseif ($sec === '@details_mid') $f['_section'] = 'issue_mid';
elseif (mb_strtolower($sec) === mb_strtolower($form['contact_title'])) $f['_section'] = 'contact';
elseif (in_array(mb_strtolower($sec), [mb_strtolower($form['issue_title']), mb_strtolower($form['details_title'])], true)) $f['_section'] = 'issue';
else $f['_section'] = $sec;
$f['_section_title'] = in_array($f['_section'], ['topic', 'topic_top'], true) ? $form['issue_title'] : ($f['_section'] === 'extra' ? 'Zusätzliche Angaben' : (in_array($f['_section'], ['contact', 'contact_top'], true) ? $form['contact_title'] : (in_array($f['_section'], ['issue', 'issue_top', 'issue_mid'], true) ? $form['details_title'] : $sec)));
$form['fields'][] = $f;
}
return $form;
}
/**
* Hinweistext eines Formularfeldes als sicheres HTML: Text wird maskiert, Links schreibt man als [Text](https://adresse)
* (nur http, https oder Adressen auf dieser Seite, die mit / beginnen). Zeilenumbrüche bleiben erhalten.
*/
function form_help_html(?string $text, string $linkLabel = ''): string
{
// Steht nur eine Adresse im Text, wird die Bezeichnung des Feldes zum Link (statt der nackten Adresse)
if ($linkLabel !== '' && preg_match('#^\s*(https?://|/)[^\s)"\'<>]{1,500}\s*$#', (string) $text)) {
$text = '[' . $linkLabel . '](' . trim((string) $text) . ')';
}
$html = e((string) $text);
$html = preg_replace_callback('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', function ($m) {
return '<a href="' . $m[2] . '" target="_blank" rel="noopener noreferrer">' . $m[1] . '</a>';
}, $html);
// Adressen ohne [Text](…) werden ebenfalls anklickbar (nicht, wenn sie schon in einem Link stehen)
$html = preg_replace_callback('~(<a [^>]*>.*?</a>)|(https?://[^\s<>"\']+)~u', function ($m) {
if (!empty($m[1])) return $m[1];
$url = rtrim($m[2], '.,;:!?)');
$rest = substr($m[2], strlen($url));
return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $rest;
}, $html);
return nl2br($html, false);
}
/** Setzt Hinweistext, Link-Adresse und verlinkten Textteil (Eingabefelder im Admin) zu gespeichertem Text mit [Wort](Adresse) zusammen. */
function form_help_compose(string $text, string $url, string $word, string $fallbackLabel = ''): string
{
$text = trim($text);
$url = trim($url);
$word = trim($word);
if (preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $text) && $url === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $text . ')';
if ($url === '' || !preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $url)) return mb_substr($text, 0, 1000);
if ($text === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $url . ')';
if ($word !== '' && ($pos = mb_stripos($text, $word)) !== false) {
$found = mb_substr($text, $pos, mb_strlen($word));
return mb_substr(mb_substr($text, 0, $pos) . '[' . $found . '](' . $url . ')' . mb_substr($text, $pos + mb_strlen($word)), 0, 1000);
}
return mb_substr($text . ' [' . ($word !== '' ? $word : 'Mehr erfahren') . '](' . $url . ')', 0, 1000);
}
/** Gegenstück zu form_help_compose(): zerlegt gespeicherten Text in [Text ohne Link-Syntax, Adresse, verlinkter Teil]. */
function form_help_split(?string $stored): array
{
$stored = (string) $stored;
if (preg_match('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', $stored, $m, PREG_OFFSET_CAPTURE)) {
$plain = substr($stored, 0, $m[0][1]) . $m[1][0] . substr($stored, $m[0][1] + strlen($m[0][0]));
return [$plain, $m[2][0], $m[1][0]];
}
return [$stored, '', ''];
}
@@ -19,14 +19,11 @@ if (get_setting('helpdesk_status', 'online') === 'offline') {
<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">
<?= site_icon_links() ?>
<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(){});
@@ -66,8 +63,7 @@ if ('serviceWorker' in navigator) {
<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; ?>
<?= site_icon_links() ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
@@ -115,7 +111,7 @@ if ('serviceWorker' in navigator) {
<span class="text-brand">Desk<span>Hub</span></span>
<?php endif; ?>
</a>
<?php if ($logo && $displayName): ?>
<?php if ($logo && $displayName && get_setting('logo_hide_name', '0') !== '1'): ?>
<span style="font-size:1.15rem; font-weight:700; color:var(--ink); letter-spacing:-.01em"><?= e($displayName) ?></span>
<?php endif; ?>
</div>
@@ -117,16 +117,19 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
}
foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$closedByRule = apply_rule_close($pdo, $ticketId, $ruleResult);
$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];
if (!$closedByRule) {
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];
}
+47 -5
View File
@@ -127,6 +127,7 @@ function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
return null;
}
@@ -146,14 +147,18 @@ function plugin_add_filter(string $hook, callable $fn, int $priority = 10): void
$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
/**
* Menüpunkt im Mitarbeiterbereich. $page ist der Dateiname unter plugins/<slug>/pages/ (ohne .php).
* Mit $belongsTo (zum Beispiel 'backup', siehe plugin_parent_pages()) erscheint kein eigener Eintrag in der Seitenleiste: Die Seite wird
* stattdessen als Reiter innerhalb dieser Seite angeboten (zum Beispiel unter „Backup“). Dafür ist keine zusätzliche Berechtigung nötig (ui.menu genügt).
*/
function plugin_register_menu(string $label, string $page, ?string $permission = null, ?string $belongsTo = 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];
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && isset(plugin_parent_pages()[$belongsTo])) ? $belongsTo : null];
}
/**
@@ -306,11 +311,45 @@ 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;
if (!empty($m['belongs_to'])) continue; // erscheint als Reiter in der übergeordneten Seite, nicht in der Seitenleiste
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
/** Seiten, unter denen Plugins Reiter anbieten dürfen: Schlüssel => [Bezeichnung des ersten Reiters, Adresse]. */
function plugin_parent_pages(): array
{
return ['backup' => ['Backups', '/staff/backup.php']];
}
/** Plugin-Seiten (mit Rechteprüfung), die als Reiter zu einer übergeordneten Seite gehören. */
function plugin_subpages(string $parent, ?array $staff): array
{
$items = [];
foreach (plugin_state()['menus'] as $m) {
if (($m['belongs_to'] ?? null) !== $parent) continue;
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;
}
/** Reiterleiste einer übergeordneten Seite (leer, wenn kein Plugin Reiter anbietet). $active: 'parent' oder '<plugin>:<seite>'. */
function plugin_subpage_tabs(string $parent, ?array $staff, string $active = 'parent'): string
{
$parents = plugin_parent_pages();
$subs = plugin_subpages($parent, $staff);
if (!$subs || !isset($parents[$parent])) return '';
$tab = function (string $label, string $url, bool $on): string {
return '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted);border:1px solid transparent') . '">' . htmlspecialchars($label) . '</a>';
};
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border);margin:0 0 18px">' . $tab($parents[$parent][0], $parents[$parent][1], $active === 'parent');
foreach ($subs as $s) $html .= $tab($s['label'], $s['url'], $active === $s['plugin'] . ':' . $s['page']);
return $html . '</div>';
}
// ─── Verwaltung (Installieren, Aktivieren, Entfernen) ────────────────────────
function plugins_installed(PDO $pdo): array
@@ -785,10 +824,13 @@ function plugin_run_migrations(PDO $pdo, string $slug): array
if (in_array($key, $done, true)) continue;
$sql = preg_replace('/^\s*--.*$/m', '', (string) file_get_contents($file));
try {
$pdo->beginTransaction();
// SQLite kann Tabellen-Änderungen in einer Transaktion zurückrollen. MySQL beendet bei CREATE/ALTER TABLE jede offene
// Transaktion von selbst ("There is no active transaction"), dort läuft die Datei deshalb ohne Transaktion.
$useTransaction = $driver === 'sqlite';
if ($useTransaction) $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();
if ($useTransaction && $pdo->inTransaction()) $pdo->commit();
$applied++;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
+10
View File
@@ -96,6 +96,16 @@ function pseudo_cron_dispatch(): void
catalog_refresh_background();
}
// Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf)
if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) {
try {
if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php';
backup_verify_pending(2);
} catch (Throwable $e) {
error_log('pseudo_cron [verify_backups]: ' . $e->getMessage());
}
}
// 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()]);
+1 -1
View File
@@ -124,7 +124,7 @@ function security_check_settings(PDO $pdo): array
// 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\').'];
$out[] = ['level' => 'warn', 'title' => 'Reverse-Proxy nicht eingetragen', 'detail' => 'Die Anfrage kam über einen Proxy (X-Forwarded-For), aber dessen Adresse (' . e((string) ($_SERVER['REMOTE_ADDR'] ?? '?')) . ') 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
+17 -1
View File
@@ -59,9 +59,25 @@ function update_find_assets(array $release, string $tag): array
return $out;
}
/**
* Zugangstoken für den Update-Server (nur nötig, wenn das Repository privat ist; Gitea antwortet sonst mit "404").
* Quelle: config/config.php ('update' => ['token' => '…']) hat Vorrang, sonst das in den Einstellungen der Update-Seite
* gespeicherte (verschlüsselt). Gibt [Token, Quelle] zurück, Quelle 'config', 'einstellungen' oder ''.
*
* @return array{0: string, 1: string}
*/
function update_token_info(): array
{
$fromConfig = trim((string) (config()['update']['token'] ?? ''));
if ($fromConfig !== '') return [$fromConfig, 'config'];
$stored = (string) get_setting('update_token', '');
$fromSettings = $stored !== '' ? trim(decrypt_secret($stored)) : '';
return $fromSettings !== '' ? [$fromSettings, 'einstellungen'] : ['', ''];
}
/** 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'] ?? '');
[$token] = update_token_info();
return $token !== '' ? ['Authorization: token ' . $token] : [];
}
+148 -21
View File
@@ -9,12 +9,82 @@
* Sicherheitsgründen vom Server gelöscht oder zumindest gesperrt werden.
*/
// Sicherheits-Header und Sitzungs-Cookie des Assistenten
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex, nofollow');
$installHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0])) === 'https';
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $installHttps, 'httponly' => true, 'samesite' => 'Lax']);
session_start();
define('ROOT_DIR', dirname(__DIR__));
define('LOCK_FILE', __DIR__ . '/.installed');
require_once ROOT_DIR . '/database/sqlite_schema.php';
/** Gleiche Regeln wie im Programm (validate_password_strength), damit das Admin-Konto nicht schwächer ist als alle anderen. */
function install_password_error(string $p): ?string
{
if (strlen($p) < 10) return 'Das Admin-Passwort muss mindestens 10 Zeichen lang sein.';
if (!preg_match('/[A-Z]/', $p)) return 'Das Admin-Passwort muss mindestens einen Großbuchstaben enthalten.';
if (!preg_match('/[a-z]/', $p)) return 'Das Admin-Passwort muss mindestens einen Kleinbuchstaben enthalten.';
if (!preg_match('/[0-9]/', $p)) return 'Das Admin-Passwort muss mindestens eine Ziffer enthalten.';
if (!preg_match('/[^A-Za-z0-9]/', $p)) return 'Das Admin-Passwort muss mindestens ein Sonderzeichen enthalten.';
return null;
}
/** Verständliche Meldung zu einem Datenbankfehler (mit dem technischen Text dahinter). */
function install_db_error(Throwable $e): string
{
$code = (int) ($e instanceof PDOException ? ($e->errorInfo[1] ?? $e->getCode()) : 0);
$hint = match (true) {
$code === 1045 => 'Benutzername oder Passwort der Datenbank stimmen nicht.',
$code === 1049 => 'Die Datenbank existiert nicht. Legen Sie sie zuerst (leer) an, z. B. im Hosting-Bereich.',
$code === 1044 => 'Der Datenbank-Benutzer hat keine Rechte auf diese Datenbank.',
$code === 2002 || $code === 2006 => 'Der Datenbank-Server ist nicht erreichbar. Prüfen Sie Host (und Port, z. B. localhost:3306).',
$code === 2054 => 'Der Datenbank-Server verlangt eine Anmeldemethode, die diese PHP-Version nicht unterstützt.',
default => 'Prüfen Sie Host, Datenbankname, Benutzer und Passwort.',
};
return 'Datenbankverbindung fehlgeschlagen. ' . $hint . ' (Technisch: ' . $e->getMessage() . ')';
}
/**
* Schreibt die Konfiguration so, dass sie sicher gilt: auch wenn schon eine (alte, z. B. mitgelieferte) config.php liegt,
* die dem Webserver nicht gehört oder nicht beschreibbar ist, und mit Löschen des PHP-Zwischenspeichers (OPcache),
* damit nicht weiter die alte Version geladen wird.
*/
function install_write_config(string $path, string $content): bool
{
$ok = @file_put_contents($path, $content) !== false;
if (!$ok && is_file($path)) {
@chmod($path, 0644);
$ok = @file_put_contents($path, $content) !== false;
if (!$ok && @unlink($path)) $ok = @file_put_contents($path, $content) !== false; // Datei gehört einem anderen Benutzer, der Ordner aber nicht
}
if ($ok) {
clearstatcache(true, $path);
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
}
return $ok;
}
/** Liest die geschriebene Konfiguration frisch ein und prüft, dass genau die eingegebenen Daten darin stehen. Gibt null oder eine Fehlermeldung zurück. */
function install_verify_config(string $path, array $expected): ?string
{
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
$onDisk = (string) @file_get_contents($path);
if (!str_contains($onDisk, var_export((string) $expected['app_key'], true))) return 'Die Konfigurationsdatei enthält nicht die neuen Daten. Prüfen Sie die Schreibrechte auf config/config.php.';
$loaded = @include $path;
$db = is_array($loaded) ? (array) ($loaded['db'] ?? []) : [];
foreach (['driver', 'host', 'name', 'user', 'pass'] as $k) {
if ((string) ($db[$k] ?? '') !== (string) $expected['db'][$k]) {
return 'Die Konfiguration wurde geschrieben, PHP liefert aber noch eine ältere Version (Zwischenspeicher/OPcache des Servers). Laden Sie PHP neu (Hosting-Oberfläche) oder warten Sie einige Minuten und starten Sie die Installation erneut.';
}
}
return null;
}
function h(?string $s): string
{
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
@@ -22,7 +92,7 @@ function h(?string $s): string
// URL der aktuellen Installation automatisch erkennen
$detectedUrl = (function(): string {
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$scheme = $GLOBALS['installHttps'] ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
// install/ vom Pfad entfernen → Root-URL
$path = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
@@ -55,22 +125,33 @@ $existingInstallation = (function (): bool {
$alreadyInstalled = file_exists(LOCK_FILE) || $existingInstallation;
$errors = [];
$success = false;
$manualConfig = null;
// ---------------------------------------------------------------
// Anforderungsprüfung
// ---------------------------------------------------------------
// Ordner anlegen, die für den Betrieb nötig sind (falls beim Hochladen leer weggelassen)
foreach (['uploads', 'backups', 'plugins', 'app_downloads', 'config'] as $__dir) {
if (!is_dir(ROOT_DIR . '/' . $__dir)) @mkdir(ROOT_DIR . '/' . $__dir, 0755, true);
}
$checks = [
['label' => 'PHP-Version 8.1 oder neuer (gefunden: ' . PHP_VERSION . ')', 'ok' => version_compare(PHP_VERSION, '8.1.0', '>=')],
['label' => 'PHP-Version 8.2 oder neuer (gefunden: ' . PHP_VERSION . ')', 'ok' => version_compare(PHP_VERSION, '8.2.0', '>=')],
['label' => 'PDO-Erweiterung', 'ok' => extension_loaded('pdo')],
['label' => 'PDO MySQL-Treiber (für Produktivbetrieb)', 'ok' => extension_loaded('pdo_mysql'), 'optional' => true],
['label' => 'PDO SQLite-Treiber (optional, für lokale Tests)', 'ok' => extension_loaded('pdo_sqlite'), 'optional' => true],
['label' => 'Mindestens ein Datenbanktreiber (MySQL oder SQLite)', 'ok' => extension_loaded('pdo_mysql') || extension_loaded('pdo_sqlite')],
['label' => 'mbstring-Erweiterung', 'ok' => extension_loaded('mbstring')],
['label' => 'openssl-Erweiterung (Verschlüsselung der Zugangsdaten, Signaturen)', 'ok' => extension_loaded('openssl')],
['label' => 'json-Erweiterung', 'ok' => extension_loaded('json')],
['label' => 'curl-Erweiterung (optional: Webhooks, Plugin-Katalog, Updates)', 'ok' => extension_loaded('curl'), 'optional' => true],
['label' => 'zip-Erweiterung (optional: Plugins, Themes, Update per ZIP, Backups)', 'ok' => extension_loaded('zip'), 'optional' => true],
['label' => 'IMAP-Erweiterung (optional, nur für E-Mail-zu-Ticket)', 'ok' => extension_loaded('imap'), 'optional' => true],
['label' => 'Verzeichnis config/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/config')],
['label' => 'Verzeichnis uploads/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/uploads')],
['label' => 'Verzeichnis database/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/database')],
['label' => 'Verzeichnis database/ ist beschreibbar (nur für SQLite nötig)', 'ok' => is_writable(ROOT_DIR . '/database'), 'optional' => true],
['label' => 'Verzeichnis backups/ ist beschreibbar (für Backups)', 'ok' => is_writable(ROOT_DIR . '/backups'), 'optional' => true],
['label' => 'HTTPS (empfohlen: Anmeldedaten sonst unverschlüsselt)', 'ok' => $installHttps, 'optional' => true],
];
$requiredOk = true;
foreach ($checks as $c) {
if (empty($c['optional']) && !$c['ok']) {
@@ -97,7 +178,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
if ($siteUrl === '') $errors[] = 'Bitte die URL der Installation angeben (z.B. https://support.firma.de).';
if ($adminName === '') $errors[] = 'Bitte einen Namen für das Admin-Konto angeben.';
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige Admin-E-Mail-Adresse angeben.';
if (strlen($adminPass) < 8) $errors[] = 'Das Admin-Passwort muss mindestens 8 Zeichen lang sein.';
if (($pwError = install_password_error($adminPass)) !== null) $errors[] = $pwError;
if ($adminPass !== $adminPass2) $errors[] = 'Die beiden Passwörter stimmen nicht überein.';
if (!extension_loaded('pdo_' . $driver)) $errors[] = "Die PHP-Erweiterung pdo_{$driver} ist nicht installiert.";
@@ -127,13 +208,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $dbConfig['host'], $dbConfig['name']);
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass']);
} else {
// Nur bei gewähltem SQLite wird eine Datenbank-Datei angelegt, und nur für diese Installation:
// Bricht sie ab, wird die halbfertige Datei am Ende des Aufrufs wieder entfernt.
@unlink($dbConfig['sqlite_path']);
$sqlitePath = $dbConfig['sqlite_path'];
register_shutdown_function(function () use (&$success, &$pdo, &$stmt, $sqlitePath) {
if ($success) return;
// Verbindung und Statements schließen, sonst lässt sich die Datei (vor allem unter Windows) nicht löschen
$stmt = null;
$pdo = null;
gc_collect_cycles();
foreach ([$sqlitePath, $sqlitePath . '-wal', $sqlitePath . '-shm'] as $leftover) @unlink($leftover);
});
$pdo = new PDO('sqlite:' . $dbConfig['sqlite_path']);
$pdo->exec('PRAGMA foreign_keys = ON;');
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (Throwable $e) {
$errors[] = 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage();
$errors[] = install_db_error($e);
}
}
@@ -147,7 +239,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
// Vordefiniertes Demo-Admin-Konto entfernen und durch das hier
// eingegebene, echte Admin-Konto ersetzen.
$pdo->exec("DELETE FROM staff WHERE email = 'admin@example.com'");
$pdo->prepare('DELETE FROM staff WHERE email IN (?, ?)')->execute(['admin@example.com', $adminEmail]);
$stmt = $pdo->prepare(
'INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'
);
@@ -240,11 +332,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk)
. " * WICHTIG: app_key niemals ändern – verschlüsselte Werte in der DB werden sonst unlesbar.\n"
. " */\n\nreturn " . var_export($configArray, true) . ";\n";
if (file_put_contents(ROOT_DIR . '/config/config.php', $php) === false) {
$errors[] = 'config/config.php konnte nicht geschrieben werden. Bitte Schreibrechte des Verzeichnisses prüfen.';
$cfgPath = ROOT_DIR . '/config/config.php';
if (!install_write_config($cfgPath, $php)) {
$errors[] = 'config/config.php konnte nicht geschrieben werden. Die Datenbank ist eingerichtet. Speichern Sie den unten angezeigten Inhalt als config/config.php auf dem Server (oder geben Sie dem Webserver Schreibrechte auf den Ordner config/) und laden Sie die Seite neu.';
$manualConfig = $php;
} else {
file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . " von " . ($_SERVER['REMOTE_ADDR'] ?? 'CLI'));
@chmod($cfgPath, 0640); // enthält Schlüssel und Datenbank-Passwort
if (($verifyError = install_verify_config($cfgPath, $configArray)) !== null) {
$errors[] = $verifyError;
$manualConfig = $php;
} else {
// Programm-Migrationen (Schritte mit Code statt SQL) jetzt mit der fertigen Konfiguration einspielen: Fehler erscheinen hier
// und nicht erst beim ersten Seitenaufruf. Scheitert das, wird die Konfiguration wieder entfernt, damit ein neuer Versuch möglich ist.
try {
require_once ROOT_DIR . '/includes/db.php';
require_once ROOT_DIR . '/includes/functions.php';
get_db();
@file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . ' von ' . ($_SERVER['REMOTE_ADDR'] ?? 'CLI'));
$success = true;
} catch (Throwable $e) {
@unlink($cfgPath);
$errors[] = 'Fehler beim Einspielen der Datenbank-Änderungen: ' . $e->getMessage();
}
}
}
}
}
@@ -491,7 +601,7 @@ html, body {
box-shadow: 0 0 0 3px rgba(15,111,106,.12);
}
.i-field input::placeholder { color: #b0bac6; }
.i-field .i-hint {
.i-hint, .i-field .i-hint {
font-size: .74rem;
color: var(--muted);
margin-top: 4px;
@@ -715,9 +825,11 @@ code {
<div class="i-card" style="text-align:center;padding:40px 28px">
<div class="i-success-icon">🔒</div>
<h2 style="margin-bottom:10px">Bereits installiert</h2>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">
Eingerichtet am <strong><?= h(is_file(LOCK_FILE) ? (string) file_get_contents(LOCK_FILE) : "bereits vorhandener Konfiguration") ?></strong>
</p>
<?php if (is_file(LOCK_FILE)): ?>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Eingerichtet am <strong><?= h((string) file_get_contents(LOCK_FILE)) ?></strong></p>
<?php else: ?>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Es gibt bereits eine Konfiguration mit Datenbank und Mitarbeiterkonten. Eine Neuinstallation würde diese Daten überschreiben und ist deshalb gesperrt.</p>
<?php endif; ?>
<div class="i-alert warning" style="margin-top:20px;text-align:left">
<span class="i-alert-icon">⚠️</span>
<span>Bitte das Verzeichnis <code>install/</code> vom Server löschen, um diese Seite zu deaktivieren.</span>
@@ -726,8 +838,8 @@ code {
<a href="/login.php" class="i-btn-primary">Kundenportal</a>
<a href="/staff/login.php" class="i-btn-secondary">Mitarbeiter-Login</a>
</div>
<p style="font-size:.75rem;color:var(--muted);margin-top:16px">
Zum Wiederholen: <code>install/.installed</code> löschen
<p style="font-size:.75rem;color:var(--muted);margin-top:16px;line-height:1.6">
Wirklich neu installieren? Erst ein Backup der Daten anlegen, dann <code>config/config.php</code>, <code>install/.installed</code> und die Datenbank (bei SQLite die Datei <code>database/deskhub.sqlite</code>) entfernen. Alle bisherigen Daten gehen dabei verloren.
</p>
</div>
@@ -779,6 +891,13 @@ code {
<?php foreach ($errors as $err): ?>
<div class="i-alert error"><span class="i-alert-icon">✗</span><span><?= h($err) ?></span></div>
<?php endforeach; ?>
<?php if ($manualConfig !== null): ?>
<div class="i-card">
<div class="i-card-title">Inhalt für config/config.php</div>
<div class="i-hint" style="margin-bottom:10px">Enthält Schlüssel und Datenbank-Passwort: nur auf dem Server speichern, nirgends weitergeben.</div>
<textarea readonly rows="14" style="width:100%;font-family:monospace;font-size:.78rem" onclick="this.select()"><?= h($manualConfig) ?></textarea>
</div>
<?php endif; ?>
<!-- Step 2: Configuration form -->
<div class="i-card">
@@ -825,7 +944,7 @@ code {
<input type="password" id="db_pass" name="db_pass" placeholder="••••••••">
</div>
</div>
<p class="i-hint" style="margin-bottom:16px">Die Datenbank muss bereits existieren (leer) — Tabellen werden automatisch angelegt.</p>
<div class="i-hint" style="margin-bottom:16px">Die Datenbank muss bereits existieren (leer). Tabellen werden automatisch angelegt.</div>
</div>
<!-- Website -->
@@ -849,7 +968,7 @@ code {
</div>
<div class="i-field">
<label for="admin_email">E-Mail</label>
<input type="email" id="admin_email" name="admin_email" value="<?= h($_POST['admin_email'] ?? '') ?>" placeholder="admin@firma.de" required>
<input type="email" id="admin_email" name="admin_email" autocapitalize="none" autocorrect="off" spellcheck="false" value="<?= h($_POST['admin_email'] ?? '') ?>" placeholder="admin@firma.de" required>
</div>
</div>
<div class="i-row">
@@ -857,6 +976,7 @@ code {
<label for="admin_password">Passwort</label>
<input type="password" id="admin_password" name="admin_password" placeholder="••••••••" required>
<div class="i-pw-bar"><div class="i-pw-fill" id="pw-fill"></div></div>
<div class="i-hint">Mindestens 10 Zeichen mit Groß- und Kleinbuchstaben, Ziffer und Sonderzeichen.</div>
</div>
<div class="i-field">
<label for="admin_password2">Passwort wiederholen</label>
@@ -888,17 +1008,24 @@ document.querySelectorAll('.i-driver-tab input[type=radio]').forEach(function(ra
});
});
// Doppelklick verhindern: sonst startet die Installation zweimal
var installForm = document.getElementById('install-form');
if (installForm) installForm.addEventListener('submit', function() {
var b = installForm.querySelector('button[type=submit]');
if (b) { b.disabled = true; b.textContent = 'Wird installiert …'; }
});
// Password strength bar
var pwInput = document.getElementById('admin_password');
var pwFill = document.getElementById('pw-fill');
if (pwInput && pwFill) {
pwInput.addEventListener('input', function() {
var v = this.value, score = 0;
if (v.length >= 8) score++;
if (v.length >= 12) score++;
if (/[A-Z]/.test(v)) score++;
if (v.length >= 10) score++;
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
if (/[0-9]/.test(v)) score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
if (v.length >= 14) score++;
var colors = ['#ef4444','#f97316','#eab308','#22c55e','#16a34a'];
var widths = ['20%','40%','60%','80%','100%'];
pwFill.style.width = score > 0 ? widths[score-1] : '0%';
+99 -31
View File
@@ -59,7 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
usort($files, fn($a,$b) => filemtime($b)-filemtime($a));
foreach (array_slice($files, $keep) as $old) @unlink($old);
}
$verified = backup_verify_and_record($result['path']);
$verified = $result['verify'] ?? backup_verify_and_record($result['path']); // create_backup() hat schon automatisch geprüft
audit_log($staff['id'], "Manuelles Backup: {$result['filename']} ({$result['size']} MB)$ftpMsg");
flash($verified['ok'] ? 'success' : 'error', ($verified['ok'] ? "✅ Backup erstellt und geprüft: {$result['filename']} ({$result['size']} MB){$ftpMsg}"
: "Backup erstellt, aber NICHT wiederherstellbar: " . implode(' ', $verified['errors'])));
@@ -164,7 +164,7 @@ $backupDir = realpath(__DIR__ . '/../backups') ?: (dirname(__DIR__) . '/backup
$backupFiles = [];
if (is_dir($backupDir)) {
foreach (array_merge(glob($backupDir.'/backup_*.zip')?:[], glob($backupDir.'/backup_*.tar.gz')?:[]) as $f) {
$backupFiles[] = ['name'=>basename($f),'size'=>round(filesize($f)/1024/1024,2),'date'=>filemtime($f)];
$backupFiles[] = ['name'=>basename($f),'size'=>round(filesize($f)/1024/1024,2),'bytes'=>filesize($f),'date'=>filemtime($f)];
}
usort($backupFiles, fn($a,$b) => $b['date']-$a['date']);
}
@@ -172,11 +172,35 @@ $totalSize = array_sum(array_column($backupFiles, 'size'));
$lastAutoRun = get_setting('cron_process_backups_last_run');
$lastAutoBkp = bget('last_auto_backup');
$checkMap = json_decode((string) get_setting('backup_check_map', ''), true);
$checkMap = is_array($checkMap) ? $checkMap : [];
$rows = [];
foreach ($backupFiles as $b) $rows[] = $b + ['source' => 'local', 'badges' => [], 'actions' => ''];
// Plugins mit Reiter unter „Backup“ (zum Beispiel Synology NAS Backup) können Marken, Knöpfe und weitere Einträge (nur extern vorhandene Backups) ergänzen.
// Zeilen: name, size (MB), bytes, date, source ('local'|'extern'), badges (HTML-Liste), actions (HTML). Die Plugins sind für das Maskieren ihrer HTML-Ausgaben selbst verantwortlich.
if (function_exists('plugin_filter')) $rows = plugin_filter('backup.list.rows', $rows, ['backupDir' => $backupDir]);
usort($rows, fn($a, $b) => ($b['date'] ?? 0) <=> ($a['date'] ?? 0));
$ago = function (int $ts): string {
$d = time() - $ts;
if ($d < 90) return 'gerade eben';
if ($d < 3600) return 'vor ' . round($d / 60) . ' Min.';
if ($d < 86400) return 'vor ' . round($d / 3600) . ' Std.';
return 'vor ' . round($d / 86400) . ' Tagen';
};
$fmtMb = fn($mb) => $mb >= 1024 ? number_format($mb / 1024, 2, ',', '.') . ' GB' : number_format((float) $mb, $mb < 10 ? 2 : 0, ',', '.') . ' MB';
// Zahlen für die Kacheln oben: Backups auf dem Server und (über Plugins, zum Beispiel NAS) extern vorhandene
$externRows = array_values(array_filter($rows, fn($r) => ($r['source'] ?? 'local') !== 'local'));
$sizeAll = array_sum(array_map(fn($r) => (float) ($r['size'] ?? 0), $rows));
$sizeExtern = array_sum(array_map(fn($r) => (float) ($r['size'] ?? 0), $externRows));
$newestTs = $rows ? (int) max(array_map(fn($r) => (int) ($r['date'] ?? 0), $rows)) : 0;
$pageTitle = 'Backup';
$activePage = 'backup';
require __DIR__ . '/_header.php';
?>
<?= plugin_subpage_tabs('backup', $staff, 'parent') ?>
<div class="topbar">
<h1>💾 Backup</h1>
<form method="post" id="backup-form">
@@ -188,37 +212,80 @@ require __DIR__ . '/_header.php';
<!-- Statistiken -->
<div class="stat-grid" style="margin-bottom:20px">
<div class="stat-card"><div class="num"><?= count($backupFiles) ?></div><div class="lbl">Backups vorhanden</div></div>
<div class="stat-card"><div class="num"><?= $totalSize ?> MB</div><div class="lbl">Gesamtgröße</div></div>
<div class="stat-card"><div class="num"><?= $backupFiles ? date('d.m.Y', $backupFiles[0]['date']) : '–' ?></div><div class="lbl">Letztes Backup</div></div>
<div class="stat-card"><div class="num"><?= $lastAutoBkp ? date('d.m.Y', strtotime($lastAutoBkp)) : '–' ?></div><div class="lbl">Letztes Auto-Backup</div></div>
<div class="stat-card"><div class="num"><?= count($rows) ?></div><div class="lbl">Backups vorhanden</div>
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px"><?= count($backupFiles) ?> auf dem Server<?= $externRows ? ' · ' . count($externRows) . ' nur extern (NAS)' : '' ?></div></div>
<div class="stat-card"><div class="num"><?= e($fmtMb($sizeAll)) ?></div><div class="lbl">Gesamtgröße</div>
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px">Server: <?= e($fmtMb($totalSize)) ?><?= $externRows ? ' · extern: ' . e($fmtMb($sizeExtern)) : '' ?></div></div>
<div class="stat-card"><div class="num"><?= $newestTs ? e(date('d.m.Y', $newestTs)) : '–' ?></div><div class="lbl">Letztes Backup</div>
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px"><?= $newestTs ? e(date('H:i', $newestTs)) . ' Uhr' : 'noch keins' ?></div></div>
<div class="stat-card"><div class="num"><?= $lastAutoBkp ? date('d.m.Y', strtotime($lastAutoBkp)) : '–' ?></div><div class="lbl">Letztes Auto-Backup</div>
<div class="lbl" style="text-transform:none;letter-spacing:0;margin-top:3px"><?= $lastAutoBkp ? e(date('H:i', strtotime($lastAutoBkp))) . ' Uhr' : ((bget('schedule') ?: 'disabled') === 'disabled' ? 'Zeitplan aus' : 'noch nicht gelaufen') ?></div></div>
</div>
<style>
.bk-list { display:flex; flex-direction:column; }
.bk-row { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:6px 16px; align-items:center; padding:9px 4px; border-top:1px solid var(--border); }
.bk-row:first-child { border-top:0; }
.bk-name { min-width:0; }
.bk-name code { font-size:.82rem; word-break:break-all; }
.bk-meta { display:block; font-size:.76rem; color:var(--muted); margin-top:1px; }
.bk-badges { display:flex; gap:5px; flex-wrap:wrap; justify-content:flex-end; }
.bk-badge { font-size:.7rem; padding:2px 8px; border-radius:999px; border:1px solid var(--border); color:var(--muted); white-space:nowrap; }
.bk-badge.ok { color:#15803d; border-color:#bbf7d0; background:#f0fdf4; }
.bk-badge.bad { color:#b91c1c; border-color:#fecaca; background:#fef2f2; }
.bk-actions { display:flex; gap:5px; align-items:center; justify-content:flex-end; flex-wrap:nowrap; }
.bk-actions form { margin:0; }
.bk-btn { padding:4px 9px; font-size:.76rem; line-height:1.4; white-space:nowrap; cursor:pointer; list-style:none; }
.bk-btn::-webkit-details-marker { display:none; }
.bk-restore { position:relative; }
.bk-restore form.bk-pop { position:absolute; right:0; z-index:5; width:340px; padding:14px; background:var(--card-bg,#fff); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.18); text-align:left; }
@media (max-width:820px) { .bk-row { grid-template-columns:1fr; } .bk-badges, .bk-actions { justify-content:flex-start; flex-wrap:wrap; } }
</style>
<!-- Backup-Liste -->
<div class="card" style="margin-bottom:20px">
<h3>Vorhandene Backups</h3>
<div style="display:flex; justify-content:space-between; align-items:baseline; gap:12px; flex-wrap:wrap">
<h3 style="margin:0">Vorhandene Backups</h3>
<?php $lastCheck = json_decode((string) get_setting('backup_last_check', ''), true); if (is_array($lastCheck)): ?>
<p class="muted" style="font-size:.82rem; margin-top:-6px">Letzte Prüfung: <?= e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> – <?= e($lastCheck['file']) ?>:
<strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></p>
<span class="muted" style="font-size:.8rem">Letzte Prüfung: <?= e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> –
<strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></span>
<?php endif; ?>
<?php if (!$backupFiles): ?>
</div>
<?php if (!$rows): ?>
<div class="empty-state">Noch keine Backups vorhanden.</div>
<?php else: ?>
<table>
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th><th style="width:160px"></th></tr></thead>
<tbody>
<?php foreach ($backupFiles as $b): ?>
<tr>
<td style="font-family:monospace; font-size:.82rem"><?= e($b['name']) ?></td>
<td class="muted"><?= $b['size'] ?> MB</td>
<td class="muted"><?= date('d.m.Y H:i', $b['date']) ?></td>
<td style="display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start">
<a href="/staff/backup_download.php?file=<?= urlencode($b['name']) ?>" class="btn secondary" style="padding:5px 10px; font-size:.78rem">⬇️ Download</a>
<div class="bk-list" style="margin-top:8px">
<?php foreach ($rows as $b):
$isLocal = ($b['source'] ?? 'local') === 'local';
$chk = $isLocal ? ($checkMap[$b['name']] ?? null) : null;
?>
<div class="bk-row">
<div class="bk-name">
<code><?= e($b['name']) ?></code>
<span class="bk-meta"><?= e($fmtMb($b['size'] ?? 0)) ?> · <?= e(date('d.m.Y H:i', (int) ($b['date'] ?? 0))) ?> (<?= e($ago((int) ($b['date'] ?? 0))) ?>)<?= !$isLocal ? ' · nicht auf diesem Server' : '' ?></span>
</div>
<div class="bk-badges">
<?php if ($isLocal && str_ends_with($b['name'], '.zip')): ?>
<?php if ($chk): ?>
<span class="bk-badge <?= $chk['ok'] ? 'ok' : 'bad' ?>" title="Geprüft am <?= e(date('d.m.Y H:i', strtotime($chk['at']))) ?>"><?= $chk['ok'] ? '✔ geprüft' : '✖ defekt' ?></span>
<?php else: ?>
<span class="bk-badge" title="Noch nicht auf Wiederherstellbarkeit geprüft">nicht geprüft</span>
<?php endif; ?>
<?php endif; ?>
<?php foreach ((array) ($b['badges'] ?? []) as $badge): ?><?= $badge ?><?php endforeach; ?>
</div>
<div class="bk-actions">
<?php if ($isLocal): ?>
<a href="/staff/backup_download.php?file=<?= urlencode($b['name']) ?>" class="btn secondary bk-btn" title="Backup herunterladen">⬇ Download</a>
<?php if (str_ends_with($b['name'], '.zip')): ?>
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="action" value="verify_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
<button type="submit" class="btn secondary" style="padding:5px 10px; font-size:.78rem" title="Prüft, ob sich das Backup wiederherstellen lässt (verändert nichts)">✔️ Prüfen</button></form>
<details style="position:relative"><summary class="btn secondary" style="padding:5px 10px; font-size:.78rem; list-style:none; cursor:pointer">♻️ Wiederherstellen</summary>
<form method="post" style="position:absolute; right:0; z-index:5; width:340px; padding:14px; background:var(--card-bg,#fff); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.18)">
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="verify_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
<button type="submit" class="btn secondary bk-btn" title="Prüft, ob sich das Backup wiederherstellen lässt (verändert nichts)">✔ Prüfen</button></form>
<?php endif; ?>
<?php endif; ?>
<?= $b['actions'] ?? '' ?>
<?php if ($isLocal && str_ends_with($b['name'], '.zip')): ?>
<details class="bk-restore"><summary class="btn secondary bk-btn" title="Datenbank und Dateien aus diesem Backup zurückspielen">♻ Wiederherstellen</summary>
<form method="post" class="bk-pop">
<?= csrf_field() ?><input type="hidden" name="action" value="restore_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
<p style="font-size:.82rem; margin:0 0 8px"><strong>Achtung:</strong> Der aktuelle Stand der Datenbank wird durch den Stand dieses Backups ersetzt. Vorher wird automatisch ein Sicherheits-Backup erstellt. Programmdateien und die Konfiguration bleiben unverändert.</p>
<label style="display:block; font-weight:400"><input type="checkbox" name="database" value="1" checked style="width:auto"> Datenbank</label>
@@ -228,20 +295,20 @@ require __DIR__ . '/_header.php';
<button type="submit" class="btn" style="margin-top:8px; background:var(--danger)">Jetzt wiederherstellen</button>
</form></details>
<?php endif; ?>
<form method="post" onsubmit="return confirm('Backup löschen?')" style="margin:0">
<?php if ($isLocal): ?>
<form method="post" onsubmit="return confirm('Dieses Backup auf dem Server löschen?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_backup">
<input type="hidden" name="filename" value="<?= e($b['name']) ?>">
<button type="submit" class="btn secondary" style="padding:5px 10px; font-size:.78rem; border-color:var(--danger); color:var(--danger)">🗑️</button>
<button type="submit" class="btn secondary bk-btn" style="border-color:var(--danger); color:var(--danger)" title="Auf dem Server löschen">🗑</button>
</form>
</td>
</tr>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:20px">
<!-- Zeitplan -->
@@ -251,6 +318,7 @@ require __DIR__ . '/_header.php';
Cron-Job einrichten: <code>php <?= e(dirname(__DIR__)) ?>/cron/process_backups.php</code><br>
<?php if ($lastAutoRun): ?><span style="font-size:.78rem">Letzter Lauf: <?= format_dt($lastAutoRun) ?></span><?php endif; ?>
</p>
<p class="muted" style="font-size:.8rem; margin:0 0 12px">✔ Jedes Backup wird nach dem Erstellen automatisch auf Wiederherstellbarkeit geprüft; ältere werden regelmäßig erneut geprüft.</p>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_schedule">