Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:15 +00:00
parent 491b87f468
commit 56deade877
50 changed files with 23552 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Zusätzlicher Schutz (falls die Rewrite-Regel im Root-.htaccess nicht greift)
Require all denied
Order deny,allow
Deny from all
+320
View File
@@ -0,0 +1,320 @@
<?php
/**
* Gemeinsame Verwaltungslogik für Abteilungen, Hilfethemen, Teams und SLA-Pläne.
* Wird von den Weboberflächen (staff/*.php) und der API (api/staff/v1/*.php) genutzt,
* damit beide exakt dieselben Regeln anwenden.
*/
class AdminModelException 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; }
}
/** Wert aus $in als int oder null (leer/nicht gesetzt = null). */
function am_int_or_null(array $in, string $key): ?int
{
return isset($in[$key]) && $in[$key] !== '' && $in[$key] !== null ? (int) $in[$key] : null;
}
/** Wirft, wenn in $table keine Zeile mit der id existiert. */
function am_require_row(PDO $pdo, string $table, ?int $id, string $message, string $code): void
{
if ($id === null) return;
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE id = ?");
$stmt->execute([$id]);
if (!(int) $stmt->fetchColumn()) throw new AdminModelException($message, $code);
}
/** Bisheriger Wert einer Spalte (für „nicht angegeben = unverändert“ bei älteren App-Versionen). */
function am_current(PDO $pdo, string $table, int $id, string $column, $default)
{
if (!$id) return $default;
$stmt = $pdo->prepare("SELECT {$column} FROM {$table} WHERE id = ?");
$stmt->execute([$id]);
$v = $stmt->fetchColumn();
return $v === false || $v === null ? $default : $v;
}
// ── Abteilungen ─────────────────────────────────────────────────────────────
/**
* Legt eine Abteilung an oder aktualisiert sie.
* Schlüssel in $in: name, email, is_default, is_active, manager_id, sla_plan_id, notes, signature.
* Fehlt is_active, bleibt der bisherige Status erhalten.
*/
function department_save(PDO $pdo, int $id, array $in): int
{
$name = trim((string) ($in['name'] ?? ''));
$email = trim((string) ($in['email'] ?? ''));
$isDefault = !empty($in['is_default']) ? 1 : 0;
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'departments', $id, 'is_active', 1);
$managerId = am_int_or_null($in, 'manager_id');
$slaPlanId = array_key_exists('sla_plan_id', $in) ? am_int_or_null($in, 'sla_plan_id') : ((int) am_current($pdo, 'departments', $id, 'sla_plan_id', 0) ?: null);
$notes = trim((string) ($in['notes'] ?? ''));
$signature = sanitize_staff_html((string) ($in['signature'] ?? ''));
if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED');
if ($managerId !== null) {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([$managerId]);
if (!(int) $stmt->fetchColumn()) throw new AdminModelException('Der gewählte Abteilungsleiter existiert nicht oder ist deaktiviert.', 'INVALID_MANAGER');
}
am_require_row($pdo, 'sla_plans', $slaPlanId, 'Der gewählte SLA-Plan existiert nicht.', 'INVALID_SLA_PLAN');
if ($isDefault && !$isActive) throw new AdminModelException('Die Standardabteilung muss aktiv sein.', 'DEFAULT_INACTIVE');
// E-Mail ist optional: ohne Angabe die Standardadresse der Abteilung (bzw. erste zugeordnete Adresse)
if ($email === '') {
$fb = $pdo->prepare('SELECT email FROM email_addresses WHERE department_id = ? ORDER BY is_default DESC, id LIMIT 1');
$fb->execute([$id]);
$email = (string) ($fb->fetchColumn() ?: '');
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new AdminModelException('Bitte eine gültige E-Mail-Adresse wählen.', 'INVALID_EMAIL');
}
if ($isDefault) $pdo->exec('UPDATE departments SET is_default = 0');
$now = db_now();
if ($id === 0) {
$pdo->prepare('INSERT INTO departments (name, email, is_default, is_active, manager_id, sla_plan_id, notes, signature, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
->execute([$name, $email, $isDefault, $isActive, $managerId, $slaPlanId, $notes ?: null, $signature ?: null, $now, $now]);
$id = (int) $pdo->lastInsertId();
} else {
$pdo->prepare('UPDATE departments SET name=?, email=?, is_default=?, is_active=?, manager_id=?, sla_plan_id=?, notes=?, signature=?, updated_at=? WHERE id=?')
->execute([$name, $email, $isDefault, $isActive, $managerId, $slaPlanId, $notes ?: null, $signature ?: null, $now, $id]);
}
// Eingehende Mails an die Abteilungs-E-Mail landen ohne weiteren Schritt in dieser Abteilung
if ($email !== '') sync_department_email_route($pdo, $id, $email);
return $id;
}
/** Löscht eine Abteilung (nur ohne zugeordnete Tickets). Gibt den Namen zurück. */
/**
* Anzahl der Datensätze in $table, die über $column auf $id verweisen (0, wenn Tabelle oder Spalte in dieser Datenbank fehlen).
*/
function am_reference_count(PDO $pdo, string $table, string $column, int $id): int
{
try {
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE {$column} = ?");
$stmt->execute([$id]);
return (int) $stmt->fetchColumn();
} catch (Throwable $e) {
return 0;
}
}
/**
* Löscht eine Abteilung. Wird sie noch gebraucht, gibt es eine verständliche Meldung statt eines Absturzes:
* Standardabteilung, letzte Abteilung und Abteilungen mit Tickets, Hilfethemen, E-Mail-Adressen, Postfächern, wiederkehrenden
* Tickets, Hauptabteilungs-Mitarbeitern oder Eskalationsregeln lassen sich nicht löschen. Reine Zuordnungen (zusätzliche
* Mitgliedschaften, Textbausteine) werden aufgeräumt.
*/
function department_delete(PDO $pdo, int $id): string
{
$stmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?');
$stmt->execute([$id]);
$dept = $stmt->fetch();
if (!$dept) throw new AdminModelException('Die Abteilung existiert nicht (mehr).', 'NOT_FOUND', 404);
$name = (string) $dept['name'];
if (!empty($dept['is_default'])) throw new AdminModelException('Die Standardabteilung kann nicht gelöscht werden. Bitte zuerst eine andere Abteilung als Standard festlegen.', 'DEPARTMENT_IS_DEFAULT');
if ((int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn() <= 1) throw new AdminModelException('Die letzte Abteilung kann nicht gelöscht werden.', 'LAST_DEPARTMENT');
$inUse = [];
$labels = [
['tickets', 'department_id', 'Tickets'], ['help_topics', 'department_id', 'Hilfethemen'], ['email_addresses', 'department_id', 'E-Mail-Adressen'],
['imap_accounts', 'department_id', 'Postfächer (Abruf)'], ['recurring_tickets', 'department_id', 'wiederkehrende Tickets'],
['staff', 'department_id', 'Mitarbeiter mit dieser Hauptabteilung'], ['escalation_rules', 'condition_dept_id', 'Eskalationsregeln'],
];
foreach ($labels as [$table, $column, $label]) {
$n = am_reference_count($pdo, $table, $column, $id);
if ($n > 0) $inUse[] = "{$n} {$label}";
}
if ($inUse) {
throw new AdminModelException('Diese Abteilung kann nicht gelöscht werden, sie wird noch verwendet von: ' . implode(', ', $inUse) . '. Bitte zuerst anderen Abteilungen zuordnen oder entfernen.', 'DEPARTMENT_IN_USE');
}
try {
$pdo->prepare('DELETE FROM staff_department WHERE department_id = ?')->execute([$id]);
$pdo->prepare('UPDATE canned_responses SET department_id = NULL WHERE department_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM departments WHERE id = ?')->execute([$id]);
} catch (PDOException $e) {
error_log('department_delete: ' . $e->getMessage());
throw new AdminModelException('Die Abteilung wird noch verwendet und kann nicht gelöscht werden.', 'DEPARTMENT_IN_USE');
}
return $name;
}
// ── Hilfethemen ─────────────────────────────────────────────────────────────
/** Alle Nachfahren eines Hilfethemas (um Zyklen beim Elternthema zu verhindern). */
function help_topic_descendants(PDO $pdo, int $id): array
{
$found = [];
$queue = [$id];
$stmt = $pdo->prepare('SELECT id FROM help_topics WHERE parent_id = ?');
while ($queue) {
$stmt->execute([array_shift($queue)]);
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $child) {
if (!in_array((int) $child, $found, true)) { $found[] = (int) $child; $queue[] = (int) $child; }
}
}
return $found;
}
/**
* Legt ein Hilfethema an oder aktualisiert es.
* Schlüssel in $in: name, department_id, priority_id, is_active, is_public, parent_id, sla_plan_id, notes, sort_order.
*/
function help_topic_save(PDO $pdo, int $id, array $in): int
{
$name = trim((string) ($in['name'] ?? ''));
$deptId = (int) ($in['department_id'] ?? 0);
$prioId = (int) ($in['priority_id'] ?? 0);
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'help_topics', $id, 'is_active', 1);
$isPublic = array_key_exists('is_public', $in) ? (!empty($in['is_public']) ? 1 : 0) : (int) am_current($pdo, 'help_topics', $id, 'is_public', 1);
$parentId = array_key_exists('parent_id', $in) ? am_int_or_null($in, 'parent_id') : ((int) am_current($pdo, 'help_topics', $id, 'parent_id', 0) ?: null);
$slaPlanId = array_key_exists('sla_plan_id', $in) ? am_int_or_null($in, 'sla_plan_id') : ((int) am_current($pdo, 'help_topics', $id, 'sla_plan_id', 0) ?: null);
$notes = array_key_exists('notes', $in) ? trim((string) $in['notes']) : (string) am_current($pdo, 'help_topics', $id, 'notes', '');
$sort = (int) ($in['sort_order'] ?? 0);
if ($name === '') throw new AdminModelException('Bitte ein Thema angeben.', 'NAME_REQUIRED');
am_require_row($pdo, 'departments', $deptId ?: null, 'Bitte eine gültige Abteilung wählen.', 'INVALID_DEPARTMENT');
if (!$deptId) throw new AdminModelException('Bitte eine gültige Abteilung wählen.', 'INVALID_DEPARTMENT');
am_require_row($pdo, 'priorities', $prioId ?: null, 'Bitte eine gültige Priorität wählen.', 'INVALID_PRIORITY');
if (!$prioId) throw new AdminModelException('Bitte eine gültige Priorität wählen.', 'INVALID_PRIORITY');
am_require_row($pdo, 'sla_plans', $slaPlanId, 'Der gewählte SLA-Plan existiert nicht.', 'INVALID_SLA_PLAN');
if ($parentId !== null) {
am_require_row($pdo, 'help_topics', $parentId, 'Das übergeordnete Thema existiert nicht.', 'INVALID_PARENT');
if ($id && ($parentId === $id || in_array($parentId, help_topic_descendants($pdo, $id), true))) {
throw new AdminModelException('Ein Thema kann nicht sich selbst oder einem eigenen Unterthema untergeordnet werden.', 'INVALID_PARENT');
}
}
$now = db_now();
if ($id === 0) {
$pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, is_public, parent_id, sla_plan_id, notes, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
->execute([$name, $deptId, $prioId, $isActive, $isPublic, $parentId, $slaPlanId, $notes ?: null, $sort, $now, $now]);
return (int) $pdo->lastInsertId();
}
$pdo->prepare('UPDATE help_topics SET name=?, department_id=?, priority_id=?, is_active=?, is_public=?, parent_id=?, sla_plan_id=?, notes=?, sort_order=?, updated_at=? WHERE id=?')
->execute([$name, $deptId, $prioId, $isActive, $isPublic, $parentId, $slaPlanId, $notes ?: null, $sort, $now, $id]);
return $id;
}
/** Löscht ein Hilfethema; Tickets verlieren die Zuordnung, Unterthemen rücken eine Ebene nach oben. Gibt den Namen zurück. */
function help_topic_delete(PDO $pdo, int $id): string
{
$name = (string) am_current($pdo, 'help_topics', $id, 'name', (string) $id);
$pdo->prepare('UPDATE tickets SET help_topic_id = NULL WHERE help_topic_id = ?')->execute([$id]);
$parent = am_current($pdo, 'help_topics', $id, 'parent_id', null);
$pdo->prepare('UPDATE help_topics SET parent_id = ? WHERE parent_id = ?')->execute([$parent ?: null, $id]);
try {
$pdo->prepare('DELETE FROM help_topics WHERE id = ?')->execute([$id]);
} catch (PDOException $e) {
error_log('help_topic_delete: ' . $e->getMessage());
throw new AdminModelException('Das Hilfethema wird noch verwendet und kann nicht gelöscht werden.', 'TOPIC_IN_USE');
}
return $name;
}
// ── Teams ───────────────────────────────────────────────────────────────────
/**
* Legt ein Team an oder aktualisiert es.
* Schlüssel in $in: name, lead_id, notes, is_active, no_alerts, members (Array von Mitarbeiter-IDs).
* Fehlen is_active/no_alerts, bleiben die bisherigen Werte erhalten.
*/
function team_save(PDO $pdo, int $id, array $in): int
{
$name = trim((string) ($in['name'] ?? ''));
$leadId = am_int_or_null($in, 'lead_id');
$notes = trim((string) ($in['notes'] ?? ''));
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'teams', $id, 'is_active', 1);
$noAlerts = array_key_exists('no_alerts', $in) ? (!empty($in['no_alerts']) ? 1 : 0) : (int) am_current($pdo, 'teams', $id, 'no_alerts', 0);
$members = array_values(array_unique(array_filter(array_map('intval', (array) ($in['members'] ?? [])))));
if ($name === '') throw new AdminModelException('Bitte einen Teamnamen angeben.', 'NAME_REQUIRED');
if ($leadId !== null) {
$stmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([$leadId]);
if (!(int) $stmt->fetchColumn()) throw new AdminModelException('Der gewählte Teamleiter existiert nicht oder ist deaktiviert.', 'INVALID_LEAD');
}
$dup = $pdo->prepare('SELECT COUNT(*) FROM teams WHERE LOWER(name) = LOWER(?) AND id <> ?');
$dup->execute([$name, $id]);
if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Ein Team mit diesem Namen existiert bereits.', 'NAME_TAKEN');
$now = db_now();
if ($id === 0) {
$pdo->prepare('INSERT INTO teams (name, lead_id, notes, is_active, no_alerts, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
->execute([$name, $leadId, $notes ?: null, $isActive, $noAlerts, $now, $now]);
$id = (int) $pdo->lastInsertId();
} else {
$pdo->prepare('UPDATE teams SET name=?, lead_id=?, notes=?, is_active=?, no_alerts=?, updated_at=? WHERE id=?')
->execute([$name, $leadId, $notes ?: null, $isActive, $noAlerts, $now, $id]);
}
$pdo->prepare('DELETE FROM team_members WHERE team_id = ?')->execute([$id]);
$ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)');
foreach ($members as $sid) $ins->execute([$id, $sid]);
return $id;
}
/** Löscht ein Team; zugewiesene Tickets verlieren die Team-Zuordnung. Gibt den Namen zurück. */
function team_delete(PDO $pdo, int $id): string
{
$name = (string) am_current($pdo, 'teams', $id, 'name', (string) $id);
$pdo->prepare('UPDATE tickets SET team_id = NULL WHERE team_id = ?')->execute([$id]);
try {
$pdo->prepare('DELETE FROM teams WHERE id = ?')->execute([$id]);
} catch (PDOException $e) {
error_log('team_delete: ' . $e->getMessage());
throw new AdminModelException('Das Team wird noch verwendet und kann nicht gelöscht werden.', 'TEAM_IN_USE');
}
return $name;
}
// ── SLA-Pläne ───────────────────────────────────────────────────────────────
/** Legt einen SLA-Plan an oder aktualisiert ihn. Schlüssel: name, response_hours, resolution_hours, is_active, notes. */
function sla_plan_save(PDO $pdo, int $id, array $in): int
{
$name = trim((string) ($in['name'] ?? ''));
$response = (int) ($in['response_hours'] ?? 0);
$resolution = (int) ($in['resolution_hours'] ?? 0);
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'sla_plans', $id, 'is_active', 1);
$notes = trim((string) ($in['notes'] ?? ''));
if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED');
if ($response <= 0 || $resolution <= 0) throw new AdminModelException('Bitte gültige Stunden angeben (größer als 0).', 'INVALID_HOURS');
if ($response > $resolution) throw new AdminModelException('Die Reaktionszeit darf nicht länger als die Lösungszeit sein.', 'INVALID_HOURS');
$dup = $pdo->prepare('SELECT COUNT(*) FROM sla_plans WHERE LOWER(name) = LOWER(?) AND id <> ?');
$dup->execute([$name, $id]);
if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Ein SLA-Plan mit diesem Namen existiert bereits.', 'NAME_TAKEN');
$now = db_now();
if ($id === 0) {
$pdo->prepare('INSERT INTO sla_plans (name, response_hours, resolution_hours, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
->execute([$name, $response, $resolution, $isActive, $notes ?: null, $now, $now]);
return (int) $pdo->lastInsertId();
}
$pdo->prepare('UPDATE sla_plans SET name=?, response_hours=?, resolution_hours=?, is_active=?, notes=?, updated_at=? WHERE id=?')
->execute([$name, $response, $resolution, $isActive, $notes ?: null, $now, $id]);
return $id;
}
/** Löscht einen SLA-Plan; Abteilungen und Themen fallen auf die Zeiten der Priorität zurück. Gibt den Namen zurück. */
function sla_plan_delete(PDO $pdo, int $id): string
{
$name = (string) am_current($pdo, 'sla_plans', $id, 'name', (string) $id);
$pdo->prepare('UPDATE departments SET sla_plan_id = NULL WHERE sla_plan_id = ?')->execute([$id]);
$pdo->prepare('UPDATE help_topics SET sla_plan_id = NULL WHERE sla_plan_id = ?')->execute([$id]);
try {
$pdo->prepare('DELETE FROM sla_plans WHERE id = ?')->execute([$id]);
} catch (PDOException $e) {
error_log('sla_plan_delete: ' . $e->getMessage());
throw new AdminModelException('Der SLA-Plan wird noch verwendet und kann nicht gelöscht werden.', 'SLA_PLAN_IN_USE');
}
return $name;
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Download der Windows- und Android-App für Mitarbeiter.
*
* Der Admin legt die Dateien per FTP in app_downloads/ (per .htaccess nicht direkt aufrufbar) ab:
* DeskHub-Setup.exe (Windows) und DeskHub.apk (Android). Ausgeliefert werden sie nur über staff/app_download.php
* an angemeldete Mitarbeiter.
*/
/** Unterstützte Plattformen: Dateiendung, feste Dateinamen auf dem Server, Dateikopf-Prüfung, Anzeigename. */
function app_platforms(): array
{
return [
'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'],
'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'],
];
}
function app_downloads_dir(): string
{
return rtrim((string) (getenv('DESKHUB_APPS_DIR') ?: dirname(__DIR__) . '/app_downloads'), '/\\');
}
/** Legt den Ordner an und sperrt den Direktzugriff. */
function app_downloads_prepare_dir(): bool
{
$dir = app_downloads_dir();
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
@file_put_contents($htaccess, "# Nur über staff/app_download.php (mit Anmeldung) abrufbar\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\nOptions -Indexes\n");
}
if (!is_file($dir . '/index.html')) @file_put_contents($dir . '/index.html', '');
return is_writable($dir);
}
/**
* Angaben zu einer App oder null, wenn die Datei nicht im Ordner liegt. Version gibt es nicht: Die Dateien werden
* per FTP abgelegt, angezeigt werden Datum, Größe und eine berechnete SHA-256-Prüfsumme (zwischengespeichert,
* solange sich Änderungsdatum und Größe nicht ändern).
*/
function app_download_info(string $platform): ?array
{
$platforms = app_platforms();
if (!isset($platforms[$platform])) return null;
$p = $platforms[$platform];
$path = app_downloads_dir() . '/' . $p['stored'];
clearstatcache(true, $path);
if (!is_file($path) || (int) filesize($path) < 1024) return null;
$size = (int) filesize($path);
$mtime = (int) filemtime($path);
$cacheKey = "app_{$platform}_hash_cache";
$cached = explode(':', (string) get_setting($cacheKey, ''), 3);
if (count($cached) === 3 && (int) $cached[0] === $mtime && (int) $cached[1] === $size) {
$sha = $cached[2];
} else {
$sha = (string) hash_file('sha256', $path);
set_setting($cacheKey, "{$mtime}:{$size}:{$sha}");
}
return [
'platform' => $platform,
'label' => $p['label'],
'size' => $size,
'sha256' => $sha,
'modified_at' => date('Y-m-d H:i:s', $mtime),
'path' => $path,
'filename' => $p['download'],
'mime' => $p['mime'],
];
}
function app_format_size(int $bytes): string
{
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '.') . ' MB';
return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
}
+141
View File
@@ -0,0 +1,141 @@
<?php
/**
* Authentifizierung – getrennte Sessions für Kunden (users) und Mitarbeiter (staff)
*/
// ---------------------------------------------------------------
// Kunden-Auth
// ---------------------------------------------------------------
function customer_login(array $user): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
}
function customer_logout(): void
{
unset($_SESSION['user_id']);
}
function current_user(): ?array
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$stmt = get_db()->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$_SESSION['user_id']]);
$user = $stmt->fetch();
return $cache = ($user ?: null);
}
function require_customer_login(): array
{
$user = current_user();
if (!$user) {
flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
redirect(base_path() . '/login.php');
}
return $user;
}
// ---------------------------------------------------------------
// Mitarbeiter-Auth
// ---------------------------------------------------------------
function staff_login(array $staff): void
{
session_regenerate_id(true);
$_SESSION['staff_id'] = $staff['id'];
$_SESSION['staff_last_activity'] = time();
}
function staff_logout(): void
{
// Automatische Zeiterfassung: offene Arbeits-Sitzungen beim Logout sofort
// abschließen, statt bis zum nächsten Stale-Sweep (Auto-Cron) zu warten.
if (!empty($_SESSION['staff_id'])) {
finalize_all_sessions_for_staff(get_db(), (int) $_SESSION['staff_id']);
}
unset($_SESSION['staff_id']);
}
function current_staff(): ?array
{
if (empty($_SESSION['staff_id'])) {
return null;
}
static $cache = null;
if ($cache !== null) {
return $cache;
}
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$stmt->execute([$_SESSION['staff_id']]);
$staff = $stmt->fetch();
return $cache = ($staff ?: null);
}
function require_staff_login(): array
{
$staff = current_staff();
if (!$staff) {
flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
redirect(staff_base_path() . '/login.php');
}
// Inaktivitäts-Timeout
$timeout = (int)(get_setting('staff_session_timeout', '30') ?: 30); // Minuten
$last = $_SESSION['staff_last_activity'] ?? 0;
if ($last && (time() - $last) > ($timeout * 60)) {
staff_logout();
session_start();
$_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet.']];
redirect('/staff/login.php');
}
$_SESSION['staff_last_activity'] = time();
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php):
// bei jeder authentifizierten Backend-Anfrage werden fällige Jobs im Hintergrund geprüft.
pseudo_cron_schedule_background();
return $staff;
}
function require_admin(): array
{
$staff = require_staff_login();
if ($staff['role'] !== 'admin') {
flash('error', 'Diese Funktion ist nur für Administratoren verfügbar.');
redirect(staff_base_path() . '/reports.php');
}
return $staff;
}
/**
* Wie require_admin(), lässt aber zusätzlich Agenten mit einer benutzerdefinierten Rolle
* durch, die genau dieses Adminportal-Recht zugewiesen bekommen haben ("Co-Admin" für
* einzelne Bereiche, siehe admin_section_permissions() / staff/roles.php).
*/
function require_admin_or_permission(string $permission): array
{
$staff = require_staff_login();
if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) {
flash('error', 'Für diese Funktion fehlt die erforderliche Berechtigung.');
redirect(staff_base_path() . '/reports.php');
}
return $staff;
}
/** Pfad-Helfer, damit Includes unabhängig vom Aufrufort funktionieren */
function base_path(): string
{
return '';
}
function staff_base_path(): string
{
return '/staff';
}
+223
View File
@@ -0,0 +1,223 @@
<?php
/**
* Backup-Hilfsfunktion – wird von update.php, backup.php und den Cron-Jobs genutzt.
*
* Ein Backup ist eine ZIP-Datei mit
* - allen Programm- und Datendateien (ohne Backups, Entwickler-Artefakte),
* - einem konsistenten Datenbank-Abzug: database_backup.sqlite (SQLite, per VACUUM INTO) oder database_backup.sql (MySQL),
* - backup_manifest.json (Zeitpunkt, Datenbanktyp, Programmversion, Anzahl Tabellen).
* Wiederherstellen und Prüfen: includes/backup_restore.php.
*/
require_once __DIR__ . '/simple_zip.php';
/** Pfade, die nie ins Backup gehören (Entwickler-Artefakte und Zwischenspeicher). */
function backup_excluded_fragments(): array
{
return ['/node_modules/', '/.git/', '/desktop/dist/', '/mobile/build/', '/mobile/artifacts/', '/plugin-webseite/storage/', '/plugin-webseite/schluessel/'];
}
// ── Backup erstellen ───────────────────────────────────────────────�
/**
* @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())
*/
function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null): array
{
$rootDir = realpath($rootDir ?? __DIR__ . '/..');
$backupDir = $backupDir ?? $rootDir . '/backups';
$pdo = $pdo ?? get_db();
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
// .htaccess zum Schutz des Backup-Ordners
$htaccess = $backupDir . '/.htaccess';
if (!file_exists($htaccess)) {
file_put_contents($htaccess, "Require all denied
Order deny,allow
Deny from all
");
}
$backupName = 'backup_' . date('Y-m-d_H-i-s') . '.zip';
$backupPath = $backupDir . '/' . $backupName;
if (file_exists($backupPath)) { // zwei Backups in derselben Sekunde
$backupName = 'backup_' . date('Y-m-d_H-i-s') . '_' . substr(bin2hex(random_bytes(2)), 0, 3) . '.zip';
$backupPath = $backupDir . '/' . $backupName;
}
// Zeitlimit erhöhen für große Backups
@set_time_limit(300);
@ini_set('memory_limit', '256M');
// php_zip automatisch aktivieren falls nicht geladen
if (!class_exists('ZipArchive')) {
$activated = false;
// 1. Dynamisch laden (funktioniert wenn dl() erlaubt)
if (function_exists('dl') && ini_get('enable_dl')) {
$activated = @dl('php_zip.dll') || @dl('zip.so') || @dl('zip');
}
// 2. php.ini dauerhaft anpassen (für nächsten Start)
if (!$activated) {
$phpIni = php_ini_loaded_file();
if ($phpIni && is_writable($phpIni)) {
$ini = file_get_contents($phpIni);
$patched = preg_replace('/^[ \t]*;[ \t]*(extension[ \t]*=[ \t]*(?:php_)?zip(?:\.dll|\.so)?)/mi', '$1', $ini);
if ($patched !== $ini) {
file_put_contents($phpIni, $patched);
// Hinweis speichern dass Neustart nötig ist
file_put_contents($backupDir . '/.restart_required', '1');
}
}
}
}
// Datenbank-Abzug: SQLite-Datei wird NICHT als laufende Datei kopiert, sondern konsistent per VACUUM INTO gesichert
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$liveDb = null;
if ($driver === 'sqlite') {
$row = $pdo->query('PRAGMA database_list')->fetch(PDO::FETCH_ASSOC);
$liveDb = !empty($row['file']) ? realpath($row['file']) : null;
}
// Alle Dateien sammeln
$exclude = [realpath($backupDir) ?: $backupDir];
$iter = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$files = [];
foreach ($iter as $file) {
if (!$file->isFile()) continue;
$filePath = $file->getRealPath();
$norm = str_replace('\\', '/', $filePath);
$skip = false;
foreach ($exclude as $ex) {
if ($ex && strpos($filePath, $ex) === 0) { $skip = true; break; }
}
foreach (backup_excluded_fragments() as $frag) {
if (str_contains($norm, $frag)) { $skip = true; break; }
}
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 /
}
// Abzug erzeugen
$sqlDump = null; // MySQL: Text
$sqlFile = null; // MySQL: Dateiname im ZIP
$sqliteSnapshot = null;
$tableCount = 0;
$dbError = null;
if ($driver === 'sqlite') {
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
@unlink($snapshot);
try {
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
$sqliteSnapshot = $snapshot;
} catch (Throwable $e) {
// Ältere SQLite-Version ohne VACUUM INTO: Datei kopieren (weniger sicher, aber besser als nichts)
if ($liveDb && @copy($liveDb, $snapshot)) $sqliteSnapshot = $snapshot;
else $dbError = 'SQLite-Abzug fehlgeschlagen: ' . $e->getMessage();
}
if ($sqliteSnapshot) {
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn();
}
} elseif ($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";
// Alle Tabellen auslesen
$tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN);
$tableCount = count($tables);
foreach ($tables as $table) {
// CREATE TABLE
$create = $pdo->query("SHOW CREATE TABLE `$table`")->fetch();
$dump .= "\n-- Tabelle: $table\n";
$dump .= "DROP TABLE IF EXISTS `$table`;\n";
$dump .= $create['Create Table'] . ";\n\n";
// Daten
$rows = $pdo->query("SELECT * FROM `$table`")->fetchAll(PDO::FETCH_ASSOC);
if ($rows) {
$cols = '`' . implode('`, `', array_keys($rows[0])) . '`';
$dump .= "INSERT INTO `$table` ($cols) VALUES\n";
$vals = [];
foreach ($rows as $row) {
$escaped = array_map(fn($v) => $v === null ? 'NULL' : $pdo->quote((string)$v), $row);
$vals[] = '(' . implode(', ', $escaped) . ')';
}
$dump .= implode(",\n", $vals) . ";\n\n";
}
}
$dump .= "SET FOREIGN_KEY_CHECKS=1;\n";
$sqlFile = 'database_backup.sql';
$sqlDump = $dump;
} catch (Throwable $e) {
$dbError = 'MySQL-Abzug fehlgeschlagen: ' . $e->getMessage();
}
}
// Ohne Datenbank-Abzug ist es kein brauchbares Backup: lieber Fehler melden als ein Backup vortäuschen
if ($dbError !== null) {
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
return ['ok' => false, 'error' => $dbError];
}
$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),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung
try {
if (class_exists('ZipArchive')) {
$zip = new ZipArchive();
if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return ['ok' => false, 'error' => 'Backup-ZIP konnte nicht erstellt werden.'];
}
foreach ($files as $filePath => $relativePath) {
$zip->addFile($filePath, $relativePath);
}
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
$zip->addFromString('backup_manifest.json', $manifest);
$zip->close();
} else {
// Eigene ZIP-Implementierung (kein php_zip nötig)
$zip = new SimpleZip($backupPath);
foreach ($files as $filePath => $relativePath) {
$zip->addFile($filePath, $relativePath);
}
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
if ($sqliteSnapshot) $zip->addFromString('database_backup.sqlite', (string) file_get_contents($sqliteSnapshot));
$zip->addFromString('backup_manifest.json', $manifest);
$zip->close();
}
} catch (Exception $e) {
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
return ['ok' => false, 'error' => 'Backup fehlgeschlagen: ' . $e->getMessage()];
}
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
if (!file_exists($backupPath)) {
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt.'];
}
return [
'ok' => true,
'filename' => $backupName,
'path' => $backupPath,
'files' => count($files),
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
];
}
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/**
* Backups prüfen und wiederherstellen (Gegenstück zu create_backup in includes/backup.php).
*
* backup_verify($zip) prüft, ob ein Backup vollständig und lesbar ist, ohne etwas zu verändern
* backup_restore($pdo, $zip, …) stellt Datenbank und (wählbar) Dateien wieder her; vorher wird ein Sicherheits-Backup erstellt
*
* Programmdateien (includes/, staff/, config/ …) werden nie überschrieben: Dafür gibt es das System-Update. Wiederhergestellt werden
* die Datenbank sowie die Ordner uploads/, plugins/ und app_downloads/.
*/
require_once __DIR__ . '/backup.php';
/** Ordner im Backup, die beim Wiederherstellen zurückgespielt werden dürfen. */
function backup_restorable_dirs(): array
{
return ['uploads' => 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien'];
}
/** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */
function backup_entry_name_ok(string $name): bool
{
$n = str_replace('\\', '/', $name);
return $n !== '' && $n[0] !== '/' && !str_contains($n, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $n) && !preg_match('#^[A-Za-z]:#', $n);
}
/** Zerlegt einen SQL-Dump in Anweisungen (Zeilenweise, mit Beachtung von Zeichenketten). */
function backup_sql_statements(string $file): Generator
{
$fh = fopen($file, 'rb');
$buf = '';
$inQuote = false;
while (($line = fgets($fh)) !== false) {
if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue;
$buf .= $line;
// Anführungszeichen-Zustand über die Zeile fortschreiben
$n = strlen($line);
for ($i = 0; $i < $n; $i++) {
$c = $line[$i];
if ($inQuote) {
if ($c === '\\') { $i++; continue; }
if ($c === "'") $inQuote = false;
} elseif ($c === "'") {
$inQuote = true;
}
}
if (!$inQuote && substr(rtrim($line), -1) === ';') {
yield trim($buf);
$buf = '';
}
}
if (trim($buf) !== '') yield trim($buf);
fclose($fh);
}
/** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */
function backup_extract_entry(ZipArchive $zip, string $name): ?string
{
$in = $zip->getStream($name);
if (!$in) return null;
$tmp = tempnam(sys_get_temp_dir(), 'dhrs');
$out = fopen($tmp, 'wb');
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
return $tmp;
}
/**
* Prüft ein Backup, ohne etwas zu verändern.
*
* @return array{ok:bool, errors:string[], warnings:string[], info:array}
*/
function backup_verify(string $zipPath): array
{
$r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []];
if (!class_exists('ZipArchive')) { $r['errors'][] = 'Die PHP-Erweiterung zip fehlt.'; return $r; }
if (!is_file($zipPath) || filesize($zipPath) < 100) { $r['errors'][] = 'Die Backup-Datei fehlt oder ist leer.'; return $r; }
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; }
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string) $zip->getNameIndex($i);
if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; }
$names[$n] = true;
}
$r['info']['files'] = count($names);
$manifest = null;
if (isset($names['backup_manifest.json'])) {
$manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; }
}
$r['info']['created'] = $manifest['created'] ?? null;
$r['info']['version'] = $manifest['version'] ?? null;
$key = ['tickets', 'users', 'staff', 'settings'];
if (isset($names['database_backup.sqlite'])) {
$r['info']['driver'] = 'sqlite';
$tmp = backup_extract_entry($zip, 'database_backup.sqlite');
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') {
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist keine gültige SQLite-Datenbank.';
} else {
try {
$db = new PDO('sqlite:' . $tmp);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$integrity = $db->query('PRAGMA integrity_check')->fetchColumn();
if ($integrity !== 'ok') $r['errors'][] = 'Die Datenbank im Backup ist beschädigt: ' . $integrity;
$tables = $db->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
$r['info']['tables'] = count($tables);
foreach ($key as $t) if (!in_array($t, $tables, true)) $r['errors'][] = "In der Datenbank des Backups fehlt die Tabelle \"{$t}\".";
foreach (['tickets', 'users', 'staff'] as $t) if (in_array($t, $tables, true)) $r['info'][$t] = (int) $db->query("SELECT COUNT(*) FROM {$t}")->fetchColumn();
} catch (Throwable $e) {
$r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage();
}
$db = null;
}
if ($tmp) @unlink($tmp);
} elseif (isset($names['database_backup.sql'])) {
$r['info']['driver'] = 'mysql';
$tmp = backup_extract_entry($zip, 'database_backup.sql');
if (!$tmp) {
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.';
} else {
$creates = [];
$count = 0;
$last = '';
foreach (backup_sql_statements($tmp) as $stmt) {
$count++;
$last = $stmt;
if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true;
}
$r['info']['tables'] = count($creates);
$r['info']['statements'] = $count;
foreach ($key as $t) if (!isset($creates[$t])) $r['errors'][] = "Im Datenbank-Abzug fehlt die Tabelle \"{$t}\".";
if (stripos($last, 'FOREIGN_KEY_CHECKS=1') === false) $r['errors'][] = 'Der Datenbank-Abzug ist unvollständig (das Ende fehlt).';
@unlink($tmp);
}
} elseif (isset($names['database/deskhub.sqlite'])) {
// Ältere Backups enthalten die laufende SQLite-Datei als Kopie
$r['info']['driver'] = 'sqlite';
$r['warnings'][] = 'Dieses ältere Backup enthält eine Kopie der laufenden Datenbankdatei. Sie lässt sich nicht garantiert fehlerfrei wiederherstellen.';
$tmp = backup_extract_entry($zip, 'database/deskhub.sqlite');
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') $r['errors'][] = 'Die Datenbankkopie im Backup ist keine gültige SQLite-Datenbank.';
else $r['errors'][] = 'Backups im alten Format können nicht automatisch wiederhergestellt werden. Bitte ein neues Backup erstellen.';
if ($tmp) @unlink($tmp);
} else {
$r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.';
}
$zip->close();
$r['ok'] = !$r['errors'];
return $r;
}
/**
* Stellt ein Backup wieder her.
*
* Optionen: database (Standard true), dirs (Liste aus backup_restorable_dirs(), Standard alle),
* root (Zielordner für Dateien), backup_dir (Zielordner des Sicherheits-Backups), safety_backup (Standard true).
*
* @return array{ok:bool, error?:string, tables?:int, rows?:int, files?:int, safety?:string, notes?:string[]}
*/
function backup_restore(PDO $pdo, string $zipPath, array $options = []): array
{
@set_time_limit(600);
$check = backup_verify($zipPath);
if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])];
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if (($check['info']['driver'] ?? '') !== $driver) {
return ['ok' => false, 'error' => 'Das Backup stammt aus einer ' . strtoupper((string) $check['info']['driver']) . '-Datenbank, dieses System nutzt ' . strtoupper($driver) . '. Eine Wiederherstellung ist nur auf demselben Datenbanktyp möglich.'];
}
$root = realpath($options['root'] ?? __DIR__ . '/..');
$result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []];
if (($options['safety_backup'] ?? true)) {
$safety = create_backup($root, $options['backup_dir'] ?? null, $pdo);
if (!$safety['ok']) return ['ok' => false, 'error' => 'Das Sicherheits-Backup vor der Wiederherstellung ist fehlgeschlagen, es wurde nichts verändert: ' . $safety['error']];
$result['safety'] = $safety['filename'];
}
$zip = new ZipArchive();
$zip->open($zipPath);
try {
if ($options['database'] ?? true) {
$db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip);
$result['tables'] = $db['tables'];
$result['rows'] = $db['rows'];
$result['notes'] = array_merge($result['notes'], $db['notes']);
}
$dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($options['dirs'] ?? array_keys(backup_restorable_dirs()))));
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue;
$top = explode('/', $name, 2)[0];
if (!in_array($top, $dirs, true)) continue;
$dest = $root . '/' . $name;
if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true);
$in = $zip->getStream($zip->getNameIndex($i));
if (!$in) continue;
$out = fopen($dest, 'wb');
if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; }
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
$result['files']++;
}
} catch (Throwable $e) {
$zip->close();
return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')];
}
$zip->close();
return $result;
}
/** SQLite: Inhalte aller Tabellen des Backups in die laufende Datenbank übernehmen (Spalten, die es in beiden gibt). */
function backup_restore_sqlite(PDO $pdo, ZipArchive $zip): array
{
$tmp = backup_extract_entry($zip, 'database_backup.sqlite');
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
$out = ['tables' => 0, 'rows' => 0, 'notes' => []];
$pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion
try {
$pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk');
$pdo->beginTransaction();
$bkTables = $pdo->query("SELECT name FROM bk.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
$liveTables = $pdo->query("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
$cols = fn(string $schema, string $t) => array_column($pdo->query("PRAGMA {$schema}.table_info(\"{$t}\")")->fetchAll(PDO::FETCH_ASSOC), 'name');
$missing = [];
foreach ($liveTables as $t) {
if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; }
$common = array_values(array_intersect($cols('main', $t), $cols('bk', $t)));
$pdo->exec("DELETE FROM main.\"{$t}\"");
if ($common) {
$list = '"' . implode('","', $common) . '"';
$pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\"");
}
$out['tables']++;
$out['rows'] += (int) $pdo->query("SELECT COUNT(*) FROM main.\"{$t}\"")->fetchColumn();
}
// Zähler der automatischen Nummern übernehmen, damit neue Datensätze nicht alte Nummern wiederverwenden
$hasSeq = (bool) $pdo->query("SELECT 1 FROM bk.sqlite_master WHERE name = 'sqlite_sequence'")->fetchColumn();
if ($hasSeq) {
$pdo->exec('DELETE FROM main.sqlite_sequence');
$pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence');
}
$pdo->commit();
if ($missing) $out['notes'][] = 'Tabellen, die es im Backup noch nicht gab, blieben unverändert: ' . implode(', ', $missing) . '. Danach unter System-Update „Datenbank aktualisieren“ ausführen.';
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
} finally {
try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {}
$pdo->exec('PRAGMA foreign_keys = ON');
@unlink($tmp);
}
return $out;
}
/** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */
function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array
{
$tmp = backup_extract_entry($zip, 'database_backup.sql');
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
$out = ['tables' => 0, 'rows' => 0, 'notes' => []];
try {
foreach (backup_sql_statements($tmp) as $stmt) {
$pdo->exec($stmt);
if (preg_match('/^CREATE TABLE/i', $stmt)) $out['tables']++;
if (preg_match('/^INSERT INTO/i', $stmt)) $out['rows'] += max(1, substr_count($stmt, "),\n(") + 1);
}
} finally {
@unlink($tmp);
}
$out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.';
return $out;
}
/** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */
function backup_path_for(string $filename, ?string $backupDir = null): ?string
{
$dir = realpath($backupDir ?? __DIR__ . '/../backups');
$file = basename($filename);
if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null;
return $dir . '/' . $file;
}
/**
* Prüft ein Backup und merkt sich das Ergebnis (für die Anzeige und den Hinweis im System). Ein Backup, das sich nicht
* wiederherstellen lässt, ist wertlos: Das soll auffallen, bevor man es braucht.
*/
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));
return $r;
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* Wird von jeder Seite als erstes eingebunden.
*/
require_once __DIR__ . '/../version.php';
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/themes.php';
require_once __DIR__ . '/time_tracking.php';
require_once __DIR__ . '/pseudo_cron.php';
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/smtp_mailer.php';
require_once __DIR__ . '/mailer.php';
require_once __DIR__ . '/email_addresses.php';
require_once __DIR__ . '/health.php';
require_once __DIR__ . '/admin_models.php';
require_once __DIR__ . '/queues.php';
require_once __DIR__ . '/integrations.php';
require_once __DIR__ . '/reports.php';
require_once __DIR__ . '/notifications.php';
require_once __DIR__ . '/plugins.php';
require_once __DIR__ . '/app_downloads.php';
require_once __DIR__ . '/theme_catalog.php';
require_once __DIR__ . '/security_check.php';
require_once __DIR__ . '/update_security.php';
date_default_timezone_set(get_setting('timezone', config()['site']['timezone'] ?? 'UTC'));
// Sichere Session-Cookie-Einstellungen (vor session_start)
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => request_is_https(),
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
// Sicherheits-Header
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.qrserver.com; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';");
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
// HSTS nur bei echtem HTTPS senden
if (request_is_https()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
// PHP-Fehler nie im Browser ausgeben (Produktion)
@ini_set('display_errors', '0');
@ini_set('log_errors', '1');
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php).
// Bewusst auf JEDER Seite (nicht nur im Mitarbeiterbereich): auch Kundenportal-Besuche
// (Ticket einreichen, Status prüfen, Login, KB) halten die Hintergrund-Jobs am Laufen –
// genau wie bei WordPress' wp-cron.php reicht dafür jeder Seitenaufruf, nicht nur ein
// eingeloggter Mitarbeiter. Läuft NACH dem Senden der Antwort, verlangsamt also nichts.
pseudo_cron_schedule_background();
// Aktive Plugins laden (Fehler in Plugins werden isoliert, siehe includes/plugins.php).
plugins_boot();
// DB-kompatibler SQL-Ausdruck für Domain-Teil einer E-Mail
function sql_email_domain(string $col): string {
$driver = config()['db']['driver'] ?? 'sqlite';
return $driver === 'mysql'
? "SUBSTRING_INDEX($col,'@',-1)"
: "SUBSTR($col, INSTR($col,'@')+1)";
}
+11
View File
@@ -0,0 +1,11 @@
<?php
/** @var string|null $logo */
$logo = get_setting('logo_path');
?>
<div class="brand-block">
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>">
<?php else: ?>
<span class="brand-block-text">Desk<span>Hub</span></span>
<?php endif; ?>
</div>
@@ -0,0 +1,23 @@
<?php
/**
* Offizieller Plugin- und Theme-Katalog dieser DeskHub-Version (siehe plugin-webseite/README.md).
* Mit diesen Werten gilt der geprüfte Modus: Plugins nur aus diesem Katalog, nur mit Signatur des Herausgebers.
* Ändern: Webseite → Verwaltung → „Einstellungen für DeskHub“, dann Release neu bauen.
*/
return [
'plugins_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=plugins',
'themes_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=themes',
'access_key' => '', // Zugangsschlüssel des Katalogs (docker-build gibt ihn aus); leer = Katalog ohne Zugangsschutz
'publisher_key' => '-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEApuCBEnEYk4uVtP4cijWN
LBgzjGU9TKb8Zjygk6DIeKKiR3Li7MUjkSPzP4JIZolxdzN147yw4gKG08WUSx4B
QbtvfgD40CxjRC83D3L+/8K2FaWmYGsQefhZjtsZzY9xtJjUEEVe0eRYlyi2ZDgW
nDcz0++KTnRmPwd2qz2MNsgx1gOciRRawqOXzzlx7u5aKaVHzpxTIMaFA/7i/Tpd
Eu8fEuTP88LDbwKuYVo8Aid5s3y171uRCDDu+91Bwu0sIxzAXH+Z/yXqHppmEGxX
HCYXGn/L/sEAKRsH9aIO8muE8wpOF5QhDW4NtWD5QFPxbkvLzvRBp5Q3jyvRjHXy
S1i4h8VujBNJXC5AsVjXgO/4Aq4q/j1HhOfhK6Njv0gwt54CnK+SVsniwro0fpZr
XGlAo9JY6X4WIJ5etRMzwrhwTG7BTz1ClrE51628qO4q9ymFLiEoakQhYevfTnRa
hrQtwtRCAjN86vPD3dT4cXtjZnYQ+9/DEHNy2CBieIVXAgMBAAE=
-----END PUBLIC KEY-----
',
];