Upload via GUI (335 Dateien)

This commit is contained in:
2026-10-24 20:14:54 +00:00
parent 65003aa37b
commit 9108a34774
15 changed files with 1111 additions and 152 deletions
+7
View File
@@ -23,6 +23,10 @@ require_once __DIR__ . '/fcm.php';
require_once __DIR__ . '/staff_activity.php';
require_once __DIR__ . '/notifications.php';
require_once __DIR__ . '/ticket_extras.php';
require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/csat.php';
require_once __DIR__ . '/status_page.php';
require_once __DIR__ . '/ticket_templates.php';
require_once __DIR__ . '/plugins.php';
require_once __DIR__ . '/app_downloads.php';
require_once __DIR__ . '/theme_catalog.php';
@@ -71,6 +75,9 @@ pseudo_cron_schedule_background();
// Aktive Plugins laden (Fehler in Plugins werden isoliert, siehe includes/plugins.php).
plugins_boot();
// Mehrsprachigkeit: bei einer anderen Sprache als Deutsch (oder im Sammelmodus) wird die fertige Seite übersetzt (includes/i18n.php).
i18n_start();
// DB-kompatibler SQL-Ausdruck für Domain-Teil einer E-Mail
function sql_email_domain(string $col): string {
$driver = config()['db']['driver'] ?? 'sqlite';
@@ -64,7 +64,7 @@ function detect_php_cli_binary(): string
return 'php'; // Fallback: hoffen, dass PATH im Cron-Kontext passt
}
/** Baut den verwalteten DeskHub-Block mit den 6 Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */
/** Baut den verwalteten DeskHub-Block mit den Job-Zeilen (fest hinterlegte Kommandos, keine Nutzereingabe). */
function crontab_marker_block(): string
{
$php = detect_php_cli_binary();
@@ -72,6 +72,7 @@ function crontab_marker_block(): string
$lines = [
DESKHUB_CRON_MARKER_START,
"*/5 * * * * $php $root/cron/fetch_emails.php >> $root/cron/fetch_emails.log 2>&1",
"* * * * * $php $root/cron/run_plugins.php >> $root/cron/run_plugins.log 2>&1",
"*/15 * * * * $php $root/cron/check_sla.php >> $root/cron/check_sla.log 2>&1",
"0 * * * * $php $root/cron/check_escalations.php >> $root/cron/check_escalations.log 2>&1",
"0 6 * * * $php $root/cron/process_recurring.php >> $root/cron/process_recurring.log 2>&1",
+166
View File
@@ -0,0 +1,166 @@
<?php
/**
* includes/csat.php
*
* Zufriedenheitsabfrage (CSAT): Nach dem Schließen oder Lösen eines Tickets bekommt der Kunde eine Mail mit der Bitte,
* die Bearbeitung mit 1 bis 5 Sternen zu bewerten (und optional einen Kommentar zu schreiben). Pro Ticket gibt es höchstens
* eine Abfrage. Der Versand läuft über den Hintergrunddienst (cron/send_csat.php), damit er nicht an jedem Weg hängt,
* auf dem ein Ticket geschlossen werden kann (Web, App, Regeln, automatisches Schließen).
*/
function csat_enabled(): bool
{
return get_setting('csat_enabled', '0') === '1';
}
/** Wartezeit nach dem Schließen, bevor die Mail rausgeht (0 bis 1440 Minuten). */
function csat_delay_minutes(): int
{
return max(0, min(1440, (int) get_setting('csat_delay_minutes', '30')));
}
function csat_url(string $token, ?int $rating = null): string
{
return base_url() . '/csat.php?t=' . rawurlencode($token) . ($rating ? '&r=' . $rating : '');
}
/** Schaltet die Abfrage ein oder aus. Beim Einschalten gelten nur Tickets, die ab jetzt geschlossen werden (keine Mails für alte Tickets). */
function csat_set_enabled(bool $on, int $delayMinutes): void
{
if ($on && !csat_enabled()) set_setting('csat_since', db_now());
set_setting('csat_enabled', $on ? '1' : '0');
set_setting('csat_delay_minutes', (string) max(0, min(1440, $delayMinutes)));
}
/** Eintrag zu einem Ticket (oder null). */
function csat_for_ticket(PDO $pdo, int $ticketId): ?array
{
$s = $pdo->prepare('SELECT * FROM ticket_csat WHERE ticket_id = ?');
$s->execute([$ticketId]);
return $s->fetch() ?: null;
}
function csat_find_by_token(PDO $pdo, string $token): ?array
{
if (!preg_match('/^[a-f0-9]{40}$/', $token)) return null;
$s = $pdo->prepare('SELECT c.*, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE c.token = ?');
$s->execute([$token]);
return $s->fetch() ?: null;
}
/**
* Speichert die Bewertung. Jede Abfrage lässt sich nur einmal beantworten.
* @return array{ok:bool,error?:string}
*/
function csat_record(PDO $pdo, string $token, int $rating, string $comment): array
{
$row = csat_find_by_token($pdo, $token);
if (!$row) return ['ok' => false, 'error' => 'Dieser Link ist ungültig oder abgelaufen.'];
if ($row['answered_at'] !== null) return ['ok' => false, 'error' => 'Vielen Dank, Sie haben dieses Ticket bereits bewertet.'];
if ($rating < 1 || $rating > 5) return ['ok' => false, 'error' => 'Bitte wählen Sie 1 bis 5 Sterne.'];
$comment = trim(mb_substr($comment, 0, 2000));
$pdo->prepare('UPDATE ticket_csat SET rating = ?, comment = ?, answered_at = ? WHERE id = ? AND answered_at IS NULL')
->execute([$rating, $comment !== '' ? $comment : null, db_now(), $row['id']]);
try {
log_activity($pdo, (int) $row['ticket_id'], null, 'Zufriedenheit bewertet: ' . $rating . ' von 5 Sternen');
} catch (Throwable $e) {
}
return ['ok' => true];
}
/** Text der Mail an den Kunden. */
function csat_mail_html(array $ticket, string $token): string
{
$name = trim((string) ($ticket['requester_name'] ?? ''));
$stars = '';
for ($i = 1; $i <= 5; $i++) {
$stars .= '<a href="' . e(csat_url($token, $i)) . '" style="display:inline-block;text-decoration:none;font-size:34px;line-height:1;color:#f5b301;padding:0 3px" title="' . $i . ' von 5">★</a>';
}
return '<h2 style="margin:0 0 12px;font-size:20px;color:#16202a">Wie zufrieden waren Sie?</h2>'
. '<p style="margin:0 0 16px;color:#475569;font-size:15px;line-height:1.55">' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '')
. 'Ihr Ticket <strong>[#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . '</strong> ist abgeschlossen. '
. 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.</p>'
. '<p style="margin:0 0 6px;text-align:center">' . $stars . '</p>'
. '<p style="margin:0 0 16px;text-align:center;color:#64748b;font-size:12px">1 Stern = unzufrieden · 5 Sterne = sehr zufrieden</p>';
}
/**
* Verschickt die fälligen Abfragen. Fällig: Ticket geschlossen oder gelöst (seit mindestens der eingestellten Wartezeit, höchstens 14 Tage),
* kein Spam, kein Live-Chat, gültige Kundenadresse, noch keine Abfrage, und geschlossen nach dem Einschalten der Funktion.
*/
function csat_send_due(PDO $pdo, int $limit = 50): int
{
if (!csat_enabled()) return 0;
$now = time();
$until = date('Y-m-d H:i:s', $now - csat_delay_minutes() * 60);
$oldest = date('Y-m-d H:i:s', $now - 14 * 86400);
$since = (string) get_setting('csat_since', '');
if ($since !== '' && $since > $oldest) $oldest = $since;
$stmt = $pdo->prepare(
"SELECT t.* FROM tickets t
WHERE t.status IN ('closed','resolved') AND t.is_spam = 0 AND t.source <> 'chat'
AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) <= ? AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) >= ?
AND t.requester_email IS NOT NULL AND t.requester_email <> ''
AND NOT EXISTS (SELECT 1 FROM ticket_csat c WHERE c.ticket_id = t.id)
ORDER BY t.id LIMIT " . max(1, $limit)
);
$stmt->execute([$until, $oldest]);
$sent = 0;
foreach ($stmt->fetchAll() as $t) {
if (!filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue;
$token = bin2hex(random_bytes(20));
try {
$pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, sent_at) VALUES (?,?,?,?,?)')
->execute([$t['id'], $token, $t['assigned_staff_id'] ?: null, $t['department_id'] ?: null, db_now()]);
} catch (Throwable $e) {
continue; // parallel schon angelegt
}
$from = resolve_from_address($t['department_id'] ? (int) $t['department_id'] : null, $t['priority_id'] ? (int) $t['priority_id'] : null);
email_send_and_log((string) $t['requester_email'], 'Wie zufrieden waren Sie? [#' . $t['ticket_number'] . ']', email_wrap(csat_mail_html($t, $token)), $from, 'Zufriedenheitsabfrage', (string) $t['ticket_number']);
$sent++;
}
return $sent;
}
/**
* Auswertung für einen Zeitraum.
* @param array|null $deptIds null = alle Abteilungen
* @return array{sent:int,answered:int,avg:?float,rate:?float,dist:array<int,int>,byStaff:list<array>,recent:list<array>}
*/
function csat_stats(PDO $pdo, string $from, string $to, ?array $deptIds = null): array
{
$where = 'c.sent_at >= ? AND c.sent_at <= ?';
$args = [$from, $to];
if ($deptIds !== null) {
if (!$deptIds) return ['sent' => 0, 'answered' => 0, 'avg' => null, 'rate' => null, 'dist' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0], 'byStaff' => [], 'recent' => []];
$where .= ' AND c.department_id IN (' . implode(',', array_map('intval', $deptIds)) . ')';
}
$q = $pdo->prepare("SELECT COUNT(*) AS sent, SUM(CASE WHEN c.rating IS NOT NULL THEN 1 ELSE 0 END) AS answered, AVG(c.rating) AS avg FROM ticket_csat c WHERE $where");
$q->execute($args);
$r = $q->fetch() ?: [];
$sent = (int) ($r['sent'] ?? 0);
$answered = (int) ($r['answered'] ?? 0);
$dist = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0];
$d = $pdo->prepare("SELECT c.rating AS r, COUNT(*) AS n FROM ticket_csat c WHERE $where AND c.rating IS NOT NULL GROUP BY c.rating");
$d->execute($args);
foreach ($d->fetchAll() as $row) $dist[(int) $row['r']] = (int) $row['n'];
$b = $pdo->prepare("SELECT s.id, s.name, COUNT(*) AS n, AVG(c.rating) AS avg FROM ticket_csat c JOIN staff s ON s.id = c.staff_id WHERE $where AND c.rating IS NOT NULL GROUP BY s.id, s.name ORDER BY AVG(c.rating) DESC, COUNT(*) DESC");
$b->execute($args);
$byStaff = array_map(fn($x) => ['id' => (int) $x['id'], 'name' => (string) $x['name'], 'count' => (int) $x['n'], 'avg' => round((float) $x['avg'], 2)], $b->fetchAll());
$rc = $pdo->prepare("SELECT c.rating, c.comment, c.answered_at, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE $where AND c.rating IS NOT NULL ORDER BY c.answered_at DESC LIMIT 10");
$rc->execute($args);
return [
'sent' => $sent, 'answered' => $answered,
'avg' => $answered ? round((float) $r['avg'], 2) : null,
'rate' => $sent ? round($answered * 100 / $sent, 1) : null,
'dist' => $dist, 'byStaff' => $byStaff,
'recent' => array_map(fn($x) => ['rating' => (int) $x['rating'], 'comment' => (string) ($x['comment'] ?? ''), 'answeredAt' => (string) $x['answered_at'], 'number' => (string) $x['ticket_number'], 'subject' => (string) $x['subject']], $rc->fetchAll()),
];
}
/** „★★★★☆“ für Anzeigen. */
function csat_stars(?int $rating): string
{
$rating = max(0, min(5, (int) $rating));
return str_repeat('★', $rating) . str_repeat('☆', 5 - $rating);
}
@@ -550,3 +550,4 @@ if ($__assistantOn) require __DIR__ . '/assistant_widget.php';
})();
</script>
<?php endif; ?>
<script src="/assets/js/upload-compress.js?v=<?= @filemtime(__DIR__ . "/../assets/js/upload-compress.js") ?: time() ?>" defer></script>
+12 -6
View File
@@ -47,6 +47,13 @@ function check_honeypot(): void
}
}
/** Stimmt der Formular-Token mit dem der Sitzung überein? (ohne Abbruch, für Anmeldeseiten mit freundlicher Meldung) */
function csrf_valid(): bool
{
$token = $_POST['csrf_token'] ?? '';
return $token !== '' && hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
function verify_csrf(): void
{
$token = $_POST['csrf_token'] ?? '';
@@ -1070,15 +1077,14 @@ function handle_ticket_upload(array $file): array
// Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg)
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
// Doppelte Endungen blockieren (z.B. evil.php.jpg)
$allParts = explode('.', strtolower($file['name']));
// Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain
// als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt.
$dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe',
'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt'];
foreach ($allParts as $part) {
if (in_array($part, $dangerousExts, true)) {
return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.'];
}
// Gespeichert wird unter einem Zufallsnamen mit nur der letzten Endung. Namen wie „www.example.com.pdf“ oder „scan.js.2024.jpg“
// sind daher harmlos und werden nicht abgelehnt; geprüft wird die tatsächliche Endung.
if (in_array($ext, $dangerousExts, true)) {
return ['error' => 'Dieser Dateityp (.' . $ext . ') ist aus Sicherheitsgründen nicht erlaubt: ' . $file['name']];
}
if (!in_array($ext, $allowed, true)) {
return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)];
@@ -1094,7 +1100,7 @@ function handle_ticket_upload(array $file): array
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (in_array($mimeType, $dangerousMimes, true)) {
return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.'];
return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.'];
}
}
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
@@ -95,11 +95,15 @@ if ('serviceWorker' in navigator) {
<?php if (get_setting('kb_enabled', '1') === '1'): ?>
<a href="/kb.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a>
<?php endif; ?>
<?php if (status_page_enabled()): ?>
<a href="/status.php" class="<?= $activePage==='status'?'active':'' ?>">Status</a>
<?php endif; ?>
<?php if ($user): ?>
<a href="/logout.php">Abmelden</a>
<?php else: ?>
<a href="/login.php" class="<?= $activePage==='login'?'active':'' ?>">Anmelden</a>
<?php endif; ?>
<?= i18n_switcher_html() ?>
</nav>
</div>
+91
View File
@@ -111,3 +111,94 @@ function system_health(PDO $pdo): array
}
return $issues;
}
// ── Fehler-Mail bei schweren PHP-Fehlern (wie bei WordPress) ───────────────────
/** Fehlerarten, die eine Seite abbrechen. */
const ERROR_ALERT_FATAL = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
/** Dieselbe Meldung frühestens nach so vielen Sekunden erneut mailen. */
const ERROR_ALERT_REPEAT = 3600;
/** Höchstens so viele Fehler-Mails pro Stunde insgesamt (schützt das Postfach bei einem Fehler auf jeder Seite). */
const ERROR_ALERT_MAX_PER_HOUR = 10;
/** Empfänger: eigene Adresse aus den Einstellungen, sonst Admin-Adresse, sonst alle aktiven Administratoren. */
function error_alert_recipients(PDO $pdo): array
{
$own = trim((string) get_setting('error_alert_email', ''));
if ($own !== '') return array_values(array_filter(array_map('trim', preg_split('/[,;\s]+/', $own) ?: []), fn($m) => filter_var($m, FILTER_VALIDATE_EMAIL)));
$admin = trim((string) get_setting('admin_email', '') ?: (string) (config()['site']['admin_email'] ?? ''));
if (filter_var($admin, FILTER_VALIDATE_EMAIL) && $admin !== 'support@example.com') return [$admin];
return health_admin_emails($pdo);
}
/** Betreff und Text der Mail. Nur Pfad der Adresse (keine Parameter, dort können Zugangscodes stehen), keine Formular- oder Cookie-Daten. */
function error_alert_compose(array $err, array $context = []): array
{
$root = str_replace('\\', '/', dirname(__DIR__)) . '/';
$file = str_replace($root, '', str_replace('\\', '/', (string) ($err['file'] ?? '')));
$msg = mb_substr(str_replace($root, '', str_replace('\\', '/', (string) ($err['message'] ?? ''))), 0, 1500);
$site = (string) get_setting('display_name', 'DeskHub');
$path = (string) ($context['path'] ?? (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '(Hintergrundaufruf)'));
$headline = !empty($err['headline']) ? e((string) $err['headline']) : 'Auf <strong>' . e($site) . '</strong> ist ein schwerer Fehler aufgetreten. Die aufgerufene Seite konnte nicht angezeigt werden.';
$html = '<p>' . $headline . '</p>'
. '<p><strong>Fehler:</strong><br><code>' . nl2br(e($msg)) . '</code></p>'
. '<p><strong>Datei:</strong> ' . e($file) . ((int) ($err['line'] ?? 0) > 0 ? ', Zeile ' . (int) $err['line'] : '') . '<br>'
. '<strong>Seite:</strong> ' . e($path) . '<br>'
. '<strong>Zeit:</strong> ' . e(date('d.m.Y H:i:s')) . '<br>'
. '<strong>Version:</strong> DeskHub ' . e((string) (defined('DESKHUB_VERSION') ? DESKHUB_VERSION : '?')) . ', PHP ' . e(PHP_VERSION) . '</p>'
. '<p style="color:#666;font-size:12px">Dieselbe Meldung wird höchstens einmal pro Stunde gesendet. Ausschalten oder Empfänger ändern: Einstellungen → Allgemein.</p>';
return [!empty($err['subject']) ? "{$site}: " . (string) $err['subject'] : "{$site}: Schwerer Fehler auf der Webseite", $html];
}
/**
* Meldet einen Fehler, der die Seite nicht abbricht, aber Aufmerksamkeit braucht (zum Beispiel ein Plugin, das Fehler wirft, oder ein
* fehlgeschlagener Hintergrundjob). Gleiche Ein-/Ausschalter, Empfänger und Drosselung wie bei schweren Fehlern.
*/
function error_alert_notice(string $subject, string $headline, string $where, string $message): void
{
try {
error_alert_dispatch(get_db(), ['type' => E_USER_WARNING, 'message' => $message, 'file' => $where, 'line' => 0, 'subject' => $subject, 'headline' => $headline]);
} catch (Throwable $e) {
error_log('error_alert_notice: ' . $e->getMessage());
}
}
/**
* Prüft Ein/Aus und Drosselung und verschickt die Mail. $send ist nur für Tests austauschbar (Standard: send_mail).
* Liefert die Zahl der Empfänger, an die gesendet wurde.
*/
function error_alert_dispatch(PDO $pdo, array $err, ?callable $send = null): int
{
if (get_setting('error_alert', '1') !== '1') return 0;
$fp = sha1(preg_replace('/\d+/', '#', (string) ($err['file'] ?? '') . ':' . substr((string) ($err['message'] ?? ''), 0, 200)));
$now = time();
$log = json_decode((string) get_setting('error_alert_log', '[]'), true);
$log = array_values(array_filter(is_array($log) ? $log : [], fn($r) => is_array($r) && $now - (int) ($r[0] ?? 0) < 86400));
$lastHour = array_filter($log, fn($r) => $now - (int) $r[0] < 3600);
if (count($lastHour) >= ERROR_ALERT_MAX_PER_HOUR) return 0;
foreach ($lastHour as $r) if (($r[1] ?? '') === $fp && $now - (int) $r[0] < ERROR_ALERT_REPEAT) return 0;
// erst merken, dann senden: Schlägt das Senden selbst fehl, folgt keine Endlosschleife
$log[] = [$now, $fp];
set_setting('error_alert_log', json_encode($log));
[$subject, $html] = error_alert_compose($err);
$sent = 0;
foreach (error_alert_recipients($pdo) as $to) {
if (($send ?? $GLOBALS['error_alert_send'] ?? 'send_mail')($to, $subject, $html)) $sent++;
}
return $sent;
}
/** Wird am Ende jeder Anfrage aufgerufen: war es ein schwerer Fehler, geht eine Mail an den Administrator. */
function error_alert_shutdown(): void
{
static $running = false;
$err = error_get_last();
if ($running || !$err || !in_array($err['type'], ERROR_ALERT_FATAL, true)) return;
$running = true;
try {
error_alert_dispatch(get_db(), $err);
} catch (Throwable $e) {
error_log('error_alert: ' . $e->getMessage());
}
}
register_shutdown_function('error_alert_shutdown');
+334
View File
@@ -0,0 +1,334 @@
<?php
/**
* includes/i18n.php
*
* Mehrsprachigkeit. Der Quelltext von DeskHub ist deutsch; jede weitere Sprache kommt als Plugin ("Sprachpaket"), das nur Daten enthält:
*
* plugin.json {"name":"English","version":"1.0.0","entry":"plugin.php","language":{"code":"en","name":"English","flag":"flag.svg","strings":"strings.json","htmlLang":"en"}}
* plugin.php (leer; wird nur verlangt, weil jedes Plugin eine Startdatei hat)
* flag.svg Flagge (SVG oder PNG, höchstens 40 KB) – sie erscheint im Sprachwechsler
* strings.json {"Neues Ticket": "New ticket", "Willkommen, {}": "Welcome, {}", ...}
*
* Übersetzt wird die fertige Seite: Textstücke zwischen den HTML-Tags, Attribute wie placeholder/title/alt, Beschriftungen von Knöpfen und
* Textliteralen in confirm()/alert(). Ein Schlüssel muss genau dem deutschen Text entsprechen (Leerraum zählt nicht). "{}" steht für
* wechselnde Teile ("{#}" nur für Zahlen) ("Willkommen, {}"); "{1}", "{2}" in der Übersetzung erlauben eine andere Reihenfolge. Was nicht übersetzt ist, bleibt deutsch.
*
* Sprache wählen: Cookie "deskhub_lang" (gesetzt über /lang.php, dem Klick auf eine Flagge), sonst die Standardsprache (Einstellung
* "default_language", sonst Deutsch). Ist Deutsch gewählt und der Sammelmodus aus, läuft nichts davon: die Seiten werden unverändert ausgeliefert.
*
* Sammelmodus (Adminportal → Sprachen): merkt sich die deutschen Texte der besuchten Seiten, daraus entsteht eine Vorlage für Übersetzer.
*/
const I18N_COOKIE = 'deskhub_lang';
const I18N_FLAG_MAX_BYTES = 40960;
const I18N_MAX_STRINGS_BYTES = 2 * 1024 * 1024;
/** Eingebaute Flagge für Deutsch (Quellsprache; sie ist immer verfügbar, auch ohne Plugin). */
function i18n_builtin_flag_de(): string
{
return 'data:image/svg+xml;base64,' . base64_encode('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3"><rect width="5" height="3" fill="#FFCE00"/><rect width="5" height="2" fill="#DD0000"/><rect width="5" height="1"/></svg>');
}
/**
* Verfügbare Sprachen: Deutsch (eingebaut) und alle aktiven Sprachpakete.
* @return array<string,array{code:string,name:string,flag:string,htmlLang:string,slug:string,strings:string}> Schlüssel = Sprachcode
*/
function i18n_languages(bool $refresh = false): array
{
static $cache = null;
if ($cache !== null && !$refresh) return $cache;
$langs = ['de' => ['code' => 'de', 'name' => 'Deutsch', 'flag' => i18n_builtin_flag_de(), 'htmlLang' => 'de', 'slug' => '', 'strings' => '']];
try {
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return $cache = $langs;
$rows = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
} catch (Throwable $e) {
return $cache = $langs;
}
foreach ($rows as $slug) {
$slug = (string) $slug;
if (!plugin_valid_slug($slug)) continue;
$dir = plugins_dir() . '/' . $slug;
if (!is_file($dir . '/plugin.json')) continue;
$raw = json_decode((string) file_get_contents($dir . '/plugin.json'), true);
$l = is_array($raw) && is_array($raw['language'] ?? null) ? $raw['language'] : null;
if (!$l) continue;
[$manifest] = plugin_read_manifest($dir);
if (!$manifest) continue;
$code = strtolower((string) ($l['code'] ?? ''));
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code)) continue;
$flag = '';
$flagFile = (string) ($l['flag'] ?? '');
if ($flagFile !== '' && plugin_package_path_ok($flagFile) && is_file($dir . '/' . $flagFile) && filesize($dir . '/' . $flagFile) <= I18N_FLAG_MAX_BYTES) {
$ext = strtolower(pathinfo($flagFile, PATHINFO_EXTENSION));
$mime = ['svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp'][$ext] ?? '';
if ($mime !== '') $flag = 'data:' . $mime . ';base64,' . base64_encode((string) file_get_contents($dir . '/' . $flagFile));
}
$stringsFile = (string) ($l['strings'] ?? 'strings.json');
if (!plugin_package_path_ok($stringsFile) || strtolower(pathinfo($stringsFile, PATHINFO_EXTENSION)) !== 'json') $stringsFile = '';
$langs[$code] = [
'code' => $code,
'name' => mb_substr(trim((string) ($l['name'] ?? $code)), 0, 40) ?: $code,
'flag' => $flag !== '' ? $flag : ($code === 'de' ? i18n_builtin_flag_de() : ''),
'htmlLang' => preg_match('/^[A-Za-z\-]{2,12}$/', (string) ($l['htmlLang'] ?? '')) ? (string) $l['htmlLang'] : $code,
'slug' => $slug,
'strings' => $stringsFile !== '' ? $dir . '/' . $stringsFile : '',
];
}
return $cache = $langs;
}
/** Standardsprache der Seite (Einstellung), sonst Deutsch. */
function i18n_default(): string
{
$d = strtolower((string) get_setting('default_language', 'de'));
return isset(i18n_languages()[$d]) ? $d : 'de';
}
/** Sprache dieser Anfrage: Cookie, sonst Standardsprache. */
function i18n_current(): string
{
$c = strtolower((string) ($_COOKIE[I18N_COOKIE] ?? ''));
return $c !== '' && isset(i18n_languages()[$c]) ? $c : i18n_default();
}
/**
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
* @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,count:int}
*/
function i18n_dictionary(string $code): array
{
static $cache = [];
if (isset($cache[$code])) return $cache[$code];
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
$file = i18n_languages()[$code]['strings'] ?? '';
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict;
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) return $cache[$code] = $dict;
foreach ($data as $key => $value) {
$key = (string) $key;
if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue;
$norm = i18n_normalize($key);
if (str_contains($norm, '{}') || str_contains($norm, '{#}')) {
// {} = beliebiger Text, {#} = nur Ziffern
$pieces = preg_split('/(\{#\}|\{\})/', $norm, -1, PREG_SPLIT_DELIM_CAPTURE);
$regex = '/^';
foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)');
$regex .= '$/su';
$dict['patterns'][] = [$regex, $value];
} else {
$dict['exact'][$norm] = $value;
}
$dict['count']++;
}
return $cache[$code] = $dict;
}
/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */
function i18n_normalize(string $text): string
{
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
}
/** Übersetzt einen (normalisierten) Text; null = keine Übersetzung bekannt. */
function i18n_lookup(string $norm, array $dict): ?string
{
if ($norm === '') return null;
if (isset($dict['exact'][$norm])) return $dict['exact'][$norm];
foreach ($dict['patterns'] as [$regex, $value]) {
if (preg_match($regex, $norm, $m)) {
$value = preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1]] ?? '', $value);
$i = 0;
return preg_replace_callback('/\{\}/', function () use ($m, &$i) { $i++; return $m[$i] ?? ''; }, (string) $value);
}
}
return null;
}
/** Texte, die nicht übersetzt werden müssen: Zahlen, Adressen, Zeiten, reine Zeichen. */
function i18n_translatable(string $norm): bool
{
if (mb_strlen($norm) < 2 || mb_strlen($norm) > 400) return false;
if (!preg_match('/\p{L}{2}/u', $norm)) return false;
if (preg_match('#^(https?://|/|[\w.+-]+@[\w-]+\.)#i', $norm)) return false;
return true;
}
/**
* Übersetzt eine HTML-Seite. $seen sammelt (wenn übergeben) die deutschen Texte, für die es keine Übersetzung gab.
* @param array<string,bool>|null $seen
*/
function i18n_translate_html(string $html, string $code, ?array &$seen = null): string
{
$dict = $code === 'de' ? ['exact' => [], 'patterns' => [], 'count' => 0] : i18n_dictionary($code);
$collect = $seen !== null;
if (!$collect && !$dict['count']) return $html;
$note = function (string $norm) use (&$seen, $collect): void {
if ($collect && i18n_translatable($norm)) $seen[$norm] = true;
};
$tr = function (string $norm) use ($dict, $note): ?string {
$t = i18n_lookup($norm, $dict);
if ($t === null) $note($norm);
return $t;
};
// JavaScript: Textliterale, die genau einem Schlüssel entsprechen
$jsLiterals = function (string $js, bool $collectCalls) use ($dict, $note): string {
return (string) preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($m) use ($dict, $note) {
$raw = str_replace(["\\'", '\\"', '\\\\', '\\n'], ["'", '"', '\\', ' '], $m[2]);
$norm = trim((string) preg_replace('/\s+/u', ' ', $raw));
if ($norm === '') return $m[0];
$t = i18n_lookup($norm, $dict);
return $t === null ? $m[0] : json_encode($t, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}, $js);
};
$jsCollect = function (string $js) use ($note, $dict): void {
if (preg_match_all('/(?:confirm|alert|prompt|toast)\(\s*(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', $js, $mm)) {
foreach ($mm[2] as $lit) {
$norm = trim((string) preg_replace('/\s+/u', ' ', str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $lit)));
if (i18n_lookup($norm, $dict) === null) $note($norm); // schon Übersetztes nicht noch einmal sammeln
}
}
};
// Attribute eines Tags
$tag = function (string $t) use ($tr, $dict, $note, $jsCollect): string {
$isButton = (bool) preg_match('/^<input\b[^>]*\btype\s*=\s*["\']?(submit|button|reset)\b/i', $t);
return (string) preg_replace_callback('/\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(")([^"]*)"|\b(placeholder|title|alt|aria-label|value|on[a-z]+)\s*=\s*(\')([^\']*)\'/i', function ($m) use ($tr, $dict, $isButton, $jsCollect) {
$name = strtolower($m[1] !== '' ? $m[1] : $m[4]);
$quote = $m[1] !== '' ? $m[2] : $m[5];
$val = $m[1] !== '' ? $m[3] : $m[6];
if ($name === 'value' && !$isButton) return $m[0];
if (str_starts_with($name, 'on')) {
$js = html_entity_decode($val, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$jsCollect($js);
$new = preg_replace_callback('/(["\'])((?:\\\\.|(?!\1)[^\\\\\r\n])*)\1/u', function ($x) use ($dict) {
$raw = str_replace(["\\'", '\\"', '\\\\'], ["'", '"', '\\'], $x[2]);
$t = i18n_lookup(trim((string) preg_replace('/\s+/u', ' ', $raw)), $dict);
return $t === null ? $x[0] : "'" . addcslashes($t, "'\\") . "'";
}, $js);
return $new === $js ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars((string) $new, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}
$norm = i18n_normalize($val);
$t = $tr($norm);
return $t === null ? $m[0] : substr($m[0], 0, strpos($m[0], '=') + 1) . $quote . htmlspecialchars($t, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8') . $quote;
}, $t);
};
$parts = preg_split('/(<!--.*?-->|<script\b.*?<\/script>|<style\b.*?<\/style>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($parts === false) return $html;
$out = '';
$skip = false; // innerhalb <textarea> stehen Eingaben, keine Beschriftungen
foreach ($parts as $i => $part) {
if ($part === '') continue;
if ($i % 2 === 0) { // Text
if ($skip || trim($part) === '') { $out .= $part; continue; }
if (!preg_match('/^(\s*)(.*?)(\s*)$/su', $part, $m)) { $out .= $part; continue; }
$t = $tr(i18n_normalize($m[2]));
$out .= $t === null ? $part : $m[1] . htmlspecialchars($t, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') . $m[3];
continue;
}
if ($part[0] === '<' && $part[1] === '!') { $out .= $part; continue; } // Kommentar / doctype
if (stripos($part, '<script') === 0) {
if (preg_match('/^(<script\b[^>]*>)(.*)(<\/script>)$/is', $part, $m) && !preg_match('/\bsrc\s*=/i', $m[1])) {
if ($collect) $jsCollect($m[2]);
$out .= $m[1] . ($dict['count'] ? $jsLiterals($m[2], $collect) : $m[2]) . $m[3];
} else {
$out .= $part;
}
continue;
}
if (stripos($part, '<style') === 0) { $out .= $part; continue; }
if (stripos($part, '<textarea') === 0) $skip = true;
elseif (stripos($part, '</textarea') === 0) $skip = false;
if (stripos($part, '<html') === 0 && $code !== 'de') {
$lang = i18n_languages()[$code]['htmlLang'] ?? $code;
$part = preg_match('/\blang\s*=/i', $part) ? (string) preg_replace('/\blang\s*=\s*(["\'])[^"\']*\1/i', 'lang="' . $lang . '"', $part, 1) : (string) preg_replace('/^<html/i', '<html lang="' . $lang . '"', $part, 1);
}
$out .= $tag($part);
}
return $out;
}
/** Ausgabe-Filter der Seite (ob_start-Callback): nur HTML-Antworten werden angefasst. */
function i18n_output_filter(string $buffer, int $phase = 0)
{
try {
foreach (headers_list() as $h) {
if (stripos($h, 'content-type:') === 0 && stripos($h, 'text/html') === false) return false;
}
if (strlen($buffer) < 20 || !preg_match('/<[a-z!]/i', $buffer) || strlen($buffer) > 6 * 1024 * 1024) return false;
$code = $GLOBALS['__i18n_code'] ?? 'de';
$seen = null;
if (get_setting('i18n_collect', '0') === '1') $seen = [];
$html = i18n_translate_html($buffer, $code, $seen);
if ($seen) i18n_remember(array_keys($seen));
return $html;
} catch (Throwable $e) {
return false;
}
}
/** Merkt sich unübersetzte Texte (Sammelmodus). Höchstens 6000 Einträge. */
function i18n_remember(array $texts): void
{
$pdo = get_db();
$texts = array_slice(array_values(array_unique(array_map(fn($t) => mb_substr((string) $t, 0, 400), $texts))), 0, 400);
if (!$texts) return;
$hashes = array_map('md5', $texts);
$in = implode(',', array_fill(0, count($hashes), '?'));
$have = $pdo->prepare("SELECT text_hash FROM i18n_missing WHERE text_hash IN ($in)");
$have->execute($hashes);
$known = array_flip($have->fetchAll(PDO::FETCH_COLUMN));
$room = 6000 - (int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn();
$ins = $pdo->prepare('INSERT INTO i18n_missing (text_hash, text, hits, last_seen) VALUES (?,?,1,?)');
$upd = $pdo->prepare('UPDATE i18n_missing SET hits = hits + 1, last_seen = ? WHERE text_hash = ?');
foreach ($texts as $k => $t) {
if (isset($known[$hashes[$k]])) { $upd->execute([db_now(), $hashes[$k]]); continue; }
if ($room-- <= 0) break;
try { $ins->execute([$hashes[$k], $t, db_now()]); } catch (Throwable $e) { }
}
}
/** Startet die Übersetzung dieser Anfrage (wird von bootstrap.php aufgerufen). */
function i18n_start(): void
{
if (PHP_SAPI === 'cli' || defined('DESKHUB_NO_I18N')) return;
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
if (str_starts_with($path, '/api/') || str_ends_with($path, '/lang.php')) return;
$code = i18n_current();
$GLOBALS['__i18n_code'] = $code;
if ($code === 'de' && get_setting('i18n_collect', '0') !== '1') return;
ob_start('i18n_output_filter');
}
/** Pfad für „zurück“ nach dem Sprachwechsel: nur Adressen dieser Seite. */
function i18n_safe_back(string $back): string
{
if ($back === '' || $back[0] !== '/' || str_starts_with($back, '//') || str_contains($back, '\\') || preg_match('/[\r\n\0]/', $back)) return '/';
return $back;
}
/**
* Sprachwechsler mit Flaggen. Leer, solange es nur eine Sprache gibt.
* $variant: "inline" (helle Leiste), "dark" (dunkle Seitenleiste) oder "floating" (feste Position oben rechts, für Anmeldeseiten).
*/
function i18n_switcher_html(string $variant = 'inline'): string
{
$langs = i18n_languages();
if (count($langs) < 2) return '';
$cur = $GLOBALS['__i18n_code'] ?? i18n_current();
$back = rawurlencode((string) ($_SERVER['REQUEST_URI'] ?? '/'));
$wrapStyle = 'display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap;' . ($variant === 'floating' ? 'position:fixed;bottom:14px;right:14px;z-index:9999;background:rgba(255,255,255,.88);padding:5px 8px;border-radius:20px;box-shadow:0 1px 4px rgba(0,0,0,.18)' : '');
$html = '<span class="lang-switch" role="group" aria-label="Language" style="' . $wrapStyle . '">';
foreach ($langs as $code => $l) {
$on = $code === $cur;
$inner = $l['flag'] !== ''
? '<img src="' . htmlspecialchars($l['flag'], ENT_QUOTES) . '" alt="' . htmlspecialchars($l['name'], ENT_QUOTES) . '" width="24" height="16" style="display:block;width:24px;height:16px;object-fit:cover;border-radius:3px">'
: '<span style="font-size:.75rem;font-weight:700">' . htmlspecialchars(strtoupper($code)) . '</span>';
$html .= '<a href="/lang.php?l=' . rawurlencode($code) . '&amp;back=' . $back . '" hreflang="' . htmlspecialchars($l['htmlLang'], ENT_QUOTES) . '" title="' . htmlspecialchars($l['name'], ENT_QUOTES) . '"'
. ' style="display:inline-block;line-height:0;padding:2px;border-radius:5px;text-decoration:none;opacity:' . ($on ? '1' : '.6') . ';outline:' . ($on ? '2px solid ' . ($variant === 'dark' ? '#8bc4c2' : 'var(--primary,#0f6f6a)') : 'none') . ';outline-offset:1px"'
. ($on ? ' aria-current="true"' : '') . '>' . $inner . '</a>';
}
return $html . '</span>';
}
+2
View File
@@ -22,6 +22,8 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
'from_email' => get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''),
'from_name' => get_setting('smtp_from_name', config()['mail']['from_name'] ?? ''),
];
// Adressen mit der reservierten Endung .invalid (z. B. von Discord- oder Telegram-Nutzern ohne E-Mail) sind nie zustellbar: nichts senden, kein Fehler
if (preg_match('/@[^@\\s]+\\.invalid$/i', trim($to))) return true;
$smtpHost = get_setting('smtp_host', '');
$fromHeader = $fromOverride ?: smtp_default_from();
+107 -16
View File
@@ -32,6 +32,7 @@ function plugin_known_permissions(): array
'ui.menu' => 'Eigene Menüpunkte und Seiten im Mitarbeiterbereich',
'ui.sidebar' => 'Bausteine in der Seitenleiste der Ticketansicht',
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
];
}
@@ -39,7 +40,7 @@ function plugin_known_permissions(): array
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
function &plugin_state(): array
{
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
return $state;
}
@@ -118,14 +119,14 @@ function plugin_permission_allowed(?string $permission): bool
function plugin_hook_permission(string $hook): ?string
{
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'cron.run') return 'hooks.cron';
if ($hook === 'cron.run' || $hook === 'cron.minute') return 'hooks.cron';
return null;
}
/** Erforderliche Berechtigung für einen Filter. */
function plugin_filter_permission(string $hook): ?string
{
if ($hook === 'ticket.view.sidebar') return 'ui.sidebar';
if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar';
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
return null;
@@ -158,13 +159,29 @@ function plugin_register_menu(string $label, string $page, ?string $permission =
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]+$/i', $page)) return;
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && isset(plugin_parent_pages()[$belongsTo])) ? $belongsTo : null];
$state['menus'][] = ['plugin' => $slug, 'label' => mb_substr($label, 0, 60), 'page' => $page, 'permission' => $permission, 'belongs_to' => ($belongsTo !== null && ($belongsTo === 'admin' || isset(plugin_parent_pages()[$belongsTo]))) ? $belongsTo : null];
}
/**
* Eigener API-Endpunkt: GET/POST /api/staff/v1/plugin.php?p=<slug>&endpoint=<name> (Bearer-Token wie die übrige Mitarbeiter-API).
* Der Handler erhält ($staff, $request) und gibt ein Array zurück, das als JSON ausgeliefert wird.
*/
/**
* Registriert eine öffentliche Webhook-Adresse: POST /api/public/v1/plugin_webhook.php?p=<slug>&e=<name>. Gedacht für Dienste, die selbst
* Anfragen schicken (Discord, Telegram, Zahlungsanbieter). Die Adresse ist ohne Anmeldung erreichbar; das Plugin MUSS die Echtheit der Anfrage
* selbst prüfen (Signatur oder geheimes Token) und ungültige Anfragen mit Status 401 beantworten, bevor es etwas tut.
* Der Handler erhält ['method', 'headers' (Namen klein), 'body' (roher Text, höchstens 256 KB), 'query', 'ip'] und gibt
* ['status' => 200, 'json' => [...]] oder ['status' => 200, 'body' => 'Text', 'headers' => ['Content-Type' => '...']] zurück.
*/
function plugin_register_webhook(string $endpoint, callable $handler): void
{
$state = &plugin_state();
$slug = $state['current'];
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
if (!plugin_permission_allowed('api.public')) return;
$state['webhooks'][$slug][$endpoint] = $handler;
}
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
{
if (!plugin_permission_allowed('api.endpoints')) return;
@@ -216,10 +233,21 @@ function plugin_record_failure(string $slug, string $message): void
$pdo = get_db();
$pdo->prepare('UPDATE plugins SET fail_count = fail_count + 1, last_error = ? WHERE slug = ?')->execute([mb_substr($message, 0, 500), $slug]);
$fails = (int) $pdo->query('SELECT fail_count FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn();
if ($fails >= PLUGIN_FAIL_LIMIT) {
$disabled = $fails >= PLUGIN_FAIL_LIMIT;
if ($disabled) {
$pdo->prepare('UPDATE plugins SET is_active = 0 WHERE slug = ?')->execute([$slug]);
error_log("plugin[{$slug}]: nach {$fails} Fehlern automatisch deaktiviert");
}
// Mail an den Administrator (gleiche Drosselung wie bei schweren Fehlern: dieselbe Meldung höchstens einmal pro Stunde)
if (function_exists('error_alert_notice')) {
$name = (string) ($pdo->query('SELECT name FROM plugins WHERE slug = ' . $pdo->quote($slug))->fetchColumn() ?: $slug);
error_alert_notice(
$disabled ? "Plugin „{$name}“ wurde wegen Fehlern deaktiviert" : "Plugin „{$name}“ meldet einen Fehler",
$disabled ? "Das Plugin „{$name}“ ({$slug}) hat {$fails} Fehler verursacht und wurde automatisch deaktiviert. Die Webseite selbst läuft weiter. Die Funktionen des Plugins stehen erst wieder zur Verfügung, wenn Sie es unter Plugins erneut aktivieren." : "Das Plugin „{$name}“ ({$slug}) hat einen Fehler gemeldet (Fehler {$fails} von " . PLUGIN_FAIL_LIMIT . '). Nach ' . PLUGIN_FAIL_LIMIT . ' Fehlern wird es automatisch deaktiviert.',
'plugins/' . $slug . ($disabled ? ' (deaktiviert)' : ''),
$message
);
}
} catch (Throwable $e) {
// Die Fehlerbehandlung selbst darf nie einen weiteren Fehler auslösen.
}
@@ -261,7 +289,7 @@ function plugins_boot(): void
if (plugins_globally_disabled()) return;
try {
$rows = get_db()->query('SELECT slug, approved_permissions, signature_ok FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll();
$rows = get_db()->query('SELECT slug, approved_permissions, signature_ok, package_hash FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll();
} catch (Throwable $e) {
return; // Tabelle fehlt (Datenbank noch nicht aktualisiert)
}
@@ -298,6 +326,7 @@ function plugins_boot(): void
continue;
}
$state['perms'][$slug] = $manifest['permissions'];
if (in_array('db.migrations', $manifest['permissions'], true)) plugin_ensure_migrated($slug, (string) ($row['package_hash'] ?? ''));
plugin_guard($slug, function () use ($dir, $manifest, $slug, &$state) {
require $dir . '/' . $manifest['entry']; // einmal pro Request (plugins_boot läuft nur einmal)
$state['loaded'][$slug] = $manifest;
@@ -305,6 +334,38 @@ function plugins_boot(): void
}
}
/**
* Spielt neue Datenbank-Änderungen eines Plugins ein, wenn sich sein Paket seit dem letzten Mal geändert hat (zum Beispiel nach einem Update
* des aktiven Plugins; sonst liefen sie erst bei der nächsten Aktivierung). Läuft vor dem Laden des Plugins, höchstens einmal je Paketstand.
*/
function plugin_ensure_migrated(string $slug, string $packageHash): void
{
if ($packageHash === '') return;
$key = 'plugin_migrated.' . $slug;
try {
if (get_setting($key, '') === $packageHash) return;
$res = plugin_run_migrations(get_db(), $slug);
if ($res['errors']) {
plugin_record_failure($slug, 'Datenbank-Änderung fehlgeschlagen: ' . implode('; ', $res['errors']));
return;
}
set_setting($key, $packageHash);
} catch (Throwable $e) {
plugin_record_failure($slug, 'Datenbank-Änderung fehlgeschlagen: ' . $e->getMessage());
}
}
/** Meldung nach Installation oder Update eines Plugins. @param array $res Ergebnis von plugin_install_zip(), $row Zeile aus plugins_installed() */
function plugin_install_message(array $res, array $row): string
{
$sig = $res['signed'] ? 'Signatur geprüft' : 'nicht signiert';
if (!$res['updated']) return "{$res['name']} {$res['version']} installiert ({$sig}). Es ist noch nicht aktiv.";
$msg = "{$res['name']} auf Version {$res['version']} aktualisiert ({$sig}).";
if ($res['needs_reapproval']) return $msg . ' Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $res['needs_reapproval']) . '). Das Plugin ist nicht aktiv: Bitte prüfen und beim Aktivieren bestätigen.';
if (!empty($row['is_active'])) return $msg . ' Das Plugin läuft weiter, neue Datenbank-Änderungen werden beim nächsten Aufruf automatisch eingespielt.';
return $msg . ' Es ist weiterhin nicht aktiv.';
}
/** Menüpunkte der geladenen Plugins, gefiltert nach Rechten des Mitarbeiters. */
function plugin_menu_items(?array $staff): array
{
@@ -317,6 +378,22 @@ function plugin_menu_items(?array $staff): array
return $items;
}
/**
* Menüpunkte im Adminportal (Gruppe „Plugins“): Plugins registrieren sie mit plugin_register_menu(…, 'admin'). Nur für echte Administratoren;
* ist zusätzlich eine Berechtigung angegeben, muss sie auch erfüllt sein.
*/
function plugin_admin_menu_items(?array $staff): array
{
$items = [];
if (!$staff || ($staff['role'] ?? '') !== 'admin') return $items;
foreach (plugin_state()['menus'] as $m) {
if (($m['belongs_to'] ?? null) !== 'admin') continue;
if ($m['permission'] !== null && !has_permission($staff, $m['permission'])) continue;
$items[] = $m + ['url' => '/staff/plugin.php?p=' . rawurlencode($m['plugin']) . '&page=' . rawurlencode($m['page'])];
}
return $items;
}
/** Seiten, unter denen Plugins Reiter anbieten dürfen: Schlüssel => [Bezeichnung des ersten Reiters, Adresse]. */
function plugin_parent_pages(): array
{
@@ -467,31 +544,38 @@ function plugin_catalog_url(): string
}
/**
* Katalog mit Zwischenspeicher (1 Stunde), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss.
* $refresh = true erzwingt einen neuen Abruf. Fehler werden nicht zwischengespeichert.
* Katalog mit kurzem Zwischenspeicher (Standard 5 Minuten), damit die Plugin-Seite nicht bei jedem Aufruf den Server abfragen muss und trotzdem
* (fast) immer aktuell ist. $refresh = true erzwingt einen neuen Abruf, $maxAge legt fest, wie alt der Zwischenspeicher höchstens sein darf.
* Ist der Katalog-Server gerade nicht erreichbar, zeigt die Seite den letzten Stand und fragt zwei Minuten lang nicht erneut, statt bei jedem
* Aufruf zu warten.
*
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int}
* @return array{ok:bool, error?:string, plugins?:array, cached_at?:int, stale?:bool}
*/
function plugin_catalog_cached(bool $refresh = false): array
function plugin_catalog_cached(bool $refresh = false, int $maxAge = 300): array
{
$url = plugin_catalog_url();
$key = hash('sha256', $url);
if (!$refresh) {
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
}
$usable = is_array($cached) && ($cached['key'] ?? '') === $key;
if (!$refresh && $usable) {
$age = time() - (int) ($cached['at'] ?? 0);
if ($age < $maxAge) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at']];
if (time() - (int) get_setting('plugin_catalog_fail_at', '0') < 120) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at'], 'stale' => true];
}
$res = plugin_catalog_fetch();
if ($res['ok']) {
set_setting('plugin_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'plugins' => $res['plugins']], JSON_UNESCAPED_UNICODE));
set_setting('plugin_catalog_fail_at', '0');
$res['cached_at'] = time();
return $res;
}
set_setting('plugin_catalog_fail_at', (string) time());
if (!$refresh && $usable) return ['ok' => true, 'plugins' => $cached['plugins'], 'cached_at' => (int) $cached['at'], 'stale' => true];
return $res;
}
/**
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung alle 30 Minuten aufgerufen), damit die Seiten sofort
* Hält die Kataloge im Hintergrund frisch (wird von der Automatisierung etwa alle 5 Minuten aufgerufen), damit die Seiten sofort
* anzeigen und nie auf den Katalog-Server warten müssen. Fehler (Server nicht erreichbar) lassen den alten Stand stehen.
*/
function catalog_refresh_background(): void
@@ -642,9 +726,11 @@ function plugin_catalog_fetch(): array
'slug' => $slug, 'name' => mb_substr(trim((string) ($e['name'] ?? $slug)), 0, 120), 'version' => $version,
'description' => mb_substr((string) ($e['description'] ?? ''), 0, 500), 'author' => mb_substr((string) ($e['author'] ?? ''), 0, 120),
'requires' => (string) ($e['requires'] ?? ''), 'permissions' => $perms, 'downloadUrl' => $dl, 'sha256' => $sha,
'signature' => trim((string) ($e['signature'] ?? '')), 'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')),
'signature' => trim((string) ($e['signature'] ?? '')), 'preview' => catalog_preview_url($url, (string) ($e['previewUrl'] ?? '')), 'featured' => !empty($e['featured']), 'language' => (is_array($e['language'] ?? null) && preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', (string) ($e['language']['code'] ?? ''))) ? ['code' => (string) $e['language']['code'], 'name' => mb_substr((string) ($e['language']['name'] ?? ''), 0, 40)] : null,
];
}
// Empfohlene zuerst, sonst die Reihenfolge des Katalogs
usort($plugins, fn($a, $b) => (int) $b['featured'] <=> (int) $a['featured']); // stabil: sonst bleibt die Reihenfolge des Katalogs
return ['ok' => true, 'plugins' => $plugins];
}
@@ -784,6 +870,11 @@ function plugin_install_zip(PDO $pdo, string $zipPath, ?string $expectedSha256 =
->execute([$manifest['name'], $manifest['version'], $hash, $now, $signed ? 1 : 0, $source, $slug]);
// Fordert die neue Version Berechtigungen an, die nie bestätigt wurden, wird ein aktives Plugin sofort angehalten
$needsReapproval = array_values(array_diff($manifest['permissions'], array_filter(explode(',', (string) ($existing['approved_permissions'] ?? '')))));
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung (wie bei der Aktivierung)
if ($needsReapproval && $source === 'catalog' && $signed) {
if ($existing['is_active']) $pdo->prepare('UPDATE plugins SET approved_permissions = ? WHERE slug = ?')->execute([implode(',', $manifest['permissions']), $slug]);
$needsReapproval = [];
}
if ($needsReapproval && $existing['is_active']) {
$pdo->prepare('UPDATE plugins SET is_active = 0, last_error = ? WHERE slug = ?')
->execute(['Die neue Version fordert zusätzliche Berechtigungen an (' . implode(', ', $needsReapproval) . '). Bitte prüfen und erneut aktivieren.', $slug]);
+5 -7
View File
@@ -40,6 +40,8 @@ function pseudo_cron_job_definitions(): array
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'],
'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
];
}
@@ -90,11 +92,12 @@ function pseudo_cron_dispatch(): void
$fn($pdo, false);
} catch (Throwable $e) {
error_log("pseudo_cron [$key]: " . $e->getMessage());
if (function_exists('error_alert_notice')) error_alert_notice("Hintergrundjob „{$key}“ ist fehlgeschlagen", "Der Hintergrundjob „{$key}“ hat einen Fehler gemeldet und wurde abgebrochen. Beim nächsten Intervall wird er erneut versucht.", 'cron/' . $job['file'], $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')');
}
}
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 30 Minuten)
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 1800)) {
// Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 5 Minuten)
if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280)) {
catalog_refresh_background();
}
@@ -108,11 +111,6 @@ function pseudo_cron_dispatch(): void
}
}
// Plugins: wiederkehrende Aufgaben (höchstens alle 5 Minuten)
if (pseudo_cron_try_claim($pdo, 'plugins_cron_last_run', 300)) {
plugin_hook('cron.run', ['time' => time()]);
}
// Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php,
// lebt bereits in bootstrap.php – kein require_once aus cron/ nötig).
if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) {
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* includes/status_page.php
*
* Statusseite für Kunden (status.php): Zustand der Dienste („Komponenten“) und Meldungen zu Störungen und Wartungen.
* Verwaltet wird sie im Mitarbeiterbereich (Adminportal → Statusseite) und über die Apps.
*/
function status_page_enabled(): bool
{
return get_setting('status_page_enabled', '0') === '1';
}
/** Zustände einer Komponente, vom besten zum schlechtesten. Schlüssel => [Beschriftung, Farbe, Rang]. */
function status_component_states(): array
{
return [
'operational' => ['In Betrieb', '#157a45', 0],
'maintenance' => ['Wartung', '#2563eb', 1],
'degraded' => ['Eingeschränkt', '#b45309', 2],
'outage' => ['Ausfall', '#dc2626', 3],
];
}
/** Stufen einer Störungsmeldung. */
function status_incident_states(): array
{
return [
'investigating' => 'Wird untersucht',
'identified' => 'Ursache gefunden',
'monitoring' => 'Wird beobachtet',
'resolved' => 'Behoben',
];
}
function status_components(PDO $pdo): array
{
return $pdo->query('SELECT * FROM status_components ORDER BY sort_order, name')->fetchAll();
}
/** Gesamtzustand: der schlechteste Zustand aller Komponenten und offener Störungen. @return array{key:string,label:string,color:string} */
function status_overall(PDO $pdo): array
{
$states = status_component_states();
$worst = 'operational';
foreach (status_components($pdo) 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 WHERE status <> 'resolved'")->fetchColumn();
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]];
}
/** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): array
{
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
$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 >= ?)") . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
$stmt = $pdo->prepare($sql);
$stmt->execute($onlyOpen ? [] : [$since]);
$rows = $stmt->fetchAll();
$up = $pdo->prepare('SELECT * FROM status_incident_updates WHERE incident_id = ? ORDER BY created_at DESC, id DESC');
foreach ($rows as &$r) {
$up->execute([$r['id']]);
$r['updates'] = $up->fetchAll();
}
return $rows;
}
function status_set_component(PDO $pdo, int $id, string $status): bool
{
if (!isset(status_component_states()[$status])) return false;
$s = $pdo->prepare('UPDATE status_components SET status = ? WHERE id = ?');
$s->execute([$status, $id]);
return $s->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
function status_save_component(PDO $pdo, ?int $id, string $name, string $description, int $sort): ?int
{
$name = trim(mb_substr($name, 0, 120));
if ($name === '') return null;
$description = trim(mb_substr($description, 0, 255));
if ($id) {
$pdo->prepare('UPDATE status_components SET name = ?, description = ?, sort_order = ? WHERE id = ?')->execute([$name, $description !== '' ? $description : null, $sort, $id]);
return $id;
}
$pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at) VALUES (?,?,?,?,?)')->execute([$name, $description !== '' ? $description : null, 'operational', $sort, db_now()]);
return (int) $pdo->lastInsertId();
}
function status_delete_component(PDO $pdo, int $id): void
{
$pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]);
}
/**
* Neue Störungs- oder Wartungsmeldung. Mit $componentState wird die zugeordnete Komponente gleich auf diesen Zustand gesetzt.
* @return array{ok:bool,id?:int,error?:string}
*/
function status_create_incident(PDO $pdo, string $title, string $body, string $status, ?int $componentId, ?string $componentState, ?int $staffId): array
{
$title = trim(mb_substr($title, 0, 200));
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel angeben.'];
if (!isset(status_incident_states()[$status])) $status = 'investigating';
if ($componentId !== null) {
$c = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ?');
$c->execute([$componentId]);
if (!(int) $c->fetchColumn()) $componentId = null;
}
$now = db_now();
$pdo->prepare('INSERT INTO status_incidents (title, body, status, impact, component_id, created_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$title, trim($body) !== '' ? trim($body) : null, $status, 'minor', $componentId, $staffId, $now, $now, $status === 'resolved' ? $now : null]);
$id = (int) $pdo->lastInsertId();
if (trim($body) !== '') {
$pdo->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')->execute([$id, $status, trim($body), $staffId, $now]);
}
if ($componentId !== null && $componentState !== null && isset(status_component_states()[$componentState])) status_set_component($pdo, $componentId, $componentState);
return ['ok' => true, 'id' => $id];
}
/**
* Update zu einer Meldung. Wird sie als „Behoben“ markiert, setzt die zugeordnete Komponente zurück auf „In Betrieb“, wenn $restore true ist.
* @return array{ok:bool,error?:string}
*/
function status_update_incident(PDO $pdo, int $id, string $status, string $body, ?int $staffId, bool $restore = true): array
{
if (!isset(status_incident_states()[$status])) return ['ok' => false, 'error' => 'Unbekannter Status.'];
$s = $pdo->prepare('SELECT * FROM status_incidents WHERE id = ?');
$s->execute([$id]);
$inc = $s->fetch();
if (!$inc) return ['ok' => false, 'error' => 'Meldung nicht gefunden.'];
$now = db_now();
$pdo->prepare('UPDATE status_incidents SET status = ?, updated_at = ?, resolved_at = ? WHERE id = ?')
->execute([$status, $now, $status === 'resolved' ? ($inc['resolved_at'] ?: $now) : null, $id]);
$pdo->prepare('INSERT INTO status_incident_updates (incident_id, status, body, created_by, created_at) VALUES (?,?,?,?,?)')
->execute([$id, $status, trim(mb_substr($body, 0, 4000)), $staffId, $now]);
if ($status === 'resolved' && $restore && $inc['component_id']) {
// Nur zurücksetzen, wenn keine andere offene Meldung dieselbe Komponente betrifft
$o = $pdo->prepare("SELECT COUNT(*) FROM status_incidents WHERE component_id = ? AND status <> 'resolved' AND id <> ?");
$o->execute([$inc['component_id'], $id]);
if (!(int) $o->fetchColumn()) status_set_component($pdo, (int) $inc['component_id'], 'operational');
}
return ['ok' => true];
}
function status_delete_incident(PDO $pdo, int $id): void
{
$pdo->prepare('DELETE FROM status_incident_updates WHERE incident_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_incidents WHERE id = ?')->execute([$id]);
}
/** Zusammenfassung für die Apps. */
function status_summary(PDO $pdo): array
{
$states = status_component_states();
$overall = status_overall($pdo);
return [
'enabled' => status_page_enabled(),
'title' => (string) get_setting('status_page_title', 'Systemstatus'),
'url' => base_url() . '/status.php',
'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'] ?? ''), 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status']], 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,
'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'],
'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']),
], status_incidents($pdo, 14)),
];
}
+13 -7
View File
@@ -45,24 +45,30 @@ function theme_catalog_fetch(): array
}
/**
* Theme-Katalog mit Zwischenspeicher (1 Stunde), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen Abruf.
* Theme-Katalog mit kurzem Zwischenspeicher (Standard 5 Minuten), damit er ohne Klick sofort angezeigt wird. $refresh erzwingt einen neuen
* Abruf. Ist der Katalog-Server nicht erreichbar, bleibt der letzte Stand sichtbar und es wird zwei Minuten lang nicht erneut gefragt.
*
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int}
* @return array{ok:bool, error?:string, themes?:array, cached_at?:int, stale?:bool}
*/
function theme_catalog_cached(bool $refresh = false): array
function theme_catalog_cached(bool $refresh = false, int $maxAge = 300): array
{
$key = hash('sha256', theme_catalog_url());
if (!$refresh) {
$cached = json_decode((string) get_setting('theme_catalog_cache', ''), true);
if (is_array($cached) && ($cached['key'] ?? '') === $key && time() - (int) ($cached['at'] ?? 0) < 3600) {
return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
}
$usable = is_array($cached) && ($cached['key'] ?? '') === $key;
if (!$refresh && $usable) {
$age = time() - (int) ($cached['at'] ?? 0);
if ($age < $maxAge) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at']];
if (time() - (int) get_setting('theme_catalog_fail_at', '0') < 120) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at'], 'stale' => true];
}
$res = theme_catalog_fetch();
if ($res['ok']) {
set_setting('theme_catalog_cache', json_encode(['key' => $key, 'at' => time(), 'themes' => $res['themes']], JSON_UNESCAPED_UNICODE));
set_setting('theme_catalog_fail_at', '0');
$res['cached_at'] = time();
return $res;
}
set_setting('theme_catalog_fail_at', (string) time());
if (!$refresh && $usable) return ['ok' => true, 'themes' => $cached['themes'], 'cached_at' => (int) $cached['at'], 'stale' => true];
return $res;
}
@@ -0,0 +1,75 @@
<?php
/**
* includes/ticket_templates.php
*
* Ticket-Vorlagen: vorbelegte Formulare (Betreff, Text, Abteilung, Hilfethema, Priorität), zum Beispiel „Neuer Mitarbeiter“ oder
* „Passwort zurücksetzen“. Kunden wählen sie auf der Seite „Neues Ticket“ (wenn „für Kunden sichtbar“), Mitarbeiter beim Anlegen
* eines Tickets in Web, Windows-Programm und App.
*/
/** @return list<array> aktive Vorlagen; $publicOnly = nur die für Kunden sichtbaren */
function ticket_templates_list(PDO $pdo, bool $publicOnly = false, bool $includeInactive = false): array
{
$sql = 'SELECT * FROM ticket_templates WHERE 1 = 1' . ($includeInactive ? '' : ' AND is_active = 1') . ($publicOnly ? ' AND is_public = 1' : '') . ' ORDER BY sort_order, name';
return $pdo->query($sql)->fetchAll();
}
function ticket_template_get(PDO $pdo, int $id, bool $publicOnly = false): ?array
{
$s = $pdo->prepare('SELECT * FROM ticket_templates WHERE id = ? AND is_active = 1' . ($publicOnly ? ' AND is_public = 1' : ''));
$s->execute([$id]);
return $s->fetch() ?: null;
}
/**
* Legt eine Vorlage an oder ändert sie.
* @return array{ok:bool,id?:int,error?:string}
*/
function ticket_template_save(PDO $pdo, ?int $id, array $in): array
{
$name = trim(mb_substr((string) ($in['name'] ?? ''), 0, 120));
if ($name === '') return ['ok' => false, 'error' => 'Bitte einen Namen angeben.'];
$opt = function (string $key, string $table) use ($pdo, $in): ?int {
$v = (int) ($in[$key] ?? 0);
if ($v < 1) return null;
$c = $pdo->prepare("SELECT COUNT(*) FROM $table WHERE id = ?");
$c->execute([$v]);
return (int) $c->fetchColumn() ? $v : null;
};
$row = [
$name,
trim(mb_substr((string) ($in['description'] ?? ''), 0, 255)) ?: null,
trim(mb_substr((string) ($in['subject'] ?? ''), 0, 200)),
trim(mb_substr((string) ($in['body'] ?? ''), 0, 10000)) ?: null,
$opt('department_id', 'departments'),
$opt('help_topic_id', 'help_topics'),
$opt('priority_id', 'priorities'),
!empty($in['is_public']) ? 1 : 0,
!isset($in['is_active']) || !empty($in['is_active']) ? 1 : 0,
(int) ($in['sort_order'] ?? 0),
];
if ($id) {
$pdo->prepare('UPDATE ticket_templates SET name=?, description=?, subject=?, body=?, department_id=?, help_topic_id=?, priority_id=?, is_public=?, is_active=?, sort_order=? WHERE id=?')->execute([...$row, $id]);
return ['ok' => true, 'id' => $id];
}
$pdo->prepare('INSERT INTO ticket_templates (name, description, subject, body, department_id, help_topic_id, priority_id, is_public, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')->execute([...$row, db_now()]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}
function ticket_template_delete(PDO $pdo, int $id): void
{
$pdo->prepare('DELETE FROM ticket_templates WHERE id = ?')->execute([$id]);
}
/** Vorlage als Feldliste für die Apps und die Formulare. */
function ticket_template_payload(array $t): array
{
return [
'id' => (int) $t['id'], 'name' => (string) $t['name'], 'description' => (string) ($t['description'] ?? ''),
'subject' => (string) $t['subject'], 'body' => (string) ($t['body'] ?? ''),
'departmentId' => $t['department_id'] !== null ? (int) $t['department_id'] : null,
'helpTopicId' => $t['help_topic_id'] !== null ? (int) $t['help_topic_id'] : null,
'priorityId' => $t['priority_id'] !== null ? (int) $t['priority_id'] : null,
'isPublic' => (bool) $t['is_public'],
];
}
@@ -31,6 +31,8 @@ function web_handoff_pages(array $staff): array
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin],
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin],
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin],
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $isAdmin],
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $isAdmin],
['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin],
['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin],
['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin],