Upload via GUI (364 Dateien)
This commit is contained in:
@@ -16,6 +16,8 @@ function app_platforms(): array
|
||||
'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'],
|
||||
'minecraft' => ['label' => 'Minecraft', 'ext' => 'jar', 'stored' => 'DeskHub-Minecraft.jar', 'download' => 'DeskHub-Minecraft.jar', 'magic' => 'PK', 'mime' => 'application/java-archive'],
|
||||
'wordpress' => ['label' => 'WordPress', 'ext' => 'zip', 'stored' => 'deskhub-tickets-wordpress.zip', 'download' => 'deskhub-tickets-wordpress.zip', 'magic' => 'PK', 'mime' => 'application/zip'],
|
||||
'chrome' => ['label' => 'Chrome', 'ext' => 'zip', 'stored' => 'deskhub-chrome-erweiterung.zip', 'download' => 'deskhub-chrome-erweiterung.zip', 'magic' => 'PK', 'mime' => 'application/zip'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,7 +29,7 @@ function app_downloads_dir(): string
|
||||
/** Official publisher release endpoint; the access key stays on the server. */
|
||||
function app_publisher_endpoint(string $platform, bool $download = false): string
|
||||
{
|
||||
if (!in_array($platform, ['android', 'windows', 'minecraft'], true) || !function_exists('catalog_defaults')) return '';
|
||||
if (!isset(app_platforms()[$platform]) || !function_exists('catalog_defaults')) return '';
|
||||
$base = (string) (catalog_defaults()['plugins_url'] ?? '');
|
||||
$parts = parse_url($base);
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
|
||||
@@ -332,7 +332,7 @@ function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldS
|
||||
}
|
||||
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
|
||||
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$ticketId]);
|
||||
$pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$ticketId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1128,7 +1128,7 @@ function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?stri
|
||||
* frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */
|
||||
function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
|
||||
{
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
|
||||
->execute([$staffId, db_now(), $ticketId]);
|
||||
}
|
||||
|
||||
@@ -1166,7 +1166,7 @@ function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
|
||||
* frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */
|
||||
function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
|
||||
{
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
|
||||
->execute([$staffId, db_now(), $ticketId]);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,138 @@ function api_key_verify(PDO $pdo, string $key, string $ip): ?array
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
|
||||
// ── Kundenformular für Fremdsysteme (eigene Felder, Formular der Abteilung) ──────────────────
|
||||
|
||||
/**
|
||||
* Das Kundenformular für ein Hilfethema oder eine Abteilung, wie es ticket_new.php zeigt: Hat die Abteilung ein eigenes Formular,
|
||||
* gelten dessen Texte und Felder; sonst die allgemeinen Felder und die des Hilfethemas.
|
||||
*
|
||||
* @return array{topicId:?int,departmentId:?int,own:?array,fields:array}
|
||||
* @throws AdminModelException wenn das Hilfethema nicht existiert
|
||||
*/
|
||||
function public_form_definition(PDO $pdo, ?int $topicId, ?int $deptId): array
|
||||
{
|
||||
if ($topicId !== null) {
|
||||
$t = $pdo->prepare('SELECT * FROM help_topics WHERE id = ? AND is_active = 1');
|
||||
$t->execute([$topicId]);
|
||||
$topic = $t->fetch();
|
||||
if (!$topic) throw new AdminModelException('Das Hilfethema existiert nicht oder ist deaktiviert.', 'INVALID_TOPIC');
|
||||
$deptId = $deptId ?? (int) $topic['department_id'];
|
||||
}
|
||||
$own = $deptId ? department_form($pdo, $deptId) : null;
|
||||
if ($own) {
|
||||
$fields = $own['fields'];
|
||||
} else {
|
||||
$all = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll();
|
||||
$fields = array_values(array_filter($all, fn(array $f) => $f['help_topic_id'] === null || ($topicId !== null && (int) $f['help_topic_id'] === $topicId)));
|
||||
}
|
||||
return ['topicId' => $topicId, 'departmentId' => $deptId, 'own' => $own, 'fields' => $fields];
|
||||
}
|
||||
|
||||
/** Die Felder eines Formulars für die API-Antwort. Hinweistexte kommen auch als sicheres HTML (Links mit absoluter Adresse). */
|
||||
function public_form_fields_payload(array $fields): array
|
||||
{
|
||||
$base = base_url();
|
||||
$out = [];
|
||||
foreach ($fields as $f) {
|
||||
$type = in_array($f['field_type'], ['text', 'textarea', 'select', 'radio', 'checkbox', 'number', 'date'], true) ? $f['field_type'] : 'text';
|
||||
$options = [];
|
||||
if (in_array($type, ['select', 'radio'], true)) {
|
||||
foreach (preg_split('/\r?\n/', trim((string) ($f['options'] ?? ''))) ?: [] as $o) {
|
||||
$o = trim($o);
|
||||
if ($o !== '') $options[] = $o;
|
||||
}
|
||||
}
|
||||
$help = (string) ($f['help_text'] ?? '');
|
||||
$html = $help !== '' ? str_replace('href="/', 'href="' . $base . '/', form_help_html($help, (string) $f['label'])) : '';
|
||||
$out[] = [
|
||||
'id' => (int) $f['id'],
|
||||
'label' => (string) $f['label'],
|
||||
'type' => $type,
|
||||
'required' => !empty($f['is_required']),
|
||||
'options' => $options,
|
||||
'help' => $help,
|
||||
'helpHtml' => $html,
|
||||
'width' => in_array($f['form_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $f['form_width'] : 'full',
|
||||
'section' => (string) ($f['_section'] ?? 'extra'),
|
||||
'sectionTitle' => (string) ($f['_section_title'] ?? ''),
|
||||
'radioInline' => !empty($f['radio_inline']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Das ganze Formular für die API-Antwort (Texte und Felder). */
|
||||
function public_form_payload(array $def): array
|
||||
{
|
||||
$o = $def['own'];
|
||||
return [
|
||||
'helpTopicId' => $def['topicId'],
|
||||
'departmentId' => $def['departmentId'],
|
||||
'ownForm' => $o !== null,
|
||||
'title' => (string) ($o['title'] ?? ''),
|
||||
'intro' => (string) ($o['intro'] ?? ''),
|
||||
'subject' => ['show' => $o ? (bool) $o['subject_show'] : true, 'label' => (string) ($o['subject_label'] ?? 'Betreff')],
|
||||
'message' => ['show' => $o ? (bool) ($o['message_show'] ?? true) : true, 'label' => (string) ($o['message_label'] ?? 'Beschreibung'),
|
||||
'hint' => (string) ($o['message_hint'] ?? 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…')],
|
||||
'submitLabel' => (string) ($o['submit_label'] ?? 'Ticket erstellen'),
|
||||
'fields' => public_form_fields_payload($def['fields']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Wert eines Feldes aus der Anfrage, in der Form, wie ticket_new.php ihn speichert (Checkbox: "1" oder "0"). */
|
||||
function public_form_field_value(array $f, array $input): string
|
||||
{
|
||||
$raw = $input[(int) $f['id']] ?? $input[(string) $f['id']] ?? null;
|
||||
if ($f['field_type'] === 'checkbox') {
|
||||
$norm = is_string($raw) ? strtolower(trim($raw)) : $raw;
|
||||
return in_array($norm, [true, 1, '1', 'on', 'yes', 'ja', 'true'], true) ? '1' : '0';
|
||||
}
|
||||
return is_scalar($raw) ? trim((string) $raw) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die gelieferten Feldwerte gegen das Formular (Pflichtfelder, Auswahlwerte, Zahl, Datum) wie ticket_new.php.
|
||||
*
|
||||
* @return array<int,string> Feld-ID => Wert
|
||||
* @throws AdminModelException mit Code INVALID_FIELD (die Meldung ist für den Besucher gedacht)
|
||||
*/
|
||||
function public_form_validate_fields(array $fields, array $input): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ($fields as $f) {
|
||||
$val = public_form_field_value($f, $input);
|
||||
$label = (string) $f['label'];
|
||||
$type = $f['field_type'];
|
||||
if (mb_strlen($val) > 10000) throw new AdminModelException('Die Angabe bei „' . $label . '“ ist zu lang.', 'INVALID_FIELD');
|
||||
if (!empty($f['is_required']) && $type !== 'checkbox' && $val === '') throw new AdminModelException('Bitte füllen Sie das Feld „' . $label . '“ aus.', 'INVALID_FIELD');
|
||||
if (!empty($f['is_required']) && $type === 'checkbox' && $val !== '1') throw new AdminModelException('Bitte bestätigen Sie „' . $label . '“.', 'INVALID_FIELD');
|
||||
if (in_array($type, ['select', 'radio'], true) && $val !== '' && !in_array($val, array_map('trim', preg_split('/\r?\n/', (string) $f['options']) ?: []), true)) {
|
||||
throw new AdminModelException('Bitte wählen Sie bei „' . $label . '“ eine der angebotenen Möglichkeiten.', 'INVALID_FIELD');
|
||||
}
|
||||
if ($type === 'number' && $val !== '' && !is_numeric($val)) throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ eine Zahl an.', 'INVALID_FIELD');
|
||||
if ($type === 'date' && $val !== '' && !(preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $val, $d) && checkdate((int) $d[2], (int) $d[3], (int) $d[1]))) {
|
||||
throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ ein gültiges Datum an (JJJJ-MM-TT).', 'INVALID_FIELD');
|
||||
}
|
||||
$values[(int) $f['id']] = $val;
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist der Schlüssel gültig, wird aber nur wegen der IP-Beschränkung abgelehnt? So kann die API dem Aufrufer sagen, welche Adresse
|
||||
* DeskHub sieht (hinter einem Proxy oder Cloudflare ist das oft nicht die erwartete). Verrät nichts, was der Aufrufer mit dem Schlüssel nicht ohnehin weiß.
|
||||
*/
|
||||
function api_key_ip_blocked(PDO $pdo, string $key, string $ip): bool
|
||||
{
|
||||
if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return false;
|
||||
$stmt = $pdo->prepare('SELECT ip_whitelist FROM api_keys WHERE key_hash = ? AND is_active = 1');
|
||||
$stmt->execute([hash('sha256', $key)]);
|
||||
$row = $stmt->fetch();
|
||||
return $row && !empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true);
|
||||
}
|
||||
|
||||
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
|
||||
/**
|
||||
* Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an,
|
||||
* benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus.
|
||||
@@ -64,11 +194,33 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$subject = trim((string) ($in['subject'] ?? ''));
|
||||
$message = trim((string) ($in['message'] ?? ''));
|
||||
// Mit "fields" liefert der Aufrufer die Angaben eines Kundenformulars (eigene Felder, Formular der Abteilung). Ohne "fields" bleibt alles wie bisher.
|
||||
$formDef = null;
|
||||
$fieldInput = [];
|
||||
if (isset($in['fields']) && is_array($in['fields'])) {
|
||||
$fieldInput = $in['fields'];
|
||||
$preTopic = am_int_or_null($in, 'helpTopicId');
|
||||
$preDept = am_int_or_null($in, 'departmentId');
|
||||
$formDef = public_form_definition($pdo, $preTopic, $preTopic === null ? ($preDept ?: (int) get_setting('default_department_id', '1')) : $preDept);
|
||||
$own = $formDef['own'];
|
||||
if ($own && !$own['subject_show'] && $subject === '') $subject = 'Anfrage ' . $own['department_name'];
|
||||
if ($own && empty($own['message_show']) && $message === '') {
|
||||
// Ohne Beschreibungsfeld besteht der Ticket-Text aus den Angaben der eigenen Felder
|
||||
$lines = [];
|
||||
foreach ($formDef['fields'] as $ff) {
|
||||
$v = public_form_field_value($ff, $fieldInput);
|
||||
if ($ff['field_type'] === 'checkbox') $v = $v === '1' ? 'Ja' : 'Nein';
|
||||
if ($v !== '') $lines[] = $ff['label'] . ': ' . $v;
|
||||
}
|
||||
$message = $lines ? implode("\n", $lines) : 'Anfrage ohne weitere Angaben';
|
||||
}
|
||||
}
|
||||
if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
|
||||
if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT');
|
||||
if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE');
|
||||
if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403);
|
||||
$fieldValues = $formDef ? public_form_validate_fields($formDef['fields'], $fieldInput) : [];
|
||||
|
||||
$topicId = am_int_or_null($in, 'helpTopicId');
|
||||
$deptId = am_int_or_null($in, 'departmentId');
|
||||
@@ -110,6 +262,12 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts
|
||||
$ticketId = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
|
||||
->execute([$ticketId, 'customer', $message, $now]);
|
||||
if ($fieldValues) {
|
||||
$cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)');
|
||||
foreach ($fieldValues as $fieldId => $val) {
|
||||
if ($val !== '') $cfStmt->execute([$ticketId, $fieldId, $val]);
|
||||
}
|
||||
}
|
||||
log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt'));
|
||||
if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']);
|
||||
|
||||
|
||||
@@ -197,6 +197,8 @@ function login_guard_locked(string $email, string $channel): void
|
||||
if (!login_guard_enabled()) return;
|
||||
try {
|
||||
$pdo = get_db();
|
||||
// Steuerzeichen und Zeilenumbrüche raus (die Adresse steht im Betreff), Länge begrenzen
|
||||
$email = mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $email)), 0, 120);
|
||||
$cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?");
|
||||
$st->execute([$email, $cut]);
|
||||
@@ -204,13 +206,14 @@ function login_guard_locked(string $email, string $channel): void
|
||||
$st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||
$st->execute([$email]);
|
||||
$staff = $st->fetch() ?: null;
|
||||
login_guard_record($pdo, 'locked', $staff ? (int) $staff['id'] : null, $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
|
||||
if (!$staff) return; // unbekannte Adresse: kein Eintrag und keine Mail (sonst lässt sich der Posteingang der Administratoren füllen); die Fehlversuche stehen schon im Protokoll
|
||||
login_guard_record($pdo, 'locked', (int) $staff['id'], $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
|
||||
$ip = client_ip();
|
||||
$msg = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Anmeldung gesperrt</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">Für das Konto <strong>' . e($email) . '</strong> gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.</p>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.</p>';
|
||||
if ($staff) send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
|
||||
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, $staff ? (string) $staff['email'] : null);
|
||||
send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
|
||||
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, (string) $staff['email']);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_locked: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function notify_new_reply_channels(array $ticket, string $excerpt): void
|
||||
// Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon
|
||||
$pdo = get_db();
|
||||
ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)');
|
||||
$pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL WHERE id = ?')->execute([$ticket['id']]);
|
||||
$pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticket['id']]);
|
||||
ticket_followers_notify($ticket, 'Neue Kundenantwort');
|
||||
} catch (Throwable $e) {
|
||||
// Datenbank noch nicht aktualisiert: nicht schlimm
|
||||
|
||||
@@ -106,9 +106,12 @@ function ticket_problem_link(PDO $pdo, int $problemId, int $ticketId, int $staff
|
||||
return true;
|
||||
}
|
||||
|
||||
function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): void
|
||||
/** Löst die Verknüpfung. Liefert false, wenn es sie nicht gab. */
|
||||
function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): bool
|
||||
{
|
||||
$pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?')->execute([$problemId, $ticketId]);
|
||||
$stmt = $pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?');
|
||||
$stmt->execute([$problemId, $ticketId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
function ticket_problems_for_ticket(PDO $pdo, int $ticketId): array
|
||||
|
||||
@@ -42,6 +42,16 @@ function status_app_connection(PDO $pdo, string $platform): array
|
||||
return ['online' => $lastSeen !== null && strtotime($lastSeen) >= strtotime($cutoff), 'last_seen' => $lastSeen, 'timeout_minutes' => $timeoutMinutes];
|
||||
}
|
||||
|
||||
/** Prüfadresse der Plugin-Webseite (ihr health.php, das ohne Zugangsschlüssel antwortet); leer, wenn kein offizieller Katalog eingetragen ist. */
|
||||
function status_plugin_site_url(): string
|
||||
{
|
||||
if (!function_exists('catalog_defaults')) return '';
|
||||
$parts = parse_url((string) (catalog_defaults()['plugins_url'] ?? ''));
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
$path = rtrim(str_replace(chr(92), '/', dirname((string) ($parts['path'] ?? '/'))), '/');
|
||||
return 'https://' . $parts['host'] . (!empty($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/health.php';
|
||||
}
|
||||
|
||||
function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
{
|
||||
$type = (string) ($component['monitor_type'] ?? 'auto');
|
||||
@@ -55,6 +65,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
|
||||
return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
|
||||
}
|
||||
if (str_contains($name, 'plugin') && (str_contains($name, 'webseite') || str_contains($name, 'website') || str_contains($name, 'seite') || str_contains($name, 'katalog'))) {
|
||||
$url = status_plugin_site_url();
|
||||
return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null;
|
||||
}
|
||||
if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
|
||||
if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
|
||||
if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
|
||||
@@ -64,6 +78,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'];
|
||||
return null;
|
||||
}
|
||||
if ($type === 'plugin_site') {
|
||||
$url = status_plugin_site_url();
|
||||
return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null;
|
||||
}
|
||||
if ($type === 'database') return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
|
||||
if ($type === 'android_app') return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
|
||||
if ($type === 'windows_app') return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
|
||||
@@ -129,7 +147,7 @@ function status_execute_component_check(PDO $pdo, array $check): array
|
||||
$ok = is_resource($socket);
|
||||
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
|
||||
} else $error = 'Ungültiges Prüfziel';
|
||||
} elseif ($check['type'] === 'http') {
|
||||
} elseif (in_array($check['type'], ['http', 'plugin_site'], true)) {
|
||||
$url = (string) $check['target'];
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) {
|
||||
if (function_exists('curl_init')) {
|
||||
@@ -210,9 +228,40 @@ function status_incident_states(): array
|
||||
];
|
||||
}
|
||||
|
||||
function status_components(PDO $pdo, bool $includeDisabled = false): array
|
||||
/** Gibt es die Spalte is_internal schon (Datenbank-Update eingespielt)? Bis dahin verhält sich alles wie bisher. */
|
||||
function status_has_internal(PDO $pdo): bool
|
||||
{
|
||||
$where = $includeDisabled ? '' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
|
||||
static $cache = [];
|
||||
$key = spl_object_id($pdo);
|
||||
if (isset($cache[$key])) return $cache[$key];
|
||||
try {
|
||||
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||||
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'is_internal') return $cache[$key] = true;
|
||||
} catch (Throwable $e) {
|
||||
// Tabelle fehlt: nichts zu filtern
|
||||
}
|
||||
return $cache[$key] = false;
|
||||
}
|
||||
|
||||
/** SQL-Zusatz: nur öffentliche (nicht interne) Dienste; $alias ist der Tabellenname von status_components. */
|
||||
function status_public_sql(PDO $pdo, string $alias = 'c'): string
|
||||
{
|
||||
return status_has_internal($pdo) ? " AND COALESCE({$alias}.is_internal, 0) = 0" : '';
|
||||
}
|
||||
|
||||
/** Nicht öffentlich sichtbar, aber geprüft: Dienst als „intern“ markieren oder die Markierung entfernen. */
|
||||
function status_set_component_internal(PDO $pdo, int $id, bool $internal): bool
|
||||
{
|
||||
if (!status_has_internal($pdo)) return false;
|
||||
$pdo->prepare('UPDATE status_components SET is_internal = ? WHERE id = ?')->execute([$internal ? 1 : 0, $id]);
|
||||
return (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
|
||||
}
|
||||
|
||||
/** @param bool $publicOnly nur Dienste, die auf der öffentlichen Statusseite stehen dürfen (nicht intern) */
|
||||
function status_components(PDO $pdo, bool $includeDisabled = false, bool $publicOnly = false): array
|
||||
{
|
||||
$where = $includeDisabled ? 'WHERE 1 = 1 ' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
|
||||
if ($publicOnly) $where .= status_public_sql($pdo) . ' ';
|
||||
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
|
||||
}
|
||||
|
||||
@@ -232,6 +281,7 @@ function status_monitor_types(): array
|
||||
'smtp' => 'SMTP-Server',
|
||||
'mail' => 'E-Mail (SMTP und IMAP)',
|
||||
'fcm' => 'Push-Dienst',
|
||||
'plugin_site' => 'Plugin-Webseite (offizieller Katalog)',
|
||||
'tcp' => 'Server und Port',
|
||||
'manual' => 'Manuell verwalten',
|
||||
];
|
||||
@@ -413,16 +463,16 @@ function status_overall(PDO $pdo): array
|
||||
{
|
||||
$states = status_component_states();
|
||||
$worst = 'operational';
|
||||
foreach (status_components($pdo) as $c) {
|
||||
foreach (status_components($pdo, false, true) as $c) {
|
||||
if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status'];
|
||||
}
|
||||
// Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“
|
||||
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)")->fetchColumn();
|
||||
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1" . status_public_sql($pdo) . "))")->fetchColumn();
|
||||
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
|
||||
$activeMaintenance = status_active_maintenance_map($pdo);
|
||||
$hasActiveMaintenance = isset($activeMaintenance[0]);
|
||||
if (!$hasActiveMaintenance) {
|
||||
foreach (status_components($pdo) as $component) {
|
||||
foreach (status_components($pdo, false, true) as $component) {
|
||||
if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; }
|
||||
}
|
||||
}
|
||||
@@ -434,7 +484,7 @@ function status_overall(PDO $pdo): array
|
||||
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array
|
||||
{
|
||||
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
|
||||
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
|
||||
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))';
|
||||
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
|
||||
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
@@ -455,7 +505,7 @@ function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): arr
|
||||
$since = date('Y-m-d H:i:s', time() - 60 * 86400);
|
||||
$where = $includeHistory
|
||||
? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)'
|
||||
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
|
||||
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))';
|
||||
$stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100");
|
||||
$stmt->execute($includeHistory ? [$since, $since] : [$now]);
|
||||
return $stmt->fetchAll();
|
||||
@@ -629,6 +679,7 @@ function status_restore_core_components(PDO $pdo): int
|
||||
['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']],
|
||||
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
|
||||
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
|
||||
['Plugin-Webseite', 'Server & Datenbank', 'plugin_site', ['Plugin-Website', 'Pluginseite', 'Plugin Webseite']],
|
||||
['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']],
|
||||
['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']],
|
||||
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
|
||||
@@ -650,6 +701,7 @@ function status_restore_core_components(PDO $pdo): int
|
||||
$maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn();
|
||||
$restored = 0;
|
||||
foreach ($defaults as [$name, $categoryName, $monitorType, $aliases]) {
|
||||
if ($monitorType === 'plugin_site' && status_plugin_site_url() === '') continue; // ohne offiziellen Katalog gibt es nichts zu prüfen
|
||||
$existing = null;
|
||||
foreach (array_merge([$name], $aliases) as $candidate) {
|
||||
$find->execute([$candidate]);
|
||||
@@ -667,6 +719,7 @@ function status_restore_core_components(PDO $pdo): int
|
||||
if ($id) {
|
||||
status_set_component_monitor($pdo, $id, $monitorType, '');
|
||||
if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false);
|
||||
if ($monitorType === 'plugin_site') status_set_component_internal($pdo, $id, true); // nur für Mitarbeiter sichtbar
|
||||
$maxOrder += 10;
|
||||
$restored++;
|
||||
}
|
||||
@@ -742,7 +795,7 @@ function status_summary(PDO $pdo): array
|
||||
'overall' => $overall,
|
||||
'componentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v[0]], array_keys($states), $states),
|
||||
'incidentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v], array_keys(status_incident_states()), status_incident_states()),
|
||||
'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status']], status_components($pdo)),
|
||||
'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status'], 'internal' => !empty($c['is_internal'])], status_components($pdo)),
|
||||
'incidents' => array_map(fn($i) => [
|
||||
'id' => (int) $i['id'], 'title' => (string) $i['title'], 'status' => (string) $i['status'], 'statusLabel' => status_incident_states()[$i['status']] ?? $i['status'],
|
||||
'component' => $i['component_name'] ?? null, 'componentId' => $i['component_id'] !== null ? (int) $i['component_id'] : null,
|
||||
|
||||
@@ -49,11 +49,21 @@ function vacation_hand_over(PDO $pdo, int $staffId): int
|
||||
$t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
|
||||
$t->execute([$staffId]);
|
||||
$tickets = $t->fetchAll();
|
||||
$own = !$pdo->inTransaction();
|
||||
if ($own) $pdo->beginTransaction(); // entweder alles (Zuweisung und Vermerk zur Rückgabe) oder nichts
|
||||
try {
|
||||
$move = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?');
|
||||
$note = $pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)');
|
||||
foreach ($tickets as $row) {
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$to, $row['id']]);
|
||||
$pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)')->execute([$row['id'], $staffId, $to, db_now()]);
|
||||
$move->execute([$to, $row['id']]);
|
||||
$note->execute([$row['id'], $staffId, $to, db_now()]);
|
||||
log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)');
|
||||
}
|
||||
if ($own) $pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($own && $pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
if ($tickets) {
|
||||
$list = '';
|
||||
foreach (array_slice($tickets, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
|
||||
@@ -113,9 +123,15 @@ function vacation_apply_change(PDO $pdo, int $staffId, array $before, array $aft
|
||||
function vacation_expand_ids(PDO $pdo, array $ids): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (array_unique(array_map('intval', $ids)) as $id) {
|
||||
if ($id <= 0) continue;
|
||||
$target = staff_is_on_vacation($id) ? vacation_target($pdo, $id) : $id;
|
||||
$ids = array_values(array_filter(array_unique(array_map('intval', $ids)), fn($id) => $id > 0));
|
||||
if (!$ids) return [];
|
||||
// Wer im Urlaub ist, in einer Abfrage ermitteln (statt eine je Mitarbeiter)
|
||||
$inIds = implode(',', array_fill(0, count($ids), '?'));
|
||||
$away = $pdo->prepare("SELECT id FROM staff WHERE is_on_vacation = 1 AND id IN ($inIds)");
|
||||
$away->execute($ids);
|
||||
$awayIds = array_flip(array_map('intval', $away->fetchAll(PDO::FETCH_COLUMN)));
|
||||
foreach ($ids as $id) {
|
||||
$target = isset($awayIds[$id]) ? vacation_target($pdo, $id) : $id;
|
||||
if ($target !== null) $out[$target] = $target;
|
||||
}
|
||||
if (!$out) return [];
|
||||
|
||||
Reference in New Issue
Block a user