Files
deskhub/wordpress-plugin/deskhub-tickets/includes/class-form.php
T
2026-11-08 20:09:19 +00:00

917 lines
50 KiB
PHP

<?php
/**
* Shortcodes [deskhub_ticket] (Ticket anlegen) und [deskhub_status] (Stand abfragen) samt Verarbeitung.
*
* Das Formular funktioniert ohne JavaScript und mit Seiten-Cache: Es nutzt keine WordPress-Nonce (die würde in einer
* zwischengespeicherten Seite ablaufen), sondern einen unterschriebenen Zeitstempel, ein verstecktes Falle-Feld,
* eine Mengenbegrenzung je Besucher und optional Cloudflare Turnstile.
*/
if (!defined('ABSPATH')) {
exit;
}
class Deskhub_Tickets_Form
{
const MAX_MESSAGE = 10000;
public static function init(): void
{
add_shortcode('deskhub_ticket', [__CLASS__, 'render_ticket_form']);
add_shortcode('deskhub_status', [__CLASS__, 'render_status_form']);
add_action('admin_post_nopriv_deskhub_ticket_submit', [__CLASS__, 'handle_submit']);
add_action('admin_post_deskhub_ticket_submit', [__CLASS__, 'handle_submit']);
add_action('wp_enqueue_scripts', [__CLASS__, 'register_assets']);
}
public static function register_assets(): void
{
wp_register_style('deskhub-tickets', DESKHUB_TICKETS_ASSETS . 'assets/form.css', [], DESKHUB_TICKETS_VERSION);
}
private static function enqueue(): void
{
wp_enqueue_style('deskhub-tickets');
wp_enqueue_script('deskhub-tickets-theme', DESKHUB_TICKETS_ASSETS . 'assets/theme.js', [], DESKHUB_TICKETS_VERSION, true);
}
/** Knopffarbe aus den Einstellungen des aktiven Themes (theme.json / Styles-Editor), als #hex; leer, wenn nichts gefunden wird. */
private static function theme_button_color(): string
{
if (!function_exists('wp_get_global_styles') || !function_exists('wp_get_global_settings')) {
return '';
}
$v = wp_get_global_styles(['elements', 'button', 'color', 'background']);
if (!is_string($v) || $v === '') {
$v = wp_get_global_styles(['elements', 'link', 'color', 'text']);
}
if (!is_string($v)) {
return '';
}
$slug = '';
if (preg_match('/^var\(--wp--preset--color--([a-z0-9-]+)\)$/', $v, $m) || preg_match('/^var:preset\|color\|([a-z0-9-]+)$/', $v, $m)) {
$slug = $m[1];
} elseif (sanitize_hex_color($v)) {
return $v;
} else {
return '';
}
$palette = wp_get_global_settings(['color', 'palette']);
foreach ((array) $palette as $list) {
foreach ((array) $list as $entry) {
if (is_array($entry) && ($entry['slug'] ?? '') === $slug && sanitize_hex_color((string) ($entry['color'] ?? ''))) {
return (string) $entry['color'];
}
}
}
return '';
}
/** Öffnendes Tag der Formular-Hülle; eine feste Akzentfarbe aus den Einstellungen setzt die Variable (sonst liest theme.js sie vom Theme). */
private static function wrap_open(string $id): string
{
$accent = sanitize_hex_color((string) (deskhub_tickets_options()['accent_color'] ?? ''));
$attr = $accent ? ' data-dht-accent="fixed" style="--dht-theme-accent:' . esc_attr($accent) . '"' : '';
$server = $accent ? '' : self::theme_button_color();
if ($server !== '') {
$attr = ' style="--dht-server-accent:' . esc_attr($server) . '"';
}
return '<div class="dht-wrap" id="' . esc_attr($id) . '"' . $attr . '>';
}
// ───────────────────────────── Ticket anlegen ─────────────────────────────
/** Plätze im Formular, die DeskHub für eigene Felder kennt (alles andere kommt als „Zusätzliche Angaben“ ans Ende). */
const SLOTS = ['contact_top', 'contact', 'topic_top', 'topic', 'issue_top', 'issue_mid', 'issue'];
/**
* @param array|string $atts
*/
public static function render_ticket_form($atts = []): string
{
$atts = shortcode_atts([
'department' => '',
'topic' => '',
'priority' => '',
'topics' => '',
'title' => '',
], is_array($atts) ? $atts : [], 'deskhub_ticket');
self::enqueue();
$client = Deskhub_Tickets_Client::from_settings();
if (!$client) {
return current_user_can('manage_options')
? '<p class="dht-notice dht-error">' . esc_html__('DeskHub Tickets ist noch nicht eingerichtet: Bitte unter Einstellungen → DeskHub Tickets Server-Adresse und API-Schlüssel eintragen. (Diese Meldung sehen nur Administratoren.)', 'deskhub-tickets') . '</p>'
: '';
}
$opts = deskhub_tickets_options();
$topics = self::parse_topics((string) $atts['topics']);
$cfg = self::form_config($atts, $opts, $topics);
$cfgB64 = base64_encode((string) wp_json_encode($cfg));
$state = self::take_state();
$values = ['name' => '', 'email' => '', 'subject' => '', 'message' => '', 'topic' => 0, 'fields' => []];
if (is_user_logged_in()) {
$u = wp_get_current_user();
$values['name'] = (string) $u->display_name;
$values['email'] = (string) $u->user_email;
}
if ($state && !empty($state['values']) && is_array($state['values'])) {
$values = array_merge($values, array_intersect_key($state['values'], $values));
}
ob_start();
echo self::wrap_open('deskhub-ticket');
if ($state && !empty($state['ok'])) {
$text = trim((string) $opts['success_text']) !== '' ? (string) $opts['success_text'] : __('Vielen Dank! Wir melden uns so schnell wie möglich per E-Mail.', 'deskhub-tickets');
echo '<div class="dht-notice dht-success" role="status"><p>' . esc_html($text) . '</p>';
if (!empty($state['number'])) {
echo '<p>' . sprintf(esc_html__('Ihre Ticketnummer: %s', 'deskhub-tickets'), '<strong>' . esc_html((string) $state['number']) . '</strong>') . '</p>';
}
if ($opts['status_page_url'] !== '') {
echo '<p><a href="' . esc_url((string) $opts['status_page_url']) . '">' . esc_html__('Stand abfragen', 'deskhub-tickets') . '</a></p>';
}
echo '</div></div>';
return (string) ob_get_clean();
}
// Kundenformulare aus DeskHub: eines je Thema der Auswahl, sonst eines für das Thema/die Abteilung. Ohne Antwort: einfaches Formular.
$forms = [];
$unsupported = false;
$topicIds = $topics ? array_map('intval', array_column($topics, 'id')) : [(int) $cfg['t']];
foreach ($topicIds as $tid) {
$f = $client->get_form($tid ?: null, (int) $cfg['d'] ?: null);
if (is_wp_error($f)) {
$forms = [];
$unsupported = $f->get_error_code() === 'deskhub_form_unsupported';
break;
}
$forms[$tid] = self::normalize_form($f);
}
if (!$forms) {
$forms = [$topicIds[0] ?? 0 => self::normalize_form(null)];
}
$multi = count($topics) > 0;
$selected = $multi ? (int) $values['topic'] : (int) array_key_first($forms);
$active = $forms[$selected] ?? reset($forms);
$texts = [];
foreach ($forms as $tid => $fm) {
$texts[$tid] = ['title' => $fm['title'], 'intro' => $fm['intro'], 'subjectShow' => $fm['subject']['show'], 'subjectLabel' => $fm['subject']['label'],
'messageShow' => $fm['message']['show'], 'messageLabel' => $fm['message']['label'], 'messageHint' => $fm['message']['hint'], 'submitLabel' => $fm['submitLabel'], 'attachments' => $fm['attachments']['enabled']];
}
if ($unsupported && current_user_can('manage_options')) {
echo '<p class="dht-notice dht-error">' . esc_html__('Der DeskHub-Server kennt die Formular-Schnittstelle noch nicht (bitte DeskHub aktualisieren). Bis dahin wird das einfache Formular ohne eigene Felder gezeigt. (Diese Meldung sehen nur Administratoren.)', 'deskhub-tickets') . '</p>';
}
$title = $atts['title'] !== '' ? (string) $atts['title'] : (string) $active['title'];
echo '<h3 class="dht-title dht-form-title"' . ($atts['title'] !== '' ? ' data-fixed="1"' : '') . ($title === '' ? ' hidden' : '') . '>' . esc_html($title) . '</h3>';
echo '<p class="dht-intro dht-form-intro"' . ($active['intro'] === '' ? ' hidden' : '') . '>' . esc_html((string) $active['intro']) . '</p>';
if ($state && !empty($state['errors'])) {
echo '<div class="dht-notice dht-error" role="alert"><ul>';
foreach ((array) $state['errors'] as $err) {
echo '<li>' . esc_html((string) $err) . '</li>';
}
echo '</ul></div>';
}
$showSubject = (bool) $active['subject']['show'];
$showMessage = (bool) $active['message']['show'];
?>
<form method="post" enctype="multipart/form-data" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="dht-form" data-dht-forms="<?php echo esc_attr((string) wp_json_encode($texts)); ?>">
<input type="hidden" name="action" value="deskhub_ticket_submit">
<input type="hidden" name="dht_cfg" value="<?php echo esc_attr($cfgB64); ?>">
<input type="hidden" name="dht_sig" value="<?php echo esc_attr(deskhub_tickets_sign('cfg|' . $cfgB64)); ?>">
<input type="hidden" name="dht_ts" value="<?php echo esc_attr(deskhub_tickets_timestamp_token()); ?>">
<input type="hidden" name="dht_back" value="<?php echo esc_attr(self::current_url()); ?>">
<div class="dht-trap" aria-hidden="true">
<label>Website <input type="text" name="dht_website" value="" tabindex="-1" autocomplete="off"></label>
</div>
<?php echo self::render_slot($forms, ['contact_top'], $multi, $selected, $values['fields']); ?>
<p>
<label for="dht-name"><?php echo esc_html__('Ihr Name', 'deskhub-tickets'); ?> *</label>
<input type="text" id="dht-name" name="dht_name" value="<?php echo esc_attr((string) $values['name']); ?>" maxlength="120" required autocomplete="name">
</p>
<p>
<label for="dht-email"><?php echo esc_html__('Ihre E-Mail-Adresse', 'deskhub-tickets'); ?> *</label>
<input type="email" id="dht-email" name="dht_email" value="<?php echo esc_attr((string) $values['email']); ?>" maxlength="190" required autocomplete="email">
</p>
<?php echo self::render_slot($forms, ['contact'], $multi, $selected, $values['fields']); ?>
<?php echo self::render_slot($forms, ['topic_top'], $multi, $selected, $values['fields']); ?>
<?php if ($topics): ?>
<p>
<label for="dht-topic"><?php echo esc_html__('Worum geht es?', 'deskhub-tickets'); ?> *</label>
<select id="dht-topic" name="dht_topic" required>
<option value=""><?php echo esc_html__('Bitte wählen …', 'deskhub-tickets'); ?></option>
<?php foreach ($topics as $tp): ?>
<option value="<?php echo (int) $tp['id']; ?>" <?php selected((int) $values['topic'], (int) $tp['id']); ?>><?php echo esc_html($tp['label']); ?></option>
<?php endforeach; ?>
</select>
</p>
<?php endif; ?>
<?php echo self::render_slot($forms, ['topic'], $multi, $selected, $values['fields']); ?>
<?php echo self::render_slot($forms, ['issue_top'], $multi, $selected, $values['fields']); ?>
<p class="dht-subject-wrap"<?php echo $showSubject ? '' : ' hidden'; ?>>
<label for="dht-subject"><span class="dht-subject-label"><?php echo esc_html((string) $active['subject']['label']); ?></span> *</label>
<input type="text" id="dht-subject" name="dht_subject" value="<?php echo esc_attr((string) $values['subject']); ?>" maxlength="255" <?php echo $showSubject ? 'required' : 'disabled'; ?>>
</p>
<?php echo self::render_slot($forms, ['issue_mid'], $multi, $selected, $values['fields']); ?>
<p class="dht-message-wrap"<?php echo $showMessage ? '' : ' hidden'; ?>>
<label for="dht-message"><span class="dht-message-label"><?php echo esc_html((string) $active['message']['label']); ?></span> *</label>
<textarea id="dht-message" name="dht_message" rows="7" maxlength="<?php echo (int) self::MAX_MESSAGE; ?>" placeholder="<?php echo esc_attr((string) $active['message']['hint']); ?>" <?php echo $showMessage ? 'required' : 'disabled'; ?>><?php echo esc_textarea((string) $values['message']); ?></textarea>
</p>
<?php echo self::render_slot($forms, ['issue'], $multi, $selected, $values['fields']); ?>
<?php echo self::render_slot($forms, [], $multi, $selected, $values['fields'], true); ?>
<?php
// Dateien anhängen: ein Feld für das ganze Formular; form.js blendet es aus, wenn das gewählte Thema keine Anhänge erlaubt
$attPolicy = null;
foreach ($forms as $fm) {
if (!empty($fm['attachments']['enabled'])) {
$attPolicy = $fm['attachments'];
break;
}
}
if ($attPolicy !== null):
$attOn = !empty($active['attachments']['enabled']);
?>
<p class="dht-attach"<?php echo $attOn ? '' : ' hidden'; ?>>
<label for="dht-files"><?php echo esc_html__('Dateien anhängen (optional)', 'deskhub-tickets'); ?></label>
<input type="file" id="dht-files" name="dht_files[]" multiple accept="<?php echo esc_attr('.' . implode(',.', $attPolicy['allowed'])); ?>" <?php echo $attOn ? '' : 'disabled'; ?>>
<span class="dht-help"><?php echo esc_html(sprintf(
/* translators: 1: Anzahl Dateien, 2: Größe je Datei, 3: Größe insgesamt, 4: Dateitypen */
__('Bis zu %1$d Dateien, je höchstens %2$s, zusammen höchstens %3$s. Erlaubt: %4$s', 'deskhub-tickets'),
$attPolicy['maxFiles'],
self::format_bytes($attPolicy['maxFileBytes']),
self::format_bytes($attPolicy['maxTotalBytes']),
implode(', ', $attPolicy['allowed'])
)); ?></span>
</p>
<?php endif; ?>
<?php if (trim((string) $opts['consent_text']) !== ''): ?>
<p class="dht-consent">
<label><input type="checkbox" name="dht_consent" value="1" required> <span><?php echo wp_kses((string) $opts['consent_text'], ['a' => ['href' => [], 'target' => [], 'rel' => []], 'strong' => [], 'em' => []]); ?></span></label>
</p>
<?php endif; ?>
<?php echo self::turnstile_widget(); ?>
<p><button type="submit" class="dht-button dht-submit"><?php echo esc_html((string) $active['submitLabel']); ?></button></p>
</form>
<?php
wp_enqueue_script('deskhub-tickets', DESKHUB_TICKETS_ASSETS . 'assets/form.js', [], DESKHUB_TICKETS_VERSION, true);
echo '</div>';
return (string) ob_get_clean();
}
/** Abteilung, Thema, Priorität und erlaubte Themen dieses Formulars (reisen unterschrieben im Formular). */
private static function form_config(array $atts, array $opts, array $topics): array
{
$hasTopic = $topics || $atts['topic'] !== '' || (int) $opts['topic_id'] > 0;
// Mit Thema bestimmt DeskHub die Abteilung (Thema und Abteilungsformular gehören zusammen), außer der Shortcode nennt eine ausdrücklich
$dept = $atts['department'] !== '' ? absint($atts['department']) : ($hasTopic ? 0 : (int) $opts['department_id']);
return [
'd' => $dept,
't' => $atts['topic'] !== '' ? absint($atts['topic']) : (int) $opts['topic_id'],
'p' => $atts['priority'] !== '' ? absint($atts['priority']) : (int) $opts['priority_id'],
'o' => array_values(array_map('intval', array_column($topics, 'id'))),
];
}
/**
* Angebot für Dateianhänge aus dem Formular von DeskHub. Ohne Angabe (ältere DeskHub-Version) oder wenn die Option des Plugins
* ausgeschaltet ist, gibt es keine Anhänge.
*
* @param mixed $a
* @return array{enabled:bool,maxFiles:int,maxFileBytes:int,maxTotalBytes:int,allowed:string[]}
*/
public static function normalize_attachments($a): array
{
$off = ['enabled' => false, 'maxFiles' => 0, 'maxFileBytes' => 0, 'maxTotalBytes' => 0, 'allowed' => []];
$opts = deskhub_tickets_options();
if (!is_array($a) || empty($a['enabled']) || empty($opts['attachments'])) {
return $off;
}
$allowed = [];
foreach ((array) ($a['allowedExtensions'] ?? []) as $e) {
$e = strtolower(ltrim(trim((string) $e), '.'));
if ($e !== '' && preg_match('/^[a-z0-9]{1,8}$/', $e)) {
$allowed[] = $e;
}
}
$files = max(0, min(10, (int) ($a['maxFiles'] ?? 0)));
$perFile = max(0, (int) ($a['maxFileBytes'] ?? 0));
$total = max(0, (int) ($a['maxTotalBytes'] ?? 0));
if ($files < 1 || $perFile < 1 || $total < 1 || !$allowed) {
return $off;
}
return ['enabled' => true, 'maxFiles' => $files, 'maxFileBytes' => $perFile, 'maxTotalBytes' => $total, 'allowed' => $allowed];
}
/** Menschenlesbare Größe ("5 MB"). */
private static function format_bytes(int $b): string
{
return $b >= 1048576 ? rtrim(rtrim(number_format($b / 1048576, 1, ',', ''), '0'), ',') . ' MB' : max(1, (int) round($b / 1024)) . ' KB';
}
/**
* Prüft die hochgeladenen Dateien gegen das Angebot und gibt sie fertig für die Schnittstelle zurück.
*
* @param array<string,mixed> $files $_FILES['dht_files']
* @return array{0:string[],1:array<int,array{name:string,data:string}>} [Fehler, Anhänge]
*/
public static function collect_attachments(array $files, array $policy): array
{
$names = isset($files['name']) ? (array) $files['name'] : [];
$errors = [];
$out = [];
$total = 0;
$dangerous = ['php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'exe', 'bat', 'cmd', 'sh', 'js', 'html', 'htm', 'svg'];
foreach ($names as $i => $rawName) {
$err = (int) ($files['error'][$i] ?? UPLOAD_ERR_NO_FILE);
if ($err === UPLOAD_ERR_NO_FILE) {
continue; // nichts ausgewählt
}
$name = sanitize_file_name(wp_unslash((string) $rawName));
if (!$policy['enabled']) {
return [[__('Bei diesem Formular können keine Dateien angehängt werden.', 'deskhub-tickets')], []];
}
if (count($out) >= $policy['maxFiles']) {
$errors[] = sprintf(__('Bitte hängen Sie höchstens %d Dateien an.', 'deskhub-tickets'), $policy['maxFiles']);
break;
}
if ($err !== UPLOAD_ERR_OK) {
$errors[] = ($err === UPLOAD_ERR_INI_SIZE || $err === UPLOAD_ERR_FORM_SIZE)
? sprintf(__('Die Datei „%s“ ist zu groß (höchstens %s).', 'deskhub-tickets'), $name, self::format_bytes($policy['maxFileBytes']))
: sprintf(__('Die Datei „%s“ konnte nicht hochgeladen werden.', 'deskhub-tickets'), $name);
continue;
}
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($name === '' || $ext === '' || in_array($ext, $dangerous, true) || !in_array($ext, $policy['allowed'], true)) {
$errors[] = sprintf(__('Der Dateityp von „%1$s“ ist nicht erlaubt. Erlaubt: %2$s', 'deskhub-tickets'), $name, implode(', ', $policy['allowed']));
continue;
}
$size = (int) ($files['size'][$i] ?? 0);
$tmp = (string) ($files['tmp_name'][$i] ?? '');
if ($size < 1 || $tmp === '' || !(is_uploaded_file($tmp) || (defined('DESKHUB_TICKETS_TESTS') && is_file($tmp)))) {
$errors[] = sprintf(__('Die Datei „%s“ konnte nicht gelesen werden.', 'deskhub-tickets'), $name);
continue;
}
if ($size > $policy['maxFileBytes']) {
$errors[] = sprintf(__('Die Datei „%1$s“ ist zu groß (höchstens %2$s).', 'deskhub-tickets'), $name, self::format_bytes($policy['maxFileBytes']));
continue;
}
$total += $size;
if ($total > $policy['maxTotalBytes']) {
$errors[] = sprintf(__('Die Dateien sind zusammen zu groß (höchstens %s).', 'deskhub-tickets'), self::format_bytes($policy['maxTotalBytes']));
break;
}
$content = file_get_contents($tmp);
if ($content === false || $content === '') {
$errors[] = sprintf(__('Die Datei „%s“ konnte nicht gelesen werden.', 'deskhub-tickets'), $name);
continue;
}
$out[] = ['name' => $name, 'data' => base64_encode($content)];
}
return [$errors, $out];
}
/** Bringt ein Formular von DeskHub (oder null) in eine feste Form mit Standardwerten, Feldern nach Plätzen sortiert. */
public static function normalize_form(?array $f): array
{
$own = $f !== null && !empty($f['ownForm']);
$str = static function ($v, string $default = ''): string { return is_string($v) && trim($v) !== '' ? trim($v) : $default; };
$out = [
'own' => $own,
'title' => $f !== null ? $str($f['title'] ?? '') : '',
'intro' => $f !== null ? $str($f['intro'] ?? '') : '',
// DeskHub meldet für Standardformulare seine eigenen deutschen Standardtexte; hier gelten die (übersetzbaren) des Plugins
'subject' => [
'show' => $f === null || !isset($f['subject']['show']) || !empty($f['subject']['show']),
'label' => $own ? $str($f['subject']['label'] ?? '', __('Betreff', 'deskhub-tickets')) : __('Betreff', 'deskhub-tickets'),
],
'message' => [
'show' => $f === null || !isset($f['message']['show']) || !empty($f['message']['show']),
'label' => $own ? $str($f['message']['label'] ?? '', __('Ihre Nachricht', 'deskhub-tickets')) : __('Ihre Nachricht', 'deskhub-tickets'),
'hint' => $own ? $str($f['message']['hint'] ?? '') : '',
],
'submitLabel' => $own ? $str($f['submitLabel'] ?? '', __('Ticket absenden', 'deskhub-tickets')) : __('Ticket absenden', 'deskhub-tickets'),
'fields' => [],
'attachments' => self::normalize_attachments($f['attachments'] ?? null),
];
$allowed = ['text', 'textarea', 'select', 'radio', 'checkbox', 'number', 'date'];
foreach ((array) ($f['fields'] ?? []) as $fd) {
if (!is_array($fd) || empty($fd['id']) || !isset($fd['label'])) {
continue;
}
$out['fields'][] = [
'id' => (int) $fd['id'],
'label' => (string) $fd['label'],
'type' => in_array($fd['type'] ?? '', $allowed, true) ? (string) $fd['type'] : 'text',
'required' => !empty($fd['required']),
'options' => array_values(array_filter(array_map('strval', (array) ($fd['options'] ?? [])), static function ($o) { return $o !== ''; })),
'helpHtml' => (string) ($fd['helpHtml'] ?? ''),
'width' => in_array($fd['width'] ?? '', ['half', 'third', 'twothirds'], true) ? (string) $fd['width'] : 'full',
'section' => (string) ($fd['section'] ?? 'extra'),
'sectionTitle' => (string) ($fd['sectionTitle'] ?? ''),
'radioInline' => !empty($fd['radioInline']),
];
}
return $out;
}
/**
* Gibt die Felder eines Platzes im Formular aus: je Thema ein Block (bei mehreren Themen schaltet form.js den passenden ein).
* $rest = true: alle Felder, die zu keinem bekannten Platz gehören („Zusätzliche Angaben“ und eigene Abschnitte).
*
* @param array<int,array> $forms
*/
private static function render_slot(array $forms, array $slots, bool $multi, int $selected, array $posted, bool $rest = false): string
{
$html = '';
foreach ($forms as $tid => $form) {
$fields = array_values(array_filter($form['fields'], static function ($f) use ($slots, $rest) {
return $rest ? !in_array($f['section'], self::SLOTS, true) : in_array($f['section'], $slots, true);
}));
if (!$fields) {
continue;
}
$on = !$multi || $tid === $selected;
$html .= '<div class="dht-topic-fields' . ($on ? ' dht-topic-active' : '') . '" data-topic="' . (int) $tid . '"><div class="dht-fields">';
$group = null;
foreach ($fields as $f) {
$heading = $rest ? $f['sectionTitle'] : '';
if ($rest && $heading !== $group) {
$html .= '</div>' . ($heading !== '' ? '<h4 class="dht-group">' . esc_html($heading) . '</h4>' : '') . '<div class="dht-fields">';
$group = $heading;
}
$html .= self::render_field($f, (string) $tid, isset($posted[$f['id']]) ? (string) $posted[$f['id']] : '', !$on);
}
$html .= '</div></div>';
}
return $html;
}
private static function help_html(string $html): string
{
return wp_kses($html, ['a' => ['href' => [], 'target' => [], 'rel' => []], 'br' => []]);
}
/** Ein Eingabefeld aus der Beschreibung von DeskHub. */
private static function render_field(array $f, string $uid, string $posted, bool $disabled): string
{
$id = (int) $f['id'];
$name = 'dht_f[' . $id . ']';
$dom = 'dht-f-' . $uid . '-' . $id;
$req = !empty($f['required']);
$dis = $disabled ? ' disabled' : '';
$reqAttr = $req ? ' required' : '';
$star = $req ? ' <span class="dht-req" aria-hidden="true">*</span>' : '';
$label = esc_html((string) $f['label']);
$help = self::help_html((string) $f['helpHtml']);
$o = '<div class="dht-field dht-w-' . esc_attr((string) $f['width']) . '">';
switch ($f['type']) {
case 'textarea':
$o .= '<label for="' . esc_attr($dom) . '">' . $label . $star . '</label>'
. '<textarea id="' . esc_attr($dom) . '" name="' . esc_attr($name) . '" rows="4"' . $reqAttr . $dis . '>' . esc_textarea($posted) . '</textarea>';
break;
case 'select':
$o .= '<label for="' . esc_attr($dom) . '">' . $label . $star . '</label><select id="' . esc_attr($dom) . '" name="' . esc_attr($name) . '"' . $reqAttr . $dis . '>'
. '<option value="">' . esc_html__('Bitte wählen …', 'deskhub-tickets') . '</option>';
foreach ($f['options'] as $opt) {
$o .= '<option value="' . esc_attr($opt) . '"' . ($posted === $opt ? ' selected="selected"' : '') . '>' . esc_html($opt) . '</option>';
}
$o .= '</select>';
break;
case 'radio':
$o .= '<fieldset class="dht-radios' . (!empty($f['radioInline']) ? ' dht-inline' : '') . '"><legend>' . $label . $star . '</legend>';
foreach ($f['options'] as $i => $opt) {
$o .= '<label class="dht-check"><input type="radio" id="' . esc_attr($dom . '-' . $i) . '" name="' . esc_attr($name) . '" value="' . esc_attr($opt) . '"'
. ($posted === $opt ? ' checked="checked"' : '') . $reqAttr . $dis . '> <span>' . esc_html($opt) . '</span></label>';
}
$o .= '</fieldset>';
break;
case 'checkbox':
$o .= '<label class="dht-check"><input type="checkbox" id="' . esc_attr($dom) . '" name="' . esc_attr($name) . '" value="1"'
. ($posted === '1' ? ' checked="checked"' : '') . $reqAttr . $dis . '> <span>' . ($help !== '' ? $help : $label) . $star . '</span></label>';
$help = '';
break;
case 'number':
case 'date':
$o .= '<label for="' . esc_attr($dom) . '">' . $label . $star . '</label><input type="' . esc_attr((string) $f['type']) . '" id="' . esc_attr($dom) . '" name="' . esc_attr($name)
. '" value="' . esc_attr($posted) . '"' . $reqAttr . $dis . '>';
break;
default:
$o .= '<label for="' . esc_attr($dom) . '">' . $label . $star . '</label><input type="text" id="' . esc_attr($dom) . '" name="' . esc_attr($name)
. '" value="' . esc_attr($posted) . '" maxlength="500"' . $reqAttr . $dis . '>';
}
if ($help !== '') {
$o .= '<p class="dht-help">' . $help . '</p>';
}
return $o . '</div>';
}
/**
* Prüft die Angaben zu den Feldern des Formulars wie DeskHub selbst (DeskHub prüft beim Anlegen noch einmal).
*
* @param array<int,array> $fields
* @param array<int,string> $input Feld-ID => abgeschickter Wert
* @return array{0:string[],1:array<int,string>} Fehlermeldungen und bereinigte Werte (Feld-ID => Wert)
*/
public static function validate_fields(array $fields, array $input): array
{
$errors = [];
$values = [];
foreach ($fields as $f) {
$id = (int) $f['id'];
$label = (string) $f['label'];
$type = (string) $f['type'];
$raw = isset($input[$id]) ? (string) $input[$id] : '';
if ($type === 'checkbox') {
$val = in_array(strtolower(trim($raw)), ['1', 'on', 'yes', 'ja', 'true'], true) ? '1' : '0';
} else {
$val = $type === 'textarea' ? sanitize_textarea_field($raw) : sanitize_text_field($raw);
$val = mb_substr($val, 0, 10000);
}
if (!empty($f['required']) && $type !== 'checkbox' && $val === '') {
$errors[] = sprintf(__('Bitte füllen Sie das Feld „%s“ aus.', 'deskhub-tickets'), $label);
} elseif (!empty($f['required']) && $type === 'checkbox' && $val !== '1') {
$errors[] = sprintf(__('Bitte bestätigen Sie „%s“.', 'deskhub-tickets'), $label);
} elseif (in_array($type, ['select', 'radio'], true) && $val !== '' && !in_array($val, $f['options'], true)) {
$errors[] = sprintf(__('Bitte wählen Sie bei „%s“ eine der angebotenen Möglichkeiten.', 'deskhub-tickets'), $label);
} elseif ($type === 'number' && $val !== '' && !is_numeric($val)) {
$errors[] = sprintf(__('Bitte geben Sie bei „%s“ eine Zahl an.', 'deskhub-tickets'), $label);
} elseif ($type === 'date' && $val !== '' && !(preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $val, $m) && checkdate((int) $m[2], (int) $m[3], (int) $m[1]))) {
$errors[] = sprintf(__('Bitte geben Sie bei „%s“ ein gültiges Datum an.', 'deskhub-tickets'), $label);
}
$values[$id] = $val;
}
return [$errors, $values];
}
/** Ticket-Text aus den Feldern, wenn das Formular kein Beschreibungsfeld hat (wie im Kundenportal von DeskHub). */
public static function compose_message(array $fields, array $values): string
{
$lines = [];
foreach ($fields as $f) {
$v = $values[(int) $f['id']] ?? '';
if ($f['type'] === 'checkbox') {
$v = $v === '1' ? __('Ja', 'deskhub-tickets') : __('Nein', 'deskhub-tickets');
}
if ($v !== '') {
$lines[] = $f['label'] . ': ' . $v;
}
}
return $lines ? implode("\n", $lines) : __('Anfrage ohne weitere Angaben', 'deskhub-tickets');
}
/** Verarbeitet das abgeschickte Formular (admin-post.php) und leitet auf die Seite zurück. */
public static function handle_submit(): void
{
$back = self::safe_back(isset($_POST['dht_back']) ? (string) wp_unslash($_POST['dht_back']) : '');
$opts = deskhub_tickets_options();
// 1) Unterschriebene Angaben prüfen (Abteilung, Thema, Priorität)
$cfgB64 = isset($_POST['dht_cfg']) ? (string) wp_unslash($_POST['dht_cfg']) : '';
$sig = isset($_POST['dht_sig']) ? (string) wp_unslash($_POST['dht_sig']) : '';
$cfg = null;
if (deskhub_tickets_verify_signature('cfg|' . $cfgB64, $sig)) {
$decoded = json_decode((string) base64_decode($cfgB64, true), true);
if (is_array($decoded)) {
$cfg = $decoded;
}
}
if ($cfg === null) {
self::fail($back, [__('Das Formular ist abgelaufen. Bitte laden Sie die Seite neu und versuchen Sie es noch einmal.', 'deskhub-tickets')], []);
}
// 2) Eingaben bereinigen
$postedFields = [];
if (isset($_POST['dht_f']) && is_array($_POST['dht_f'])) {
foreach ((array) wp_unslash($_POST['dht_f']) as $fid => $v) {
if (is_scalar($v)) {
$postedFields[absint($fid)] = (string) $v;
}
}
}
$values = [
'name' => mb_substr(sanitize_text_field(isset($_POST['dht_name']) ? (string) wp_unslash($_POST['dht_name']) : ''), 0, 120),
'email' => sanitize_email(isset($_POST['dht_email']) ? (string) wp_unslash($_POST['dht_email']) : ''),
'subject' => mb_substr(sanitize_text_field(isset($_POST['dht_subject']) ? (string) wp_unslash($_POST['dht_subject']) : ''), 0, 255),
'message' => mb_substr(sanitize_textarea_field(isset($_POST['dht_message']) ? (string) wp_unslash($_POST['dht_message']) : ''), 0, self::MAX_MESSAGE),
'topic' => isset($_POST['dht_topic']) ? absint(wp_unslash($_POST['dht_topic'])) : 0,
'fields' => $postedFields,
];
// 3) Falle-Feld: Wer es ausfüllt, ist ein Roboter. Er bekommt eine Erfolgsmeldung, es wird aber nichts angelegt.
if (isset($_POST['dht_website']) && trim((string) wp_unslash($_POST['dht_website'])) !== '') {
self::succeed($back, '');
}
// 4) Zeit seit dem Anzeigen des Formulars
$age = deskhub_tickets_timestamp_age(isset($_POST['dht_ts']) ? (string) wp_unslash($_POST['dht_ts']) : '');
$minSeconds = (int) $opts['min_seconds'];
if ($age === null || $age < $minSeconds) {
self::fail($back, [__('Das ging etwas zu schnell. Bitte prüfen Sie Ihre Angaben und senden Sie noch einmal ab.', 'deskhub-tickets')], $values);
}
// 5) Thema und Formular (aus DeskHub; ohne Antwort das einfache Formular)
$errors = [];
$allowedTopics = isset($cfg['o']) && is_array($cfg['o']) ? array_map('intval', $cfg['o']) : [];
$topicId = (int) ($cfg['t'] ?? 0);
if ($allowedTopics) {
if (!in_array($values['topic'], $allowedTopics, true)) {
$errors[] = __('Bitte wählen Sie ein Thema aus.', 'deskhub-tickets');
} else {
$topicId = $values['topic'];
}
}
$client = Deskhub_Tickets_Client::from_settings();
if (!$client) {
self::fail($back, [__('Der Support ist gerade nicht erreichbar. Bitte versuchen Sie es später noch einmal.', 'deskhub-tickets')], $values);
}
$form = null;
if (!$errors) {
$remote = $client->get_form($topicId ?: null, (int) ($cfg['d'] ?? 0) ?: null);
if (!is_wp_error($remote)) {
$form = self::normalize_form($remote);
}
}
$showSubject = $form === null || $form['subject']['show'];
$showMessage = $form === null || $form['message']['show'];
// 6) Pflichtfelder
if ($values['name'] === '') {
$errors[] = __('Bitte geben Sie Ihren Namen an.', 'deskhub-tickets');
}
if ($values['email'] === '' || !is_email($values['email'])) {
$errors[] = __('Bitte geben Sie eine gültige E-Mail-Adresse an.', 'deskhub-tickets');
}
if ($showSubject && $values['subject'] === '') {
$errors[] = __('Bitte geben Sie einen Betreff an.', 'deskhub-tickets');
}
if ($showMessage && trim($values['message']) === '') {
$errors[] = __('Bitte beschreiben Sie Ihr Anliegen.', 'deskhub-tickets');
}
$fieldValues = [];
if ($form !== null) {
[$fieldErrors, $fieldValues] = self::validate_fields($form['fields'], $postedFields);
$errors = array_merge($errors, $fieldErrors);
}
// Dateianhänge (nur wenn das Formular sie anbietet)
$attachments = [];
if (!empty($_FILES['dht_files']) && is_array($_FILES['dht_files'])) {
$policy = $form !== null ? $form['attachments'] : self::normalize_attachments(null);
[$attErrors, $attachments] = self::collect_attachments($_FILES['dht_files'], $policy);
$errors = array_merge($errors, $attErrors);
}
if (trim((string) $opts['consent_text']) !== '' && empty($_POST['dht_consent'])) {
$errors[] = __('Bitte stimmen Sie der Verarbeitung Ihrer Angaben zu.', 'deskhub-tickets');
}
if ($errors) {
self::fail($back, $errors, $values);
}
// 7) Captcha und Mengenbegrenzung
$captcha = self::verify_turnstile();
if ($captcha !== true) {
self::fail($back, [$captcha], $values);
}
if (!deskhub_tickets_rate_allow('submit', (int) $opts['rate_limit_per_hour'], HOUR_IN_SECONDS)) {
self::fail($back, [__('Sie haben in der letzten Stunde schon mehrere Tickets gesendet. Bitte versuchen Sie es später noch einmal.', 'deskhub-tickets')], $values);
}
// 8) Ticket anlegen
$text = $showMessage ? $values['message'] : self::compose_message($form['fields'], $fieldValues);
$message = $text . "\n\n— " . sprintf(
/* translators: %s: Adresse der Seite mit dem Formular */
__('Gesendet über das Formular auf %s', 'deskhub-tickets'),
$back
);
$payload = [
'name' => $values['name'],
'email' => $values['email'],
'subject' => $showSubject ? $values['subject'] : '', // ohne Betrefffeld setzt DeskHub „Anfrage <Abteilung>“
'message' => $message,
'helpTopicId' => $topicId,
'departmentId' => (int) ($cfg['d'] ?? 0),
'priorityId' => (int) ($cfg['p'] ?? 0),
];
if ($form !== null) {
$payload['fields'] = $fieldValues;
}
if ($attachments) {
$payload['attachments'] = $attachments;
}
$payload = apply_filters('deskhub_tickets_payload', $payload, $values);
$result = $client->create_ticket($payload);
if (is_wp_error($result)) {
self::fail($back, [Deskhub_Tickets_Client::user_message($result)], $values);
}
do_action('deskhub_tickets_created', $result['number'], $payload);
self::succeed($back, $result['number']);
}
// ───────────────────────────── Stand abfragen ─────────────────────────────
/**
* @param array|string $atts
*/
public static function render_status_form($atts = []): string
{
self::enqueue();
if (!Deskhub_Tickets_Client::from_settings()) {
return current_user_can('manage_options')
? '<p class="dht-notice dht-error">' . esc_html__('DeskHub Tickets ist noch nicht eingerichtet (Einstellungen → DeskHub Tickets). Diese Meldung sehen nur Administratoren.', 'deskhub-tickets') . '</p>'
: '';
}
$number = '';
$email = '';
$error = '';
$ticket = null;
if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['dht_status_submit'])) {
$number = trim(sanitize_text_field((string) wp_unslash($_POST['dht_number'] ?? '')));
$email = sanitize_email((string) wp_unslash($_POST['dht_status_email'] ?? ''));
if (isset($_POST['dht_status_website']) && trim((string) wp_unslash($_POST['dht_status_website'])) !== '') {
$error = __('Zu dieser Kombination aus Ticketnummer und E-Mail-Adresse wurde nichts gefunden.', 'deskhub-tickets');
} elseif (!preg_match('/^[A-Za-z0-9._-]{1,40}$/', $number) || !is_email($email)) {
$error = __('Bitte geben Sie Ihre Ticketnummer und die E-Mail-Adresse an, mit der Sie das Ticket gesendet haben.', 'deskhub-tickets');
} elseif (!deskhub_tickets_rate_allow('status', 10, 10 * MINUTE_IN_SECONDS)) {
// Bremst das Durchprobieren von Ticketnummern
$error = __('Zu viele Abfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.', 'deskhub-tickets');
} else {
$client = Deskhub_Tickets_Client::from_settings();
$res = $client ? $client->get_ticket($number, $email) : new WP_Error('deskhub_config', 'x');
if (is_wp_error($res)) {
$error = Deskhub_Tickets_Client::user_message($res);
} else {
$ticket = $res;
}
}
}
ob_start();
echo self::wrap_open('deskhub-status');
if ($error !== '') {
echo '<div class="dht-notice dht-error" role="alert"><p>' . esc_html($error) . '</p></div>';
}
if ($ticket) {
echo self::render_ticket_status($ticket);
}
?>
<form method="post" class="dht-form" novalidate>
<div class="dht-trap" aria-hidden="true">
<label>Website <input type="text" name="dht_status_website" value="" tabindex="-1" autocomplete="off"></label>
</div>
<p>
<label for="dht-number"><?php echo esc_html__('Ticketnummer', 'deskhub-tickets'); ?> *</label>
<input type="text" id="dht-number" name="dht_number" value="<?php echo esc_attr($number); ?>" maxlength="40" required placeholder="260101-1234">
</p>
<p>
<label for="dht-status-email"><?php echo esc_html__('E-Mail-Adresse', 'deskhub-tickets'); ?> *</label>
<input type="email" id="dht-status-email" name="dht_status_email" value="<?php echo esc_attr($email); ?>" maxlength="190" required autocomplete="email">
</p>
<p><button type="submit" name="dht_status_submit" value="1" class="dht-button"><?php echo esc_html__('Stand abfragen', 'deskhub-tickets'); ?></button></p>
</form>
<?php
echo '</div>';
return (string) ob_get_clean();
}
/** Zeigt Status und öffentlichen Verlauf eines Tickets. Alles wird maskiert; die Texte kommen vom Server als Klartext. */
public static function render_ticket_status(array $t): string
{
$labels = [
'open' => __('Offen', 'deskhub-tickets'),
'pending' => __('Wartet auf Ihre Antwort', 'deskhub-tickets'),
'answered' => __('Beantwortet', 'deskhub-tickets'),
'on_hold' => __('In Bearbeitung', 'deskhub-tickets'),
'resolved' => __('Gelöst', 'deskhub-tickets'),
'closed' => __('Geschlossen', 'deskhub-tickets'),
];
$status = (string) ($t['status'] ?? '');
$fmt = function ($s): string {
$ts = strtotime((string) $s);
return $ts ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $ts) : '';
};
$out = '<div class="dht-ticket">';
$out .= '<h3 class="dht-title">' . esc_html((string) ($t['subject'] ?? '')) . '</h3>';
$out .= '<p class="dht-meta"><span class="dht-badge dht-status-' . esc_attr(preg_replace('/[^a-z_]/', '', $status)) . '">' . esc_html($labels[$status] ?? $status) . '</span> '
. esc_html(sprintf(__('Ticket %s', 'deskhub-tickets'), (string) ($t['number'] ?? ''))) . ' · '
. esc_html(sprintf(__('Zuletzt geändert: %s', 'deskhub-tickets'), $fmt($t['updatedAt'] ?? ''))) . '</p>';
$messages = isset($t['messages']) && is_array($t['messages']) ? $t['messages'] : [];
foreach ($messages as $m) {
if (!is_array($m)) {
continue;
}
$mine = ($m['author'] ?? '') === 'customer';
$out .= '<div class="dht-msg ' . ($mine ? 'dht-msg-you' : 'dht-msg-staff') . '">'
. '<div class="dht-msg-head"><strong>' . esc_html($mine ? __('Sie', 'deskhub-tickets') : __('Support', 'deskhub-tickets')) . '</strong> · ' . esc_html($fmt($m['createdAt'] ?? '')) . '</div>'
. '<div class="dht-msg-body">' . nl2br(esc_html((string) ($m['message'] ?? ''))) . '</div></div>';
}
return $out . '</div>';
}
// ───────────────────────────── Hilfsfunktionen ────────────────────────────
/** „Rechnung:3,Technik:4“ → [['label'=>'Rechnung','id'=>3], …] */
public static function parse_topics(string $spec): array
{
$out = [];
foreach (explode(',', $spec) as $part) {
$pos = strrpos($part, ':');
if ($pos === false) {
continue;
}
$label = trim(sanitize_text_field(substr($part, 0, $pos)));
$id = absint(trim(substr($part, $pos + 1)));
if ($label !== '' && $id > 0) {
$out[] = ['label' => $label, 'id' => $id];
}
}
return $out;
}
/** Adresse der Seite, auf der das Formular steht (dorthin geht es nach dem Absenden zurück). */
private static function current_url(): string
{
$url = get_permalink();
return $url ? (string) $url : home_url('/');
}
/** Ziel für „zurück zur Seite“: nur Adressen dieser Website. */
private static function safe_back(string $url): string
{
$url = remove_query_arg('dht', $url);
$safe = wp_validate_redirect($url, home_url('/'));
return $safe ?: home_url('/');
}
/** Merkt sich Fehler oder Erfolg kurz und leitet auf die Seite zurück (kein erneutes Senden beim Neuladen). */
private static function redirect_with_state(string $back, array $state): void
{
$id = bin2hex(random_bytes(8));
set_transient('dht_state_' . $id, $state, 10 * MINUTE_IN_SECONDS);
wp_safe_redirect(add_query_arg('dht', $id, $back) . '#deskhub-ticket');
exit;
}
private static function fail(string $back, array $errors, array $values): void
{
// Die Eingaben bleiben erhalten, damit der Text der Nachricht nicht verloren geht
self::redirect_with_state($back, ['errors' => $errors, 'values' => $values]);
}
private static function succeed(string $back, string $number): void
{
self::redirect_with_state($back, ['ok' => true, 'number' => $number]);
}
/** Liest den kurz gemerkten Zustand (Fehler/Erfolg) aus der Adresse und löscht ihn. */
private static function take_state(): ?array
{
$id = isset($_GET['dht']) ? (string) wp_unslash($_GET['dht']) : '';
if (!preg_match('/^[a-f0-9]{16}$/', $id)) {
return null;
}
$state = get_transient('dht_state_' . $id);
delete_transient('dht_state_' . $id);
return is_array($state) ? $state : null;
}
// ───────────────────────────── Cloudflare Turnstile ───────────────────────
private static function turnstile_enabled(): bool
{
$o = deskhub_tickets_options();
return trim((string) $o['turnstile_site']) !== '' && trim((string) $o['turnstile_secret']) !== '';
}
private static function turnstile_widget(): string
{
if (!self::turnstile_enabled()) {
return '';
}
$site = (string) deskhub_tickets_option('turnstile_site');
wp_enqueue_script('deskhub-turnstile', 'https://challenges.cloudflare.com/turnstile/v0/api.js', [], null, true);
return '<div class="cf-turnstile" data-sitekey="' . esc_attr($site) . '"></div>';
}
/** @return true|string true bei Erfolg, sonst die Meldung für den Besucher */
private static function verify_turnstile()
{
if (!self::turnstile_enabled()) {
return true;
}
$token = isset($_POST['cf-turnstile-response']) ? (string) wp_unslash($_POST['cf-turnstile-response']) : '';
$fail = __('Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuchen Sie es noch einmal.', 'deskhub-tickets');
if ($token === '' || strlen($token) > 2048) {
return $fail;
}
$res = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'timeout' => 10,
'body' => ['secret' => (string) deskhub_tickets_option('turnstile_secret'), 'response' => $token, 'remoteip' => deskhub_tickets_client_ip()],
]);
if (is_wp_error($res)) {
error_log('DeskHub Tickets: Turnstile nicht erreichbar: ' . $res->get_error_message());
return $fail;
}
$data = json_decode((string) wp_remote_retrieve_body($res), true);
return is_array($data) && !empty($data['success']) ? true : $fail;
}
}