Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:24 +00:00
parent de06066052
commit 5a602de484
50 changed files with 8948 additions and 0 deletions
@@ -0,0 +1,44 @@
<?php
/**
* staff/_dashboard_recent_partial.php
*
* Rendert NUR die "Priorisiert"-Tabelle des Dashboards. Wird von staff/dashboard.php an
* zwei Stellen genutzt: normal beim vollständigen Seitenaufbau, und bei ?recent_partial=1
* als einziger Output für den Live-Refresh per JS (siehe <script> weiter unten in
* dashboard.php) – ohne das wäre z.B. ein Ticket, das per App geschlossen wurde, hier erst
* nach einem manuellen Reload verschwunden (siehe Nutzerrückmeldung: App zeigte
* "Geschlossen", das Dashboard hier noch "Offen", bis neu geladen wurde).
* Erwartet die von dashboard.php bereits berechnete Variable $recent.
*/
?>
<div id="dashboard-recent-region">
<h3>Priorisiert (mir zugewiesen oder unbearbeitet)</h3>
<div class="card">
<?php if (!$recent): ?>
<div class="empty-state">Keine offenen Tickets – gute Arbeit!</div>
<?php else: ?>
<table>
<thead><tr><th>Ticket</th><th>Betreff</th><th>Abteilung</th><th>Priorität</th><th>Status</th><th>Fällig</th></tr></thead>
<tbody>
<?php foreach ($recent as $t):
[$label, $color] = status_label($t['status']);
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time();
?>
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
<td>
<a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>"><?= e($t['subject']) ?></a><br>
<span class="muted" style="font-size:.78rem"><?= e($t['requester_name']) ?></span>
<span class="muted" style="font-size:.75rem; color:var(--muted-lt)"> · <?= e($t['requester_email']) ?></span>
</td>
<td><?= e($t['department_name']) ?></td>
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
<td class="muted"><?= time_until($t['resolution_due_at']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
+5
View File
@@ -0,0 +1,5 @@
</main>
</div>
<script src="/assets/js/richtext.js"></script>
</body>
</html>
+410
View File
@@ -0,0 +1,410 @@
<?php
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
$staff = current_staff();
$activePage = $activePage ?? '';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title>
<meta name="csrf" content="<?= csrf_token() ?>">
<?php
// VAPID Public Key – aus Config oder automatisch aus DB
if (file_exists(__DIR__ . '/../includes/push.php')) {
require_once __DIR__ . '/../includes/push.php';
$__vapidKeys = get_or_create_vapid_keys();
$pushKey = $__vapidKeys['public_key'] ?? '';
} else {
$pushKey = config()['push']['public_key'] ?? '';
}
?>
<?php if ($pushKey): ?>
<meta name="push-key" content="<?= e($pushKey) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">
<link rel="manifest" href="/manifest-staff.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php $faviconLogo = get_setting('logo_path'); if ($faviconLogo): ?>
<link rel="icon" href="<?= e($faviconLogo) ?>">
<?php endif; ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
}
</script>
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<?php $logo = get_setting('logo_path'); ?>
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block">
<?php else: ?>
Desk<span>Hub</span>
<?php endif; ?>
</div>
<nav>
<?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt
// (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?>
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a>
<a href="/staff/agent_directory.php" class="<?= $activePage==='agent_directory'?'active':'' ?>">Verzeichnis</a>
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?>
<?php
// Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet),
// auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst).
[$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'
AND (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) = 'customer'"
. ($__lcDeptSql !== '' ? " AND $__lcDeptSql" : '');
$__lcStmt = get_db()->prepare($__lcSql);
$__lcStmt->execute($__lcDeptParams);
$__lcPending = (int) $__lcStmt->fetchColumn();
?>
<a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>">
Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?>
</a>
<?php endif; ?>
<?php
// Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle
// gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()).
// has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine
// Rechteprüfung je Link – kein zusätzliches role==='admin' nötig.
$hasAnyAdminAccess = false;
if ($staff) {
foreach (array_keys(admin_section_permissions()) as $__perm) {
if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; }
}
}
?>
<?php if ($hasAnyAdminAccess): ?>
<div class="nav-section-label" id="admin-toggle" role="button" tabindex="0">
Adminportal <span class="nav-toggle-chevron">▾</span>
</div>
<div class="nav-admin-block" id="admin-block">
<span class="nav-group-label">Verwalten</span>
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
<span class="nav-group-label">Kunden</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
<span class="nav-group-label">Agenten</span>
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<span class="nav-group-label">System</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
</div>
<script>
(function () {
var toggle = document.getElementById('admin-toggle');
var block = document.getElementById('admin-block');
var KEY = 'deskhub_admin_nav_collapsed';
// In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt
var forceOpen = block.querySelector('a.active') !== null;
function apply(collapsed) {
block.style.display = collapsed ? 'none' : '';
toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾';
}
var stored = localStorage.getItem(KEY);
apply(!forceOpen && stored === '1');
toggle.addEventListener('click', function () {
var collapsed = block.style.display !== 'none';
apply(collapsed);
localStorage.setItem(KEY, collapsed ? '1' : '0');
});
toggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
});
})();
</script>
<?php endif; ?>
</nav>
<div class="sidebar-footer">
<?php if ($staff): ?>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px">
<?php if (!empty($staff['avatar_path'])): ?>
<img src="<?= e($staff['avatar_path']) ?>" alt="" style="width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; border:1px solid #2a3744">
<?php else: ?>
<div style="width:32px; height:32px; border-radius:50%; background:rgba(15,111,106,.3); display:flex; align-items:center; justify-content:center; font-size:.85rem; font-weight:700; color:#8bc4c2; flex-shrink:0">
<?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?>
</div>
<?php endif; ?>
<div style="min-width:0">
<div style="font-size:.82rem; font-weight:600; color:#fff; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($staff['name']) ?></div>
<div style="font-size:.72rem; color:#4e6278"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div>
<?php if (!empty($staff['is_on_vacation'])): ?>
<div style="font-size:.7rem; color:var(--accent)">🌴 Im Urlaub</div>
<?php endif; ?>
</div>
</div>
<div style="display:flex; gap:14px; align-items:center">
<?php
$__unreadMentions = 0;
$__unreadChat = 0;
if ($staff) {
$__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
$__ms->execute([$staff['id']]);
$__unreadMentions = (int)$__ms->fetchColumn();
$__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0');
$__cs->execute([$staff['id']]);
$__unreadChat = (int)$__cs->fetchColumn();
}
?>
<a href="/staff/chat.php" title="Interner Chat" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
💬<?php if ($__unreadChat > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?>
</a>
<a href="/staff/mentions.php" title="Erwähnungen" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
🔔<?php if ($__unreadMentions > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?>
</a>
<a href="/staff/profile.php" style="color:#8b97a3; font-size:.78rem">Profil</a>
<a href="/staff/logout.php" style="color:#8b97a3; font-size:.78rem">Abmelden</a>
</div>
<?php endif; ?>
</div>
</aside>
<?php
// Timeout in JS übergeben
$_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
$_jsLastAct = $_SESSION['staff_last_activity'] ?? time();
if (!isset($_SESSION['staff_last_activity'])) {
$_SESSION['staff_last_activity'] = time();
$_jsLastAct = time();
}
$_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time());
?>
<script>
window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>;
window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
</script>
<script>
(function() {
var remaining = window.sessionTimerRemaining || 0;
var timeoutMin = window.sessionTimerTimeout || 30;
function init() {
var el = document.getElementById('session-timer');
var bar = document.getElementById('session-timer-bar');
if (!el || !bar || remaining <= 0) return;
function fmt(s) {
var m = Math.floor(s / 60);
var sec = s % 60;
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function tick() {
if (remaining <= 0) {
window.location.href = '/staff/login.php';
return;
}
el.textContent = fmt(remaining);
// Immer sichtbar
bar.style.opacity = '1';
bar.style.pointerEvents = 'auto';
// Farbe nach Dringlichkeit
if (remaining <= 60) {
bar.style.background = 'rgba(239,68,68,.9)';
bar.style.borderColor = 'rgba(239,68,68,.5)';
el.style.color = '#fff';
} else if (remaining <= 300) {
bar.style.background = 'rgba(245,158,11,.9)';
bar.style.borderColor = 'rgba(245,158,11,.5)';
el.style.color = '#fff';
} else {
bar.style.background = 'rgba(15,23,42,.72)';
bar.style.borderColor = 'rgba(255,255,255,.1)';
el.style.color = '#cbd5e1';
}
remaining--;
setTimeout(tick, 1000);
}
tick();
// Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen
var resetDebounce;
function resetTimer() {
clearTimeout(resetDebounce);
resetDebounce = setTimeout(function() {
fetch('/staff/session_ping.php', { method:'POST',
headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''},
credentials:'same-origin' })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.remaining) remaining = d.remaining; })
.catch(function(){});
}, 2000); // max. alle 2 Sek. ein Ping
}
['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) {
document.addEventListener(ev, resetTimer, { passive: true });
});
} // end init
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
<script>
// ── Browser Push Notifications ───────────────────────────────────────────
(function() {
var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value;
if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
var btn = document.getElementById('push-toggle-btn');
function urlB64ToUint8(b64) {
var pad = '='.repeat((4 - b64.length % 4) % 4);
var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad);
return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); }));
}
function setBtn(active) {
if (!btn) return;
btn.textContent = active ? '🔔' : '🔕';
btn.style.color = active ? '#22c55e' : '#8b97a3';
btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
}
navigator.serviceWorker.ready.then(function(reg) {
reg.pushManager.getSubscription().then(function(s) {
setBtn(!!(s && Notification.permission === 'granted'));
});
});
window.togglePushNotifications = function() {
navigator.serviceWorker.ready.then(function(reg) {
reg.pushManager.getSubscription().then(function(sub) {
if (sub) {
sub.unsubscribe().then(function() {
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) });
setBtn(false);
});
} else {
Notification.requestPermission().then(function(perm) {
if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; }
reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) })
.then(function(ns) {
var j = ns.toJSON();
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) })
.then(function() { setBtn(true); });
}).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); });
});
}
});
});
};
})();
</script>
<main class="main">
<!-- Session-Timer: kompakte Pille oben rechts -->
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
</div>
<?php foreach (get_flashes() as $f): ?>
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<?php endforeach; ?>
<?php
if (isset($staff) && has_permission($staff, 'manage_email_addresses')) {
foreach (system_health(get_db()) as $__issue): ?>
<div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?>
<a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a>
</div>
<?php endforeach; } ?>
<?php
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') {
require_once __DIR__ . '/../database/migrate.php';
$__pending = migrations_pending(get_db());
if ($__pending): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank.
<a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a>
</div>
<?php endif; } ?>
<?php
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) {
$__upd = plugin_updates_available(get_db());
if ($__upd): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?>
<a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a>
</div>
<?php endif; } ?>
<?php $lockedOverride = get_locked_ticket_override(); ?>
<?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten.
<form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="admin_force_unlock">
<input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>">
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button>
</form>
</div>
<?php endif; ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet.
Bitte in <code>config/config.php</code> auf <code>driver&nbsp;=&nbsp;'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen.
</div>
<?php endif; ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt.
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen.
Generieren: <code>php&nbsp;-r&nbsp;"echo&nbsp;bin2hex(random_bytes(32));"</code>
</div>
<?php endif; ?>
@@ -0,0 +1,10 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
header('Content-Type: application/json');
$n = trim($_GET['n'] ?? '');
$stmt = get_db()->prepare('SELECT id, subject, department_id FROM tickets WHERE ticket_number = ?');
$stmt->execute([$n]);
$t = $stmt->fetch();
if ($t && !staff_can_access_department($staff, (int) $t['department_id'])) $t = false; // Betreff fremder Abteilungen nicht preisgeben
echo json_encode($t ? ['id' => $t['id'], 'subject' => $t['subject']] : ['id' => null]);
@@ -0,0 +1,90 @@
<?php
/**
* staff/_tickets_list_partial.php
*
* Rendert NUR die Ticket-Tabelle (inkl. "X Treffer"-Banner und Pagination), ohne
* Kopf/Filterleiste/Fußzeile. Wird von staff/tickets.php an zwei Stellen genutzt:
* 1) normal eingebunden beim vollständigen Seitenaufbau
* 2) bei ?partial=1 als einziger Output für den Live-Refresh per JS (siehe
* dortiges <script> weiter unten in tickets.php) – damit die Tabelle alle paar
* Sekunden ohne Seiten-Reload aktuell bleibt, auch während ein Mitarbeiter
* gerade auf der Liste ist.
* Erwartet dieselben Variablen, die tickets.php vor dem Einbinden bereits berechnet
* hat ($tickets, $ticketTags, $totalPages, $page, $totalCount, $departments, $search,
* $filterDept/$filterStatus/... sowie $_GET für die Pagination-Links).
*/
?>
<div id="tickets-live-region">
<form method="post" id="bulk-form-tickets">
<?= csrf_field() ?>
<input type="hidden" name="bulk_action" id="bulk_action_tickets" value="">
<div class="card">
<?php if ($search || $filterDept || ($filterStatus && $filterStatus !== '') || $filterPrio || $filterAssign || $filterTag || $filterSpam): ?>
<div style="padding:8px 12px; margin-bottom:12px; background:var(--primary-lt); border-radius:6px; font-size:.84rem; color:var(--primary-dk); font-weight:500">
<?= count($tickets) ?> Treffer
<?php if ($search): ?> für „<?= e($search) ?>"<?php endif; ?>
<?php if ($filterDept): ?> · Abteilung: <?= e(array_column($departments, 'name', 'id')[$filterDept] ?? '') ?><?php endif; ?>
· <a href="/staff/tickets.php" style="color:var(--primary-dk)">Filter zurücksetzen</a>
</div>
<?php endif; ?>
<?php if (!$tickets): ?>
<div class="empty-state">Keine Tickets gefunden.</div>
<?php else: ?>
<table>
<thead><tr>
<th style="width:24px"><input type="checkbox" onclick="document.querySelectorAll('.ticket-check').forEach(c=>c.checked=this.checked)"></th>
<th>Ticket</th><th>Betreff</th><th>Abteilung</th><th>Priorität</th><th>Status</th><th>Zugewiesen</th><th>Fällig</th>
</tr></thead>
<tbody>
<?php foreach ($tickets as $t):
[$label, $color] = status_label($t['status']);
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time() && !in_array($t['status'], ['resolved','closed']);
?>
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
<td><input type="checkbox" class="ticket-check" name="ticket_ids[]" value="<?= $t['id'] ?>"></td>
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>"><?= e($t['subject']) ?></a><br>
<span class="muted" style="font-size:.78rem"><?= e($t['requester_name']) ?></span>
<span class="muted" style="font-size:.75rem; color:var(--muted-lt)"> · <?= e($t['requester_email']) ?></span>
<?php if (!empty($ticketTags[$t['id']])): ?>
<div style="margin-top:4px">
<?php foreach ($ticketTags[$t['id']] as $tg): ?>
<span class="badge" style="background:<?= e($tg['color']) ?>; font-size:.7rem"><?= e($tg['name']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</td>
<td><?= e($t['department_name']) ?></td>
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
<td><?= $t['staff_name'] ? e($t['staff_name']) : '<span class="muted">–</span>' ?></td>
<td class="muted"><?= time_until($t['resolution_due_at']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</form>
<?php if ($totalPages > 1): ?>
<div style="display:flex;justify-content:center;align-items:center;gap:6px;margin-top:20px;flex-wrap:wrap">
<?php
$baseUrl = '/staff/tickets.php?' . http_build_query(array_diff_key($_GET, ['page'=>'','partial'=>''])) . '&page=';
$start = max(1, $page-2);
$end = min($totalPages, $page+2);
?>
<?php if ($page > 1): ?>
<a href="<?= $baseUrl.($page-1) ?>" class="btn secondary" style="padding:6px 12px">← Zurück</a>
<?php endif; ?>
<?php for ($p = $start; $p <= $end; $p++): ?>
<a href="<?= $baseUrl.$p ?>" class="btn <?= $p===$page?'':'secondary' ?>" style="padding:6px 12px;<?= $p===$page?'font-weight:700':'' ?>"><?= $p ?></a>
<?php endfor; ?>
<?php if ($page < $totalPages): ?>
<a href="<?= $baseUrl.($page+1) ?>" class="btn secondary" style="padding:6px 12px">Weiter →</a>
<?php endif; ?>
<span class="muted" style="font-size:.82rem"><?= number_format($totalCount) ?> Tickets gesamt</span>
</div>
<?php endif; ?>
</div>
+52
View File
@@ -0,0 +1,52 @@
<?php
/**
* Gemeinsame UI-Bausteine für die Verwaltungsseiten im osTicket-Stil
* (Liste mit Spalten, danach ein Formular mit Reitern).
*/
/** CSS + JS für Reiter. $startTab = data-tab des zuerst gezeigten Reiters. */
function ui_tab_assets(string $startTab): void
{
?>
<style>
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
.tab-btn.active{background:var(--card-bg,#fff);border-color:var(--border);color:inherit;font-weight:600;position:relative;top:1px}
</style>
<script>
(function () {
var btns = document.querySelectorAll('.tab-btn'), panes = document.querySelectorAll('.tab-pane');
function show(t) {
btns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
panes.forEach(function (p) { p.style.display = p.dataset.pane === t ? '' : 'none'; });
}
btns.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
show(<?= json_encode($startTab) ?>);
})();
</script>
<?php
}
/** Reiterleiste: $tabs = ['key' => 'Beschriftung', …] */
function ui_tab_bar(array $tabs): void
{
echo '<div style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">';
foreach ($tabs as $key => $label) {
echo '<button type="button" class="tab-btn" data-tab="' . e($key) . '">' . e($label) . '</button>';
}
echo '</div>';
}
/** Status-Auswahl (Aktiv/Deaktiviert) als Radio-Buttons; Feldname "status" mit den Werten active|inactive. */
function ui_status_radios(bool $active, string $inactiveLabel = 'Deaktiviert'): void
{
echo '<label>Status *</label><div style="display:flex;gap:18px;margin:4px 0 10px">';
echo '<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="active" style="width:auto"' . ($active ? ' checked' : '') . '> <strong>Aktiv</strong></label>';
echo '<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="inactive" style="width:auto"' . ($active ? '' : ' checked') . '> ' . e($inactiveLabel) . '</label>';
echo '</div>';
}
/** Datum für Listenspalten ("–" bei leer). */
function ui_date(?string $dt): string
{
return $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
}
+202
View File
@@ -0,0 +1,202 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
$errors = [];
$allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$title = trim($_POST['title'] ?? '');
$desc = trim($_POST['description'] ?? '');
$assignTo = ($_POST['assigned_to'] ?? '') !== '' ? (int)$_POST['assigned_to'] : null;
$dueAt = trim($_POST['due_at'] ?? '') ?: null;
$ticketId = ($_POST['ticket_id'] ?? '') !== '' ? (int)$_POST['ticket_id'] : null;
if ($title === '') $errors[] = 'Bitte einen Titel angeben.';
if (!$errors) {
$pdo->prepare('INSERT INTO tasks (ticket_id,title,description,assigned_to,due_at,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)')
->execute([$ticketId, $title, $desc ?: null, $assignTo, $dueAt, $staff['id'], db_now(), db_now()]);
flash('success', 'Aufgabe erstellt.');
if ($ticketId) {
$tn = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id=?'); $tn->execute([$ticketId]);
redirect('/staff/ticket_view.php?n=' . urlencode($tn->fetchColumn()));
}
redirect('/staff/tasks.php');
}
}
if ($action === 'update_status') {
$id = (int) $_POST['id'];
$status = in_array($_POST['status']??'', ['open','in_progress','done']) ? $_POST['status'] : 'open';
$pdo->prepare('UPDATE tasks SET status=?, updated_at=? WHERE id=?')->execute([$status, db_now(), $id]);
// Kein open redirect: Referer nur wenn er auf /staff/ zeigt
$ref = $_SERVER['HTTP_REFERER'] ?? '';
$safePath = (parse_url($ref, PHP_URL_HOST) === ($_SERVER['HTTP_HOST'] ?? '') && strpos(parse_url($ref, PHP_URL_PATH) ?? '', '/staff/') === 0)
? $ref : '/staff/tasks.php';
redirect($safePath);
}
if ($action === 'delete') {
$pdo->prepare('DELETE FROM tasks WHERE id=?')->execute([(int)$_POST['id']]);
flash('success', 'Aufgabe gelöscht.');
redirect('/staff/tasks.php');
}
}
$filter = $_GET['filter'] ?? 'my'; // my, all, open, done
$where = [];
$params = [];
if ($filter === 'my') {
$where[] = 'ta.assigned_to = ?'; $params[] = $staff['id'];
} elseif ($filter === 'open') {
$where[] = "ta.status != 'done'";
} elseif ($filter === 'done') {
$where[] = "ta.status = 'done'";
}
$sql = 'SELECT ta.*, s.name AS assigned_name, s2.name AS creator_name,
t.ticket_number, t.subject AS ticket_subject
FROM tasks ta
LEFT JOIN staff s ON s.id = ta.assigned_to
LEFT JOIN staff s2 ON s2.id = ta.created_by
LEFT JOIN tickets t ON t.id = ta.ticket_id'
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY ta.due_at IS NULL, ta.due_at ASC, ta.created_at DESC LIMIT 200';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$tasks = $stmt->fetchAll();
$statusLabels = ['open'=>'Offen','in_progress'=>'In Arbeit','done'=>'Erledigt'];
$statusColors = ['open'=>'#2563eb','in_progress'=>'#d97706','done'=>'#16a34a'];
$pageTitle = 'Aufgaben'; $activePage = 'tasks';
require __DIR__ . '/_header.php';
?>
<div class="topbar">
<h1>Aufgaben</h1>
<button type="button" class="btn" onclick="document.getElementById('new-task-form').style.display=document.getElementById('new-task-form').style.display==='none'?'':'none'">+ Neue Aufgabe</button>
</div>
<?php foreach ($errors as $e): ?><div class="flash error"><?= e($e) ?></div><?php endforeach; ?>
<!-- Neue Aufgabe -->
<div id="new-task-form" class="card" style="display:none; margin-bottom:20px">
<h3>Neue Aufgabe</h3>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create">
<div class="field-row">
<div style="flex:2">
<label for="title">Titel <span style="color:var(--danger)">*</span></label>
<input type="text" id="title" name="title" placeholder="Was ist zu tun?" required>
</div>
<div>
<label for="due_at">Fällig bis</label>
<input type="datetime-local" id="due_at" name="due_at">
</div>
</div>
<div class="field-row">
<div>
<label for="assigned_to">Zuweisen an</label>
<select name="assigned_to" id="assigned_to">
<option value="<?= $staff['id'] ?>" selected><?= e($staff['name']) ?> (ich)</option>
<?php foreach ($allStaff as $s): if ($s['id'] == $staff['id']) continue; ?>
<option value="<?= $s['id'] ?>"><?= e($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="ticket_id_text">Ticket verknüpfen (Ticket-Nr., optional)</label>
<input type="text" id="ticket_id_text" placeholder="z.B. 260622-1234" oninput="lookupTicket(this.value)">
<input type="hidden" name="ticket_id" id="ticket_id_hidden">
<div id="ticket_lookup_result" style="font-size:.78rem; color:var(--muted); margin-top:2px"></div>
</div>
</div>
<label for="description">Beschreibung (optional)</label>
<textarea id="description" name="description" style="min-height:80px"></textarea>
<div class="btn-row">
<button type="submit" class="btn">Erstellen</button>
<button type="button" class="btn secondary" onclick="document.getElementById('new-task-form').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
<!-- Filter-Tabs -->
<div style="display:flex; gap:0; border-bottom:2px solid var(--border); margin-bottom:16px">
<?php foreach (['my'=>'Meine','all'=>'Alle','open'=>'Offen','done'=>'Erledigt'] as $f=>$lbl): ?>
<a href="/staff/tasks.php?filter=<?= $f ?>"
style="padding:8px 18px; font-size:.88rem; font-weight:600; text-decoration:none; border-bottom:3px solid <?= $filter===$f?'var(--primary)':'transparent' ?>; margin-bottom:-2px; color:<?= $filter===$f?'var(--primary)':'var(--muted)' ?>">
<?= $lbl ?>
</a>
<?php endforeach; ?>
</div>
<div class="card">
<table>
<thead><tr><th>Aufgabe</th><th>Verknüpftes Ticket</th><th>Zugewiesen</th><th>Fällig</th><th>Status</th><th></th></tr></thead>
<tbody>
<?php foreach ($tasks as $task):
$overdue = $task['due_at'] && $task['status'] !== 'done' && strtotime($task['due_at']) < time();
?>
<tr class="<?= $overdue ? 'is-overdue' : '' ?>">
<td>
<strong style="<?= $task['status']==='done' ? 'text-decoration:line-through;color:var(--muted)' : '' ?>"><?= e($task['title']) ?></strong>
<?php if ($task['description']): ?><br><span class="muted" style="font-size:.78rem"><?= e(mb_strimwidth($task['description'], 0, 80, '…')) ?></span><?php endif; ?>
</td>
<td>
<?php if ($task['ticket_number']): ?>
<a href="/staff/ticket_view.php?n=<?= e($task['ticket_number']) ?>" class="ticket-no">#<?= e($task['ticket_number']) ?></a><br>
<span class="muted" style="font-size:.76rem"><?= e(mb_strimwidth($task['ticket_subject']??'', 0, 40, '…')) ?></span>
<?php else: ?><span class="muted">–</span><?php endif; ?>
</td>
<td><?= e($task['assigned_name'] ?? '–') ?></td>
<td class="muted" style="white-space:nowrap; <?= $overdue ? 'color:var(--danger)' : '' ?>">
<?= $task['due_at'] ? format_dt($task['due_at']) : '–' ?>
</td>
<td>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_status">
<input type="hidden" name="id" value="<?= $task['id'] ?>">
<select name="status" onchange="this.form.submit()" style="padding:3px 6px; font-size:.78rem; border-radius:5px">
<?php foreach ($statusLabels as $sv=>$sl): ?>
<option value="<?= $sv ?>" <?= $task['status']===$sv?'selected':'' ?>><?= $sl ?></option>
<?php endforeach; ?>
</select>
</form>
</td>
<td>
<form method="post" style="display:inline" onsubmit="return confirm('Aufgabe löschen?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $task['id'] ?>">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.85rem">✕</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$tasks): ?><tr><td colspan="6" class="muted" style="text-align:center;padding:28px">Keine Aufgaben gefunden.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<script>
function lookupTicket(num) {
var n = num.trim();
var hidden = document.getElementById('ticket_id_hidden');
var result = document.getElementById('ticket_lookup_result');
if (!n) { hidden.value=''; result.textContent=''; return; }
fetch('/staff/_ticket_lookup_ajax.php?n=' + encodeURIComponent(n))
.then(r=>r.json()).then(d=>{
if (d.id) { hidden.value=d.id; result.textContent='✓ ' + d.subject; result.style.color='var(--success)'; }
else { hidden.value=''; result.textContent='Ticket nicht gefunden'; result.style.color='var(--danger)'; }
}).catch(()=>{hidden.value='';result.textContent='';});
}
</script>
<?php require __DIR__ . '/_footer.php'; ?>
+180
View File
@@ -0,0 +1,180 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_admin_or_permission('manage_teams');
$pdo = get_db();
$errors = [];
$allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
try {
team_save($pdo, $id, [
'name' => $_POST['name'] ?? '', 'lead_id' => $_POST['lead_id'] ?? null, 'notes' => $_POST['notes'] ?? '',
'is_active' => ($_POST['status'] ?? 'active') === 'active', 'no_alerts' => !empty($_POST['no_alerts']),
'members' => $_POST['members'] ?? [],
]);
audit_log($staff['id'], ($action === 'create' ? 'Team erstellt: ' : 'Team aktualisiert: ') . trim($_POST['name'] ?? ''));
flash('success', $action === 'create' ? 'Team erstellt.' : 'Änderungen gespeichert.');
redirect('/staff/teams.php');
} catch (AdminModelException $e) {
$errors[] = $e->getMessage();
}
}
if ($action === 'delete') {
$deleted = team_delete($pdo, (int) $_POST['id']);
audit_log($staff['id'], "Team gelöscht: {$deleted}");
flash('success', 'Team gelöscht.');
redirect('/staff/teams.php');
}
}
$isNew = isset($_GET['new']);
$editId = (int) ($_GET['edit'] ?? 0);
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
$editTeam = null; $editMembers = [];
if ($editId) {
$es = $pdo->prepare('SELECT * FROM teams WHERE id = ?'); $es->execute([$editId]);
$editTeam = $es->fetch() ?: null;
$em = $pdo->prepare('SELECT staff_id FROM team_members WHERE team_id = ?'); $em->execute([$editId]);
$editMembers = array_map('intval', $em->fetchAll(PDO::FETCH_COLUMN));
}
if ($_POST && $errors) $editMembers = array_map('intval', (array) ($_POST['members'] ?? []));
$teams = $pdo->query(
'SELECT t.*, s.name AS lead_name,
(SELECT COUNT(*) FROM team_members tm WHERE tm.team_id = t.id) AS member_count
FROM teams t LEFT JOIN staff s ON s.id = t.lead_id ORDER BY t.name'
)->fetchAll();
$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
$pageTitle = 'Teams'; $activePage = 'teams';
require __DIR__ . '/_header.php';
?>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($showForm): ?>
<?php
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
$v = fn(string $k, string $col, $default = '') => $f($k, $editTeam[$col] ?? $default);
$curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editTeam['is_active'] ?? 1) ? 'active' : 'inactive');
$curLead = (string) $v('lead_id', 'lead_id');
$noAlerts = $_POST ? !empty($_POST['no_alerts']) : !empty($editTeam['no_alerts']);
?>
<h1>
<?= $editTeam ? 'Team aktualisieren' : 'Neues Team' ?>
<?php if ($editTeam): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editTeam['name']) ?></span><?php endif; ?>
</h1>
<form method="post" class="card" style="padding:0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="<?= $editTeam ? 'update' : 'create' ?>">
<?php if ($editTeam): ?><input type="hidden" name="id" value="<?= (int) $editTeam['id'] ?>"><?php endif; ?>
<div style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">
<button type="button" class="tab-btn" data-tab="team">Team</button>
<button type="button" class="tab-btn" data-tab="members">Mitglieder (<?= count($editMembers) ?>)</button>
</div>
<div class="tab-pane" data-pane="team" style="padding:16px 20px">
<h3 style="margin-top:0">Teaminformationen</h3>
<label for="name">Name *</label>
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" required>
<label>Status *</label>
<div style="display:flex;gap:18px;margin:4px 0 10px">
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="active" style="width:auto" <?= $curStatus === 'active' ? 'checked' : '' ?>> <strong>Aktiv</strong></label>
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="inactive" style="width:auto" <?= $curStatus !== 'active' ? 'checked' : '' ?>> Deaktiviert</label>
</div>
<label for="lead_id">Teamleiter</label>
<select name="lead_id" id="lead_id" style="max-width:320px">
<option value="">— Keine(r) —</option>
<?php foreach ($allStaff as $s): ?>
<option value="<?= $s['id'] ?>" <?= $curLead === (string) $s['id'] ? 'selected' : '' ?>><?= e($s['name']) ?></option>
<?php endforeach; ?>
</select>
<label style="display:flex;align-items:center;gap:8px;margin-top:14px">
<input type="checkbox" name="no_alerts" style="width:auto" <?= $noAlerts ? 'checked' : '' ?>>
Benachrichtigung bei Ticketzuweisung für dieses Team <strong>deaktivieren</strong>
</label>
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen, sichtbar für alle Administratoren</span></h3>
<textarea id="notes" name="notes" rows="6"><?= e($v('notes', 'notes')) ?></textarea>
</div>
<div class="tab-pane" data-pane="members" style="padding:16px 20px;display:none">
<h3 style="margin-top:0">Mitglieder</h3>
<?php if (!$allStaff): ?>
<p class="muted">Es gibt keine aktiven Mitarbeiter.</p>
<?php else: foreach ($allStaff as $s): ?>
<label style="display:flex;align-items:center;gap:8px;padding:4px 0;font-weight:400;cursor:pointer">
<input type="checkbox" name="members[]" value="<?= $s['id'] ?>" style="width:auto" <?= in_array((int) $s['id'], $editMembers, true) ? 'checked' : '' ?>>
<?= e($s['name']) ?>
</label>
<?php endforeach; endif; ?>
</div>
<div class="btn-row" style="padding:0 20px 18px">
<button type="submit" class="btn"><?= $editTeam ? 'Änderungen speichern' : 'Erstellen' ?></button>
<a href="/staff/teams.php" class="btn secondary">Abbrechen</a>
</div>
</form>
<style>
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
.tab-btn.active{background:var(--card-bg,#fff);border-color:var(--border);color:inherit;font-weight:600;position:relative;top:1px}
</style>
<script>
(function () {
var btns = document.querySelectorAll('.tab-btn'), panes = document.querySelectorAll('.tab-pane');
function show(t) {
btns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
panes.forEach(function (p) { p.style.display = p.dataset.pane === t ? '' : 'none'; });
}
btns.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
show('team');
})();
</script>
<?php else: ?>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h1 style="margin:0">Teams</h1>
<a href="/staff/teams.php?new=1" class="btn">Neues Team hinzufügen</a>
</div>
<p class="muted">Abteilungsübergreifende Mitarbeiter-Gruppen. Tickets können einem Team statt einem einzelnen Mitarbeiter zugewiesen werden; die Mitglieder werden dabei benachrichtigt.</p>
<div class="card">
<table>
<thead><tr><th>Teamname</th><th>Status</th><th style="text-align:center">Mitglieder</th><th>Teamleiter</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
<tbody>
<?php foreach ($teams as $t): ?>
<tr>
<td><a href="/staff/teams.php?edit=<?= (int) $t['id'] ?>"><?= e($t['name']) ?></a></td>
<td><strong><?= $t['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
<td style="text-align:center"><a href="/staff/teams.php?edit=<?= (int) $t['id'] ?>"><?= (int) $t['member_count'] ?></a></td>
<td><?= e($t['lead_name'] ?? '') ?></td>
<td class="muted" style="font-size:.82rem"><?= e($fmt($t['created_at'] ?? null)) ?></td>
<td class="muted" style="font-size:.82rem"><?= e($fmt($t['updated_at'] ?? null)) ?></td>
<td style="white-space:nowrap">
<a href="/staff/teams.php?edit=<?= (int) $t['id'] ?>">Bearbeiten</a> ·
<form method="post" style="display:inline" onsubmit="return confirm('Team wirklich löschen?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int) $t['id'] ?>">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$teams): ?><tr><td colspan="7" class="muted">Noch keine Teams angelegt.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>
+341
View File
@@ -0,0 +1,341 @@
<?php
/**
* staff/ticket_create.php
*
* Ticket-Erstellung durch Mitarbeiter (z.B. Telefonanruf, Vor-Ort-Anfrage).
* Unterschied zu ticket_new.php (Kundenportal):
* – Kein Honeypot, kein Rate-Limit
* – Mitarbeiter wählt Abteilung, Priorität und Hilfe-Thema direkt
* – Kann im Namen jedes Kunden (Name + E-Mail) erstellen
* – source = 'phone' oder 'staff'
* – Optionale erste interne Notiz
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin (alle Abteilungen)
$departments = $pdo->query('SELECT * FROM departments WHERE is_active = 1 ORDER BY name')->fetchAll();
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$helpTopics = help_topic_options($pdo);
$allAgents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$customFields = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 ORDER BY sort_order, label')->fetchAll();
// Abteilungen auf zugängliche einschränken
if ($myDeptIds !== null) {
$departments = array_filter($departments, fn($d) => in_array((int) $d['id'], $myDeptIds, true));
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$requesterName = trim($_POST['requester_name'] ?? '');
$requesterEmail = trim($_POST['requester_email'] ?? '');
$subject = trim($_POST['subject'] ?? '');
$message = sanitize_staff_html(trim($_POST['message'] ?? ''));
$internalNote = sanitize_staff_html(trim($_POST['internal_note'] ?? ''));
$source = in_array($_POST['source'] ?? '', ['phone','staff','web']) ? $_POST['source'] : 'phone';
$deptId = (int) ($_POST['department_id'] ?? ($departments[0]['id'] ?? 0));
$prioId = (int) ($_POST['priority_id'] ?? ($priorities[0]['id'] ?? 0));
$helpTopicId = ($_POST['help_topic_id'] ?? '') !== '' ? (int) $_POST['help_topic_id'] : null;
$assignedId = ($_POST['assigned_staff_id'] ?? '') !== '' ? (int) $_POST['assigned_staff_id'] : null;
if ($requesterName === '') $errors[] = 'Bitte Name des Anfragers angeben.';
if ($requesterEmail !== '' && !filter_var($requesterEmail, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Bitte eine gültige E-Mail-Adresse angeben (oder das Feld leer lassen).';
}
if ($subject === '') $errors[] = 'Bitte einen Betreff angeben.';
if (trim(strip_tags($message)) === '') $errors[] = 'Bitte eine Beschreibung eingeben.';
if (!$errors) {
// Sperrliste prüfen (nur wenn E-Mail vorhanden)
if ($requesterEmail !== '' && is_banned($requesterEmail)) {
$errors[] = 'Diese E-Mail-Adresse steht auf der Sperrliste.';
}
}
if (!$errors) {
// Ticket-Regeln anwenden
$ruleResult = evaluate_ticket_rules($pdo, [
'subject' => $subject,
'message' => strip_tags($message),
'requester_email' => $requesterEmail,
'requester_name' => $requesterName,
'source' => $source,
]);
if ($ruleResult['set_department'] !== null) $deptId = $ruleResult['set_department'];
if ($ruleResult['set_priority'] !== null) $prioId = $ruleResult['set_priority'];
if ($ruleResult['set_help_topic'] !== null) $helpTopicId = $ruleResult['set_help_topic'];
if ($ruleResult['assign_staff'] !== null && $assignedId === null) $assignedId = $ruleResult['assign_staff'];
$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$prioStmt->execute([$prioId]);
$priority = $prioStmt->fetch();
$sla = calc_sla_dates($priority, null, (int) $deptId, $helpTopicId ? (int) $helpTopicId : null);
$now = db_now();
$ticketNumber = generate_ticket_number($pdo);
// Bekannten registrierten Kunden nachschlagen
$userId = null;
if ($requesterEmail !== '') {
$userStmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$userStmt->execute([$requesterEmail]);
$userId = $userStmt->fetchColumn() ?: null;
}
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id,
help_topic_id, priority_id, status, source, assigned_staff_id,
first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$ticketNumber, $subject, $userId, $requesterName, $requesterEmail, $deptId,
$helpTopicId, $prioId, 'open', $source, $assignedId,
$sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
]);
$ticketId = (int) $pdo->lastInsertId();
// Erste Nachricht eintragen (öffentlich, vom Mitarbeiter im Namen des Kunden)
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
)->execute([$ticketId, 'staff', $staff['id'], $message, $now]);
// Optionale interne Notiz
if (trim(strip_tags($internalNote)) !== '') {
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,1,?)'
)->execute([$ticketId, 'staff', $staff['id'], $internalNote, $now]);
}
// Individuelle Felder
if ($customFields) {
$cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)');
foreach ($customFields as $f) {
$fn = 'custom_' . $f['id'];
$val = $f['field_type'] === 'checkbox' ? (!empty($_POST[$fn]) ? '1' : '0') : trim($_POST[$fn] ?? '');
if ($val !== '' && $val !== '0') $cfStmt->execute([$ticketId, $f['id'], $val]);
}
}
log_activity($pdo, $ticketId, $staff['id'], "Ticket durch Mitarbeiter erstellt (Quelle: {$source})");
webhook_dispatch($pdo, 'ticket.created', $ticketId);
foreach ($ruleResult['add_tags'] as $tagName) apply_tag_to_ticket($pdo, $ticketId, $tagName);
if ($ruleResult['matched_rules']) {
log_activity($pdo, $ticketId, $staff['id'], 'Ticket-Regel: ' . implode(', ', $ruleResult['matched_rules']));
}
// Eingangsbestätigung an Kunden (nur wenn E-Mail vorhanden und nicht interne Quelle)
if ($requesterEmail !== '' && !empty($_POST['notify_customer'])) {
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$deptStmt->execute([$deptId]);
$dept = $deptStmt->fetch();
notify_customer_ticket_created([
'ticket_number' => $ticketNumber,
'subject' => $subject,
'requester_name' => $requesterName,
'requester_email' => $requesterEmail,
'department_id' => $deptId,
'department_name' => $dept['name'] ?? null,
]);
}
flash('success', "Ticket #{$ticketNumber} erfolgreich erstellt.");
redirect('/staff/ticket_view.php?n=' . urlencode($ticketNumber));
}
}
$pageTitle = 'Ticket erstellen';
$activePage = 'tickets';
require __DIR__ . '/_header.php';
?>
<div style="max-width:800px">
<div class="topbar">
<h1>Neues Ticket erstellen</h1>
<a href="/staff/tickets.php" class="btn secondary">← Zur Ticket-Liste</a>
</div>
<p class="muted">Ticket im Namen eines Kunden anlegen – z.B. bei Telefonanrufen oder Vor-Ort-Anfragen.</p>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<form method="post">
<?= csrf_field() ?>
<div class="card">
<h3>Anfrage-Quelle</h3>
<div style="display:flex; gap:10px; flex-wrap:wrap">
<?php foreach (['phone' => '📞 Telefon', 'staff' => '🖥 Vor Ort / Intern', 'web' => '🌐 Web (im Namen)'] as $val => $lbl): ?>
<label style="display:flex; align-items:center; gap:6px; font-weight:500; cursor:pointer; padding:9px 16px; border:2px solid var(--border); border-radius:8px; background:var(--surface); transition:.1s"
onclick="this.parentNode.querySelectorAll('label').forEach(l=>l.style.borderColor='var(--border)'); this.style.borderColor='var(--primary)'">
<input type="radio" name="source" value="<?= $val ?>" style="width:auto" <?= ($val==='phone')?'checked':'' ?>
onchange="this.closest('form').querySelector('[name=source]:checked').closest('label').style.borderColor='var(--primary)'">
<?= $lbl ?>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="card">
<h3>Kundendaten</h3>
<div class="field-row">
<div>
<label for="requester_name">Name <span style="color:var(--danger)">*</span></label>
<input type="text" id="requester_name" name="requester_name"
value="<?= e($_POST['requester_name'] ?? '') ?>"
placeholder="Vorname Nachname" required>
</div>
<div>
<label for="requester_email">E-Mail-Adresse <span class="muted" style="font-weight:400">(optional)</span></label>
<input type="email" id="requester_email" name="requester_email"
value="<?= e($_POST['requester_email'] ?? '') ?>"
placeholder="kunde@beispiel.de">
</div>
</div>
<label style="display:flex; align-items:center; gap:8px; margin-top:12px; font-weight:400; cursor:pointer">
<input type="checkbox" name="notify_customer" value="1" style="width:auto" checked>
<span>Eingangsbestätigung per E-Mail an Kunden senden (nur wenn E-Mail angegeben)</span>
</label>
</div>
<div class="card">
<h3>Ticket-Details</h3>
<label for="subject">Betreff <span style="color:var(--danger)">*</span></label>
<input type="text" id="subject" name="subject"
value="<?= e($_POST['subject'] ?? '') ?>"
placeholder="Worum geht es?" required>
<div class="field-row" style="margin-top:6px">
<div>
<label for="department_id">Abteilung</label>
<select name="department_id" id="department_id">
<?php foreach ($departments as $d): ?>
<option value="<?= $d['id'] ?>" <?= (($_POST['department_id'] ?? '') == $d['id']) ? 'selected' : '' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="priority_id">Priorität</label>
<select name="priority_id" id="priority_id">
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= (($_POST['priority_id'] ?? '') == $p['id']) ? 'selected' : '' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="field-row">
<?php if ($helpTopics): ?>
<div>
<label for="help_topic_id">Hilfe-Thema (optional)</label>
<select name="help_topic_id" id="help_topic_id">
<option value="">– kein Thema –</option>
<?php foreach ($helpTopics as $t): ?>
<option value="<?= $t['id'] ?>" <?= (($_POST['help_topic_id'] ?? '') == $t['id']) ? 'selected' : '' ?>><?= e($t['path']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div>
<label for="assigned_staff_id">Zuweisen an</label>
<select name="assigned_staff_id" id="assigned_staff_id">
<option value="">– nicht zugewiesen –</option>
<?php foreach ($allAgents as $a): ?>
<option value="<?= $a['id'] ?>" <?= (($_POST['assigned_staff_id'] ?? '') == $a['id'] || (!isset($_POST['assigned_staff_id']) && $a['id'] == $staff['id'])) ? 'selected' : '' ?>><?= e($a['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<label for="message">Inhalt / Zusammenfassung <span style="color:var(--danger)">*</span></label>
<div class="richtext-wrap">
<div class="richtext-toolbar">
<button type="button" onclick="document.execCommand('bold')"><strong>B</strong></button>
<button type="button" onclick="document.execCommand('italic')"><em>I</em></button>
<button type="button" onclick="document.execCommand('underline')"><u>U</u></button>
<button type="button" onclick="document.execCommand('insertUnorderedList')">•</button>
<button type="button" onclick="document.execCommand('insertOrderedList')">1.</button>
</div>
<div class="richtext-editable" id="msg-editor" contenteditable="true"><?= $_POST['message'] ?? '' ?></div>
</div>
<input type="hidden" name="message" id="msg-hidden">
</div>
<?php if ($customFields): ?>
<div class="card">
<h3>Individuelle Felder</h3>
<?php foreach ($customFields as $f):
$fn = 'custom_' . $f['id'];
$posted = $_POST[$fn] ?? '';
?>
<label for="<?= $fn ?>"><?= e($f['label']) ?><?= $f['is_required'] ? ' *' : '' ?></label>
<?php if ($f['field_type'] === 'textarea'): ?>
<textarea id="<?= $fn ?>" name="<?= $fn ?>" <?= $f['is_required'] ? 'required' : '' ?>><?= e($posted) ?></textarea>
<?php elseif ($f['field_type'] === 'select'): ?>
<select id="<?= $fn ?>" name="<?= $fn ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<option value="">– bitte wählen –</option>
<?php foreach (preg_split('/\r?\n/', trim((string)$f['options'])) as $opt): $opt = trim($opt); if (!$opt) continue; ?>
<option value="<?= e($opt) ?>" <?= $posted === $opt ? 'selected' : '' ?>><?= e($opt) ?></option>
<?php endforeach; ?>
</select>
<?php elseif ($f['field_type'] === 'checkbox'): ?>
<label style="display:flex; align-items:center; gap:8px; font-weight:400; margin-top:4px">
<input type="checkbox" id="<?= $fn ?>" name="<?= $fn ?>" value="1" style="width:auto" <?= !empty($posted) ? 'checked' : '' ?>>
<span><?= e($f['label']) ?></span>
</label>
<?php else: ?>
<input type="<?= $f['field_type'] === 'number' ? 'number' : ($f['field_type'] === 'date' ? 'date' : 'text') ?>"
id="<?= $fn ?>" name="<?= $fn ?>" value="<?= e($posted) ?>" <?= $f['is_required'] ? 'required' : '' ?>>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="card">
<h3>Interne Notiz <span class="muted" style="font-weight:400; font-size:.85rem">(optional, nur für Mitarbeiter sichtbar)</span></h3>
<div class="richtext-wrap">
<div class="richtext-toolbar">
<button type="button" onclick="document.execCommand('bold')"><strong>B</strong></button>
<button type="button" onclick="document.execCommand('italic')"><em>I</em></button>
<button type="button" onclick="document.execCommand('underline')"><u>U</u></button>
<button type="button" onclick="document.execCommand('insertUnorderedList')">•</button>
</div>
<div class="richtext-editable" id="note-editor" contenteditable="true" style="min-height:80px; border-color:var(--accent)"><?= $_POST['internal_note'] ?? '' ?></div>
</div>
<input type="hidden" name="internal_note" id="note-hidden">
</div>
<div class="btn-row" style="margin-bottom:40px">
<button type="submit" class="btn" id="submit-btn">Ticket erstellen</button>
<a href="/staff/tickets.php" class="btn secondary">Abbrechen</a>
</div>
</form>
</div>
<script>
document.getElementById('submit-btn').closest('form').addEventListener('submit', function () {
document.getElementById('msg-hidden').value = document.getElementById('msg-editor').innerHTML;
document.getElementById('note-hidden').value = document.getElementById('note-editor').innerHTML;
});
// Radio-Button Quell-Karten visuell updaten beim Laden
document.querySelectorAll('input[name="source"]').forEach(function (radio) {
radio.addEventListener('change', function () {
document.querySelectorAll('input[name="source"]').forEach(function (r) {
r.closest('label').style.borderColor = 'var(--border)';
});
this.closest('label').style.borderColor = 'var(--primary)';
});
if (radio.checked) radio.closest('label').style.borderColor = 'var(--primary)';
});
</script>
<?php require __DIR__ . '/_footer.php'; ?>
@@ -0,0 +1,72 @@
<?php
/**
* staff/ticket_merge_search.php
* JSON-Endpunkt für die Ticket-Zusammenführen-Suche.
* Gibt max. 8 Tickets zurück die zur Suchanfrage passen.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
header('Content-Type: application/json');
$q = trim($_GET['q'] ?? '');
$exclude = trim($_GET['exclude'] ?? '');
if (strlen($q) < 2) {
echo json_encode([]);
exit;
}
$myDeptIds = staff_department_ids($staff);
$like = '%' . $q . '%';
// Basis-Query: Ticket suchen (nicht geschlossene und nicht bereits zusammengeführte)
$sql = "SELECT t.ticket_number, t.subject, t.requester_name, t.requester_email,
t.status, t.created_at,
p.name AS priority_name,
d.name AS department_name,
s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id
WHERE t.merged_into IS NULL
AND t.status != 'closed'
AND (t.ticket_number LIKE ? OR t.subject LIKE ? OR t.requester_name LIKE ? OR t.requester_email LIKE ?)";
$params = [$like, $like, $like, $like];
// Ausschluss des aktuellen Tickets
if ($exclude !== '') {
$sql .= " AND t.ticket_number != ?";
$params[] = $exclude;
}
// Abteilungsfilter für Nicht-Admins
if ($myDeptIds !== null) {
$placeholders = implode(',', array_fill(0, count($myDeptIds), '?'));
$sql .= " AND t.department_id IN ($placeholders)";
$params = array_merge($params, $myDeptIds);
}
$sql .= " ORDER BY t.updated_at DESC LIMIT 8";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Nur nötige Felder zurückgeben
$out = array_map(fn($t) => [
'ticket_number' => $t['ticket_number'],
'subject' => $t['subject'],
'requester_name' => $t['requester_name'],
'requester_email' => $t['requester_email'],
'status' => $t['status'],
'priority_name' => $t['priority_name'],
'department_name' => $t['department_name'],
'staff_name' => $t['staff_name'] ?? '–',
'created_at' => $t['created_at'],
], $results);
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG);
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
header('Content-Type: application/json');
$pdo = get_db();
$number = $_GET['n'] ?? '';
$known = max(0, (int) ($_GET['count'] ?? 0));
$stmt = $pdo->prepare('SELECT id, department_id FROM tickets WHERE ticket_number = ?');
$stmt->execute([$number]);
$ticket = $stmt->fetch();
if (!$ticket || !staff_can_access_department($staff, (int) $ticket['department_id'])) {
echo json_encode(['new_count' => $known]);
exit;
}
// Mitarbeiter sehen auch interne Notizen
$stmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = ?');
$stmt->execute([$ticket['id']]);
$count = (int) $stmt->fetchColumn();
echo json_encode(['new_count' => $count]);
@@ -0,0 +1,44 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
header('Content-Type: application/json');
$ticketId = (int)($_GET['ticket_id'] ?? 0);
$action = $_GET['action'] ?? 'ping';
$pdo = get_db();
if (!$ticketId) { echo json_encode([]); exit; }
if ($action === 'leave') {
$pdo->prepare("DELETE FROM ticket_presence WHERE ticket_id=? AND staff_id=?")
->execute([$ticketId, $staff['id']]);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'ping') {
// Eigene Präsenz aktualisieren
$presenceSql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'
? 'INSERT INTO ticket_presence (ticket_id, staff_id, last_seen_at) VALUES (?,?,?)
ON CONFLICT(ticket_id, staff_id) DO UPDATE SET last_seen_at=excluded.last_seen_at'
: 'INSERT INTO ticket_presence (ticket_id, staff_id, last_seen_at) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE last_seen_at=VALUES(last_seen_at)';
$pdo->prepare($presenceSql)->execute([$ticketId, $staff['id'], db_now()]);
// Alte Einträge (> 30 Sek.) löschen
$pdo->prepare('DELETE FROM ticket_presence WHERE last_seen_at < ?')
->execute([date('Y-m-d H:i:s', time() - 30)]);
}
// Alle aktiven Mitarbeiter auf diesem Ticket zurückgeben
$activeSince = date('Y-m-d H:i:s', time() - 30);
$stmt = $pdo->prepare(
"SELECT s.id, s.name, tp.last_seen_at
FROM ticket_presence tp
JOIN staff s ON s.id = tp.staff_id
WHERE tp.ticket_id = ? AND tp.staff_id != ?
AND tp.last_seen_at >= ?
ORDER BY s.name"
);
$stmt->execute([$ticketId, $staff['id'], $activeSince]);
echo json_encode($stmt->fetchAll());
+170
View File
@@ -0,0 +1,170 @@
<?php
/**
* staff/ticket_print.php
* Saubere Druckansicht eines Tickets – ohne Navigation, optimiert für Druck/PDF.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
$number = $_GET['n'] ?? '';
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, p.color AS priority_color,
d.name AS department_name, s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id
WHERE t.ticket_number = ?'
);
$stmt->execute([$number]);
$ticket = $stmt->fetch();
if (!$ticket) {
http_response_code(404);
die('Ticket nicht gefunden.');
}
if (!staff_can_access_department($staff, (int) $ticket['department_id'])) {
http_response_code(403);
die('Sie haben keinen Zugriff auf Tickets dieser Abteilung.');
}
// Thread laden
$thread = $pdo->prepare(
'SELECT tt.*, s.name AS staff_name, u.name AS user_name
FROM ticket_threads tt
LEFT JOIN staff s ON s.id = tt.staff_id
LEFT JOIN users u ON u.id = tt.user_id
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0
ORDER BY tt.created_at ASC'
);
$thread->execute([$ticket['id']]);
$messages = $thread->fetchAll();
// Anhänge
$attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?');
$siteName = get_setting('display_name', config()['site']['name'] ?? 'DeskHub');
[$statusLabel] = status_label($ticket['status']);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Ticket #<?= e($ticket['ticket_number']) ?> – <?= e($ticket['subject']) ?></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 11pt; color: #1e293b; background: #fff; padding: 20mm; }
.print-header { display: flex; justify-content: space-between; align-items: flex-start; padding-bottom: 12px; border-bottom: 2px solid #0f6f6a; margin-bottom: 16px; }
.print-header .logo { font-size: 18pt; font-weight: 800; color: #0f6f6a; }
.print-header .meta { text-align: right; font-size: 9pt; color: #64748b; }
.ticket-title { font-size: 16pt; font-weight: 700; margin-bottom: 12px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; margin-bottom: 20px; padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
.info-row { display: flex; gap: 8px; font-size: 9.5pt; }
.info-label { font-weight: 600; color: #64748b; min-width: 100px; }
.info-value { color: #1e293b; }
.section-title { font-size: 10pt; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #64748b; margin: 20px 0 10px; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; }
.message { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 12px; break-inside: avoid; }
.message-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 9pt; }
.message-header .author { font-weight: 600; }
.message-header .date { color: #64748b; }
.message-header.staff-header { background: #e6f4f3; }
.message-body { padding: 10px 12px; font-size: 10pt; line-height: 1.6; }
.attachments { padding: 6px 12px 8px; border-top: 1px solid #e2e8f0; font-size: 9pt; color: #64748b; }
.print-footer { margin-top: 24px; padding-top: 10px; border-top: 1px solid #e2e8f0; font-size: 8.5pt; color: #94a3b8; display: flex; justify-content: space-between; }
.no-print { }
@media print {
body { padding: 10mm; }
.no-print { display: none !important; }
@page { margin: 15mm; }
}
</style>
</head>
<body>
<!-- Drucken-Button (nur am Bildschirm) -->
<div class="no-print" style="position:fixed;top:16px;right:16px;display:flex;gap:8px;z-index:99">
<button onclick="window.print()" style="padding:8px 18px;background:#0f6f6a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.88rem;font-weight:600">🖨 Drucken / Als PDF speichern</button>
<a href="/staff/ticket_view.php?n=<?= urlencode($number) ?>" style="padding:8px 16px;border:1px solid #e2e8f0;border-radius:6px;text-decoration:none;color:#64748b;font-size:.88rem;background:#fff">← Zurück</a>
</div>
<!-- Kopfzeile -->
<div class="print-header">
<div class="logo"><?= e($siteName) ?></div>
<div class="meta">
Gedruckt: <?= date('d.m.Y H:i') ?><br>
von: <?= e($staff['name']) ?>
</div>
</div>
<!-- Ticket-Titel -->
<div class="ticket-title"><?= e($ticket['subject']) ?></div>
<!-- Ticket-Info -->
<div class="info-grid">
<div class="info-row"><span class="info-label">Ticket-Nr.:</span><span class="info-value">#<?= e($ticket['ticket_number']) ?></span></div>
<div class="info-row"><span class="info-label">Status:</span><span class="info-value"><?= e($statusLabel) ?></span></div>
<div class="info-row"><span class="info-label">Absender:</span><span class="info-value"><?= e($ticket['requester_name']) ?> &lt;<?= e($ticket['requester_email']) ?>&gt;</span></div>
<div class="info-row"><span class="info-label">Priorität:</span><span class="info-value"><?= e($ticket['priority_name']) ?></span></div>
<div class="info-row"><span class="info-label">Abteilung:</span><span class="info-value"><?= e($ticket['department_name']) ?></span></div>
<div class="info-row"><span class="info-label">Zugewiesen:</span><span class="info-value"><?= e($ticket['staff_name'] ?? '– nicht zugewiesen –') ?></span></div>
<div class="info-row"><span class="info-label">Erstellt:</span><span class="info-value"><?= date('d.m.Y H:i', strtotime($ticket['created_at'])) ?></span></div>
<?php if ($ticket['resolution_due_at']): ?>
<div class="info-row"><span class="info-label">Fällig bis:</span><span class="info-value"><?= date('d.m.Y H:i', strtotime($ticket['resolution_due_at'])) ?></span></div>
<?php endif; ?>
</div>
<!-- Nachrichten -->
<div class="section-title">Verlauf (<?= count($messages) ?> Nachricht<?= count($messages) !== 1 ? 'en' : '' ?>)</div>
<?php foreach ($messages as $msg):
$isStaff = $msg['author_type'] === 'staff';
$author = $isStaff ? ($msg['staff_name'] ?? 'Mitarbeiter') : ($msg['user_name'] ?? $ticket['requester_name']);
$body = $msg['message'] ?? '';
$isHtml = preg_match('/<[a-zA-Z][^>]*>/i', $body);
$attStmt->execute([$msg['id']]);
$atts = $attStmt->fetchAll();
?>
<div class="message">
<div class="message-header <?= $isStaff ? 'staff-header' : '' ?>">
<span class="author"><?= e($author) ?><?= $isStaff ? ' (Mitarbeiter)' : ' (Kunde)' ?></span>
<span class="date"><?= date('d.m.Y H:i', strtotime($msg['created_at'])) ?></span>
</div>
<div class="message-body">
<?php if ($isHtml): ?>
<?= sanitize_staff_html($body) ?>
<?php else: ?>
<?php
$text = trim($body);
foreach (preg_split('/\n{2,}/', $text) as $p) {
$p = trim($p);
if ($p !== '') echo '<p style="margin:0 0 8px">'.nl2br(e($p)).'</p>';
}
?>
<?php endif; ?>
</div>
<?php if ($atts): ?>
<div class="attachments">
📎 Anhänge: <?= implode(', ', array_map(fn($a) => e($a['original_name']), $atts)) ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<!-- Fußzeile -->
<div class="print-footer">
<span><?= e($siteName) ?> – Ticket #<?= e($ticket['ticket_number']) ?></span>
<span>Druckdatum: <?= date('d.m.Y H:i') ?></span>
</div>
</body>
</html>
+426
View File
@@ -0,0 +1,426 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$pdo = get_db();
$departments = departments_for_select($pdo, array_map('intval', $pdo->query("SELECT action_value FROM ticket_rule_actions WHERE action_type = 'set_department'")->fetchAll(PDO::FETCH_COLUMN)));
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll();
$staffList = $pdo->query("SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll();
$cannedResponses = $pdo->query('SELECT id, title FROM canned_responses ORDER BY title')->fetchAll();
$fieldLabels = [
'subject' => 'Betreff',
'message' => 'Nachricht',
'requester_email' => 'Absender-E-Mail',
'requester_name' => 'Absender-Name',
'source' => 'Kanal (web/email)',
];
$operatorLabels = [
'contains' => 'enthält',
'not_contains' => 'enthält nicht',
'equals' => 'ist genau',
'starts_with' => 'beginnt mit',
'ends_with' => 'endet mit',
];
$actionLabels = [
'set_department' => 'Abteilung setzen',
'set_priority' => 'Priorität setzen',
'set_help_topic' => 'Hilfe-Thema setzen',
'assign_staff' => 'Mitarbeiter zuweisen',
'add_tag' => 'Tag hinzufügen',
'auto_reply' => 'Auto-Antwort senden (Textbaustein)',
'reject' => 'Ticket verwerfen (z.B. Spam)',
];
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$name = trim($_POST['name'] ?? '');
$matchType = ($_POST['match_type'] ?? 'all') === 'any' ? 'any' : 'all';
$isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0;
$notes = trim($_POST['notes'] ?? '');
$sortOrder = (int) ($_POST['sort_order'] ?? 0);
$condFields = $_POST['cond_field'] ?? [];
$condOperators = $_POST['cond_operator'] ?? [];
$condValues = $_POST['cond_value'] ?? [];
$actTypes = $_POST['action_type'] ?? [];
$actDept = $_POST['action_value_dept'] ?? [];
$actPrio = $_POST['action_value_prio'] ?? [];
$actTopic = $_POST['action_value_topic'] ?? [];
$actStaff = $_POST['action_value_staff'] ?? [];
$actTag = $_POST['action_value_tag'] ?? [];
$actCanned = $_POST['action_value_canned'] ?? [];
if ($name === '') $errors[] = 'Bitte einen Namen für die Regel angeben.';
$conditions = [];
foreach ($condFields as $i => $f) {
$val = trim($condValues[$i] ?? '');
if ($val === '') continue;
if (!isset($fieldLabels[$f]) || !isset($operatorLabels[$condOperators[$i] ?? ''])) continue;
$conditions[] = ['field' => $f, 'operator' => $condOperators[$i], 'value' => $val];
}
if (!$conditions) $errors[] = 'Bitte mindestens eine Bedingung mit einem Wert angeben.';
$actionsToSave = [];
foreach ($actTypes as $i => $t) {
if (!isset($actionLabels[$t])) continue;
$value = match ($t) {
'set_department' => $actDept[$i] ?? '',
'set_priority' => $actPrio[$i] ?? '',
'set_help_topic' => $actTopic[$i] ?? '',
'assign_staff' => $actStaff[$i] ?? '',
'add_tag' => trim($actTag[$i] ?? ''),
'auto_reply' => $actCanned[$i] ?? '',
'reject' => '1',
default => '',
};
if ($t !== 'reject' && $value === '') continue;
$actionsToSave[] = ['action_type' => $t, 'action_value' => $value];
}
if (!$actionsToSave) $errors[] = 'Bitte mindestens eine Aktion festlegen.';
if (!$errors) {
if ($action === 'create') {
$pdo->prepare('INSERT INTO ticket_rules (name, match_type, is_active, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
->execute([$name, $matchType, $isActive, $sortOrder, $notes ?: null, db_now(), db_now()]);
$ruleId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "Ticket-Regel erstellt: {$name}");
flash('success', 'Ticket-Regel erstellt.');
} else {
$ruleId = (int) $_POST['id'];
$pdo->prepare('UPDATE ticket_rules SET name=?, match_type=?, is_active=?, sort_order=?, notes=?, updated_at=? WHERE id=?')
->execute([$name, $matchType, $isActive, $sortOrder, $notes ?: null, db_now(), $ruleId]);
$pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$ruleId]);
$pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$ruleId]);
audit_log($staff['id'], "Ticket-Regel aktualisiert: {$name}");
flash('success', 'Ticket-Regel aktualisiert.');
}
$condStmt = $pdo->prepare('INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES (?,?,?,?)');
foreach ($conditions as $c) {
$condStmt->execute([$ruleId, $c['field'], $c['operator'], $c['value']]);
}
$actStmt = $pdo->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)');
foreach ($actionsToSave as $a) {
$actStmt->execute([$ruleId, $a['action_type'], $a['action_value']]);
}
redirect('/staff/ticket_rules.php');
}
}
if ($action === 'toggle_active') {
$id = (int) $_POST['id'];
$pdo->prepare('UPDATE ticket_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
redirect('/staff/ticket_rules.php');
}
if ($action === 'delete') {
$id = (int) $_POST['id'];
$nameStmt = $pdo->prepare('SELECT name FROM ticket_rules WHERE id = ?');
$nameStmt->execute([$id]);
$ruleName = $nameStmt->fetchColumn();
$pdo->prepare('DELETE FROM ticket_rules WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "Ticket-Regel gelöscht: {$ruleName}");
flash('success', 'Ticket-Regel gelöscht.');
redirect('/staff/ticket_rules.php');
}
if ($action === 'move' && in_array($_POST['dir'] ?? '', ['up', 'down'], true)) {
$id = (int) $_POST['id'];
$dir = $_POST['dir'];
$rules = $pdo->query('SELECT id, sort_order FROM ticket_rules ORDER BY sort_order, id')->fetchAll();
$idx = null;
foreach ($rules as $i => $r) { if ((int) $r['id'] === $id) { $idx = $i; break; } }
$swapIdx = $dir === 'up' ? $idx - 1 : $idx + 1;
if ($idx !== null && isset($rules[$swapIdx])) {
$a = $rules[$idx]; $b = $rules[$swapIdx];
$pdo->prepare('UPDATE ticket_rules SET sort_order = ? WHERE id = ?')->execute([$b['sort_order'], $a['id']]);
$pdo->prepare('UPDATE ticket_rules SET sort_order = ? WHERE id = ?')->execute([$a['sort_order'], $b['id']]);
}
redirect('/staff/ticket_rules.php');
}
}
$rules = $pdo->query('SELECT * FROM ticket_rules ORDER BY sort_order, id')->fetchAll();
foreach ($rules as &$r) {
$cs = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?');
$cs->execute([$r['id']]);
$r['conditions'] = $cs->fetchAll();
$as = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?');
$as->execute([$r['id']]);
$r['actions'] = $as->fetchAll();
}
unset($r);
$editId = $_GET['edit'] ?? null;
$showForm = isset($_GET['new']) || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
$editRule = null;
if ($editId) {
foreach ($rules as $r) { if ((int) $r['id'] === (int) $editId) { $editRule = $r; break; } }
}
$pageTitle = 'Ticket-Regeln';
$activePage = 'ticket_rules';
require __DIR__ . '/_header.php';
/** Rendert die Werte-Auswahl einer Aktionszeile für eine bereits gespeicherte Aktion (Bearbeiten-Modus) */
function render_action_value_selects(array $departments, array $priorities, array $helpTopics, array $staffList, array $cannedResponses, ?array $existing = null): void
{
$effectiveType = $existing['action_type'] ?? 'set_department'; // Default-Auswahl des Typ-Selects
$sel = fn($type) => ($effectiveType === $type) ? '' : 'style="display:none"';
$val = fn($type) => ($existing && $existing['action_type'] === $type) ? $existing['action_value'] : '';
?>
<select name="action_value_dept[]" class="rule-val rule-val-set_department" <?= $sel('set_department') ?>>
<option value="">– Abteilung wählen –</option>
<?php foreach ($departments as $d): ?>
<option value="<?= $d['id'] ?>" <?= $val('set_department') == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
<select name="action_value_prio[]" class="rule-val rule-val-set_priority" <?= $sel('set_priority') ?>>
<option value="">– Priorität wählen –</option>
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= $val('set_priority') == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
<select name="action_value_topic[]" class="rule-val rule-val-set_help_topic" <?= $sel('set_help_topic') ?>>
<option value="">– Hilfe-Thema wählen –</option>
<?php foreach ($helpTopics as $t): ?>
<option value="<?= $t['id'] ?>" <?= $val('set_help_topic') == $t['id'] ? 'selected' : '' ?>><?= e($t['name']) ?></option>
<?php endforeach; ?>
</select>
<select name="action_value_staff[]" class="rule-val rule-val-assign_staff" <?= $sel('assign_staff') ?>>
<option value="">– Mitarbeiter wählen –</option>
<?php foreach ($staffList as $s): ?>
<option value="<?= $s['id'] ?>" <?= $val('assign_staff') == $s['id'] ? 'selected' : '' ?>><?= e($s['name']) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="action_value_tag[]" class="rule-val rule-val-add_tag" placeholder="Tag-Name" value="<?= e($val('add_tag')) ?>" <?= $sel('add_tag') ?>>
<?php if ($cannedResponses): ?>
<select name="action_value_canned[]" class="rule-val rule-val-auto_reply" <?= $sel('auto_reply') ?>>
<option value="">– Textbaustein wählen –</option>
<?php foreach ($cannedResponses as $c): ?>
<option value="<?= $c['id'] ?>" <?= $val('auto_reply') == $c['id'] ? 'selected' : '' ?>><?= e($c['title']) ?></option>
<?php endforeach; ?>
</select>
<?php else: ?>
<span class="rule-val rule-val-auto_reply muted" style="<?= $sel('auto_reply') === '' ? '' : 'display:none' ?>; align-self:center; font-size:.85rem">Noch keine Textbausteine angelegt – <a href="/staff/canned_responses.php">jetzt erstellen</a>.</span>
<?php endif; ?>
<span class="rule-val rule-val-reject muted" style="<?= $sel('reject') === '' ? '' : 'display:none' ?>; align-self:center; font-size:.85rem">Keine weitere Einstellung nötig.</span>
<?php
}
?>
<?php if (!$showForm): ?>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h1 style="margin:0">Ticket-Regeln (Filter)</h1>
<a href="/staff/ticket_rules.php?new=1" class="btn">Neue Regel hinzufügen</a>
</div>
<p class="muted">Automatische Aktionen für neue Tickets (Web-Formular und E-Mail-Eingang), ausgewertet in der Reihenfolge unten. Trifft eine Regel mit Aktion „Ticket verwerfen“ zu, werden keine weiteren Regeln mehr geprüft.</p>
<?php endif; ?>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php if (!$showForm): ?>
<div class="card">
<table>
<thead><tr><th>Reihenfolge</th><th>Name</th><th>Bedingungen</th><th>Aktionen</th><th>Status</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
<tbody>
<?php foreach ($rules as $i => $r): ?>
<tr>
<td style="white-space:nowrap">
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="move"><input type="hidden" name="dir" value="up"><input type="hidden" name="id" value="<?= $r['id'] ?>">
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0 4px" <?= $i===0?'disabled':'' ?>>▲</button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="move"><input type="hidden" name="dir" value="down"><input type="hidden" name="id" value="<?= $r['id'] ?>">
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0 4px" <?= $i===count($rules)-1?'disabled':'' ?>>▼</button>
</form>
</td>
<td><a href="/staff/ticket_rules.php?edit=<?= $r['id'] ?>"><?= e($r['name']) ?></a></td>
<td class="muted" style="font-size:.82rem">
<?= $r['match_type']==='all' ? 'Alle:' : 'Eine von:' ?>
<?php foreach ($r['conditions'] as $c): ?>
<div><?= e($fieldLabels[$c['field']] ?? $c['field']) ?> <?= e($operatorLabels[$c['operator']] ?? $c['operator']) ?> „<?= e($c['value']) ?>“</div>
<?php endforeach; ?>
</td>
<td class="muted" style="font-size:.82rem">
<?php foreach ($r['actions'] as $a): ?>
<div><?= e($actionLabels[$a['action_type']] ?? $a['action_type']) ?><?= $a['action_type']!=='reject' ? ': ' . e($a['action_value']) : '' ?></div>
<?php endforeach; ?>
</td>
<td>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="toggle_active">
<input type="hidden" name="id" value="<?= $r['id'] ?>">
<button type="submit" class="badge" style="border:none;cursor:pointer;background:<?= $r['is_active']?'#16a34a':'#6b7280' ?>"><?= $r['is_active']?'Aktiv':'Deaktiviert' ?></button>
</form>
</td>
<td class="muted" style="font-size:.82rem"><?= e(ui_date($r['updated_at'] ?? null)) ?></td>
<td>
<a href="/staff/ticket_rules.php?edit=<?= $r['id'] ?>">Bearbeiten</a> ·
<form method="post" style="display:inline" onsubmit="return confirm('Regel wirklich löschen?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $r['id'] ?>">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$rules): ?>
<tr><td colspan="7" class="muted">Noch keine Ticket-Regeln angelegt.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php else: ?>
<?php
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
$curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editRule['is_active'] ?? 1);
?>
<h1>
<?= $editRule ? 'Regel aktualisieren' : 'Neue Regel' ?>
<?php if ($editRule): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editRule['name']) ?></span><?php endif; ?>
</h1>
<div class="card" id="rule-form-card" style="padding:0">
<form method="post" id="rule-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="<?= $editRule ? 'update' : 'create' ?>">
<?php if ($editRule): ?><input type="hidden" name="id" value="<?= $editRule['id'] ?>"><?php endif; ?>
<?php ui_tab_bar(['rule' => 'Regel', 'conds' => 'Bedingungen', 'acts' => 'Aktionen']); ?>
<div class="tab-pane" data-pane="rule" style="padding:16px 20px">
<h3 style="margin-top:0">Regelinformationen</h3>
<label for="name">Name *</label>
<input type="text" id="name" name="name" value="<?= e($f('name', $editRule['name'] ?? '')) ?>" placeholder="z. B. Spam-Betreffzeile abweisen" required>
<?php ui_status_radios($curActive); ?>
<label for="sort_order">Reihenfolge</label>
<input type="number" id="sort_order" name="sort_order" value="<?= (int) $f('sort_order', $editRule['sort_order'] ?? (count($rules) + 1) * 10) ?>" style="max-width:140px">
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen, sichtbar für alle Administratoren</span></h3>
<textarea name="notes" rows="5"><?= e($f('notes', $editRule['notes'] ?? '')) ?></textarea>
</div>
<div class="tab-pane" data-pane="conds" style="padding:16px 20px;display:none">
<label for="match_type" style="margin-top:0">Verknüpfung der Bedingungen</label>
<select id="match_type" name="match_type" style="max-width:420px">
<option value="all" <?= ($editRule['match_type'] ?? 'all')==='all'?'selected':'' ?>>Alle Bedingungen müssen zutreffen (UND)</option>
<option value="any" <?= ($editRule['match_type'] ?? '')==='any'?'selected':'' ?>>Mindestens eine Bedingung muss zutreffen (ODER)</option>
</select>
<h4 style="margin:18px 0 8px">Bedingungen</h4>
<div id="cond-rows">
<?php
$condsToRender = $editRule['conditions'] ?? [['field' => 'subject', 'operator' => 'contains', 'value' => '']];
foreach ($condsToRender as $c):
?>
<div class="rule-cond-row" style="display:flex; gap:8px; margin-bottom:8px; align-items:center">
<select name="cond_field[]" style="flex:1">
<?php foreach ($fieldLabels as $key => $label): ?>
<option value="<?= $key ?>" <?= ($c['field'] ?? '')===$key?'selected':'' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="cond_operator[]" style="flex:1">
<?php foreach ($operatorLabels as $key => $label): ?>
<option value="<?= $key ?>" <?= ($c['operator'] ?? '')===$key?'selected':'' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="cond_value[]" style="flex:2" placeholder="Wert" value="<?= e($c['value'] ?? '') ?>">
<button type="button" onclick="this.closest('.rule-cond-row').remove()" style="background:none;border:none;color:#dc2626;cursor:pointer">✕</button>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn secondary" id="add-cond-btn" style="margin-bottom:18px">+ Bedingung hinzufügen</button>
</div>
<div class="tab-pane" data-pane="acts" style="padding:16px 20px;display:none">
<h4 style="margin:0 0 8px">Aktionen</h4>
<div id="action-rows">
<?php
$actionsToRender = $editRule['actions'] ?? [['action_type' => 'set_department', 'action_value' => '']];
foreach ($actionsToRender as $a):
?>
<div class="rule-action-row" style="display:flex; gap:8px; margin-bottom:8px; align-items:center">
<select name="action_type[]" class="rule-action-type" style="flex:1" onchange="updateRuleActionRow(this)">
<?php foreach ($actionLabels as $key => $label): ?>
<option value="<?= $key ?>" <?= ($a['action_type'] ?? '')===$key?'selected':'' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<div style="flex:2; display:flex">
<?php render_action_value_selects($departments, $priorities, $helpTopics, $staffList, $cannedResponses, $a); ?>
</div>
<button type="button" onclick="this.closest('.rule-action-row').remove()" style="background:none;border:none;color:#dc2626;cursor:pointer">✕</button>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn secondary" id="add-action-btn" style="margin-bottom:18px">+ Aktion hinzufügen</button>
</div>
<div class="btn-row" style="padding:0 20px 18px">
<button type="submit" class="btn"><?= $editRule ? 'Änderungen speichern' : 'Regel erstellen' ?></button>
<a href="/staff/ticket_rules.php" class="btn secondary">Abbrechen</a>
</div>
</form>
</div>
<?php ui_tab_assets('rule'); ?>
<?php endif; ?>
<template id="cond-row-template">
<div class="rule-cond-row" style="display:flex; gap:8px; margin-bottom:8px; align-items:center">
<select name="cond_field[]" style="flex:1">
<?php foreach ($fieldLabels as $key => $label): ?><option value="<?= $key ?>"><?= e($label) ?></option><?php endforeach; ?>
</select>
<select name="cond_operator[]" style="flex:1">
<?php foreach ($operatorLabels as $key => $label): ?><option value="<?= $key ?>"><?= e($label) ?></option><?php endforeach; ?>
</select>
<input type="text" name="cond_value[]" style="flex:2" placeholder="Wert">
<button type="button" onclick="this.closest('.rule-cond-row').remove()" style="background:none;border:none;color:#dc2626;cursor:pointer">✕</button>
</div>
</template>
<template id="action-row-template">
<div class="rule-action-row" style="display:flex; gap:8px; margin-bottom:8px; align-items:center">
<select name="action_type[]" class="rule-action-type" style="flex:1" onchange="updateRuleActionRow(this)">
<?php foreach ($actionLabels as $key => $label): ?><option value="<?= $key ?>"><?= e($label) ?></option><?php endforeach; ?>
</select>
<div style="flex:2; display:flex">
<?php render_action_value_selects($departments, $priorities, $helpTopics, $staffList, $cannedResponses, null); ?>
</div>
<button type="button" onclick="this.closest('.rule-action-row').remove()" style="background:none;border:none;color:#dc2626;cursor:pointer">✕</button>
</div>
</template>
<script>
function updateRuleActionRow(select) {
var row = select.closest('.rule-action-row');
row.querySelectorAll('.rule-val').forEach(function (el) { el.style.display = 'none'; });
var match = row.querySelector('.rule-val-' + select.value);
if (match) match.style.display = '';
}
var addCond = document.getElementById('add-cond-btn');
if (addCond) addCond.addEventListener('click', function () {
var tpl = document.getElementById('cond-row-template');
document.getElementById('cond-rows').appendChild(tpl.content.cloneNode(true));
});
var addAct = document.getElementById('add-action-btn');
if (addAct) addAct.addEventListener('click', function () {
var tpl = document.getElementById('action-row-template');
document.getElementById('action-rows').appendChild(tpl.content.cloneNode(true));
});
</script>
<?php require __DIR__ . '/_footer.php'; ?>
+151
View File
@@ -0,0 +1,151 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
$ticketId = (int)($_GET['id'] ?? 0);
if (!$ticketId) redirect('/staff/tickets.php');
$stmt = $pdo->prepare(
'SELECT t.*, d.name AS department_name, p.name AS priority_name
FROM tickets t
JOIN departments d ON d.id = t.department_id
JOIN priorities p ON p.id = t.priority_id
WHERE t.id = ?'
);
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch();
if (!$ticket) { http_response_code(404); die('Ticket nicht gefunden.'); }
if (!staff_can_access_department($staff, (int) $ticket['department_id'])) { http_response_code(403); die('Sie haben keinen Zugriff auf Tickets dieser Abteilung.'); }
// Nachrichten laden
$threads = $pdo->prepare(
'SELECT tt.*, s.name AS staff_name
FROM ticket_threads tt
LEFT JOIN staff s ON s.id = tt.staff_id
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0
ORDER BY tt.created_at ASC'
);
$threads->execute([$ticketId]);
$messages = $threads->fetchAll();
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$selectedMsgIds = array_map('intval', $_POST['message_ids'] ?? []);
$newSubject = trim($_POST['new_subject'] ?? '');
if (empty($selectedMsgIds)) {
$errors[] = 'Bitte mindestens eine Nachricht auswählen.';
} elseif (!$newSubject) {
$errors[] = 'Bitte einen Betreff für das neue Ticket angeben.';
} else {
// Neues Ticket erstellen
$newNumber = generate_ticket_number();
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, status, priority_id, department_id,
user_id, requester_name, requester_email, source, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$newNumber, $newSubject, 'open',
$ticket['priority_id'], $ticket['department_id'],
$ticket['user_id'], $ticket['requester_name'], $ticket['requester_email'],
'staff', db_now(), db_now()
]);
$newId = $pdo->lastInsertId();
// Ausgewählte Nachrichten verschieben
$moved = 0;
foreach ($selectedMsgIds as $msgId) {
$rows = $pdo->prepare('UPDATE ticket_threads SET ticket_id=? WHERE id=? AND ticket_id=?');
$rows->execute([$newId, $msgId, $ticketId]);
$moved += $rows->rowCount();
}
// Anhänge der verschobenen Nachrichten mitnehmen
if ($moved > 0) {
$placeholders = implode(',', array_fill(0, count($selectedMsgIds), '?'));
$pdo->prepare("UPDATE ticket_attachments SET ticket_id=? WHERE thread_id IN ($placeholders)")
->execute(array_merge([$newId], $selectedMsgIds));
}
// Log-Einträge
log_activity($pdo, $ticketId, $staff['id'], "Ticket aufgeteilt → #{$newNumber} ({$moved} Nachrichten)");
log_activity($pdo, $newId, $staff['id'], "Erstellt durch Aufteilen von #{$ticket['ticket_number']}");
audit_log($staff['id'], "Ticket #{$ticket['ticket_number']} aufgeteilt → #{$newNumber}");
flash('success', "Ticket aufgeteilt. Neues Ticket: #{$newNumber}");
redirect('/staff/ticket_view.php?n=' . urlencode($newNumber));
}
}
$pageTitle = 'Ticket aufteilen';
$activePage = 'tickets';
require __DIR__ . '/_header.php';
?>
<div class="topbar">
<div>
<h1>Ticket aufteilen</h1>
<span class="ticket-no">#<?= e($ticket['ticket_number']) ?></span>
</div>
<a href="/staff/ticket_view.php?n=<?= urlencode($ticket['ticket_number']) ?>" class="btn secondary">← Zurück</a>
</div>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<div style="display:grid; grid-template-columns:2fr 1fr; gap:20px; align-items:start">
<div class="card">
<h3>Nachrichten auswählen</h3>
<p class="muted" style="font-size:.85rem; margin-bottom:16px">
Wähle die Nachrichten aus die in das neue Ticket verschoben werden sollen. Die gewählten Nachrichten werden aus diesem Ticket entfernt.
</p>
<form method="post" id="split-form">
<?= csrf_field() ?>
<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px">
<?php foreach ($messages as $msg): ?>
<label style="display:flex; gap:12px; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer; transition:background .12s"
onmouseover="this.style.background='var(--bg-soft)'" onmouseout="this.style.background=''">
<input type="checkbox" name="message_ids[]" value="<?= $msg['id'] ?>"
style="width:auto; margin-top:3px; flex-shrink:0">
<div style="min-width:0; flex:1">
<div style="display:flex; justify-content:space-between; margin-bottom:4px">
<strong style="font-size:.83rem"><?= e($msg['staff_name'] ?? $ticket['requester_name']) ?></strong>
<span class="muted" style="font-size:.75rem"><?= format_dt($msg['created_at']) ?></span>
</div>
<div style="font-size:.85rem; color:var(--ink-soft); white-space:nowrap; overflow:hidden; text-overflow:ellipsis">
<?= e(mb_substr(strip_tags($msg['message']), 0, 120)) ?>
</div>
</div>
</label>
<?php endforeach; ?>
</div>
<label>Betreff des neuen Tickets</label>
<input type="text" name="new_subject" value="<?= e('Re: '.$ticket['subject']) ?>" required>
<div class="btn-row" style="margin-top:16px">
<button type="submit" class="btn" onclick="return confirm('Ausgewählte Nachrichten in neues Ticket verschieben?')">
✂️ Ticket aufteilen
</button>
</div>
</form>
</div>
<div class="card">
<h3>Hinweise</h3>
<ul style="padding-left:20px; line-height:2; font-size:.85rem; color:var(--muted)">
<li>Ausgewählte Nachrichten werden in ein <strong style="color:var(--ink)">neues Ticket</strong> verschoben</li>
<li>Anhänge der Nachrichten werden mitgenommen</li>
<li>Das ursprüngliche Ticket bleibt mit den verbleibenden Nachrichten erhalten</li>
<li>Beide Tickets erhalten einen Log-Eintrag</li>
</ul>
<div style="background:var(--bg-soft); border-radius:8px; padding:14px; margin-top:16px; font-size:.83rem">
<div class="muted" style="margin-bottom:6px">Ursprüngliches Ticket</div>
<strong>#<?= e($ticket['ticket_number']) ?></strong> – <?= e($ticket['subject']) ?>
<div class="muted" style="margin-top:4px"><?= count($messages) ?> Nachrichten gesamt</div>
</div>
</div>
</div>
<?php require __DIR__ . '/_footer.php'; ?>
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
$departments = $pdo->query('SELECT * FROM departments ORDER BY name')->fetchAll();
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$bulkErrors = [];
// ---------------------------------------------------------------
// Gespeicherte Ansichten: speichern / löschen
// ---------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['queue_action'] ?? '', ['save', 'delete'], true)) {
verify_csrf();
$back = '/staff/tickets.php' . (($_POST['return_query'] ?? '') !== '' ? '?' . ltrim((string) $_POST['return_query'], '?') : '');
try {
if ($_POST['queue_action'] === 'save') {
parse_str((string) ($_POST['return_query'] ?? ''), $currentGet);
queue_create($pdo, (int) $staff['id'], (string) ($_POST['queue_name'] ?? ''), queue_filters_from_web($currentGet), !empty($_POST['queue_shared']));
flash('success', 'Ansicht gespeichert.');
} else {
$qid = (int) ($_POST['queue_id'] ?? 0);
$row = $pdo->prepare('SELECT * FROM saved_queues WHERE id = ?');
$row->execute([$qid]);
$row = $row->fetch();
if ($row && queue_can_edit($row, $staff)) {
$pdo->prepare('DELETE FROM saved_queues WHERE id = ?')->execute([$qid]);
flash('success', 'Ansicht gelöscht.');
}
$back = '/staff/tickets.php';
}
} catch (AdminModelException $e) {
flash('error', $e->getMessage());
}
redirect($back);
}
// ---------------------------------------------------------------
// Sammelaktionen
// ---------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '') {
verify_csrf();
$ticketIds = array_filter(array_map('intval', $_POST['ticket_ids'] ?? []));
$bulkAction = $_POST['bulk_action'];
if (!$ticketIds) {
$bulkErrors[] = 'Bitte wählen Sie mindestens ein Ticket aus.';
} else {
// Zugriff prüfen: nur Tickets aus erlaubten Abteilungen dürfen verändert werden
$placeholders = implode(',', array_fill(0, count($ticketIds), '?'));
[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 'department_id');
$stmt = $pdo->prepare("SELECT * FROM tickets WHERE id IN ({$placeholders})" . $deptFilter);
$stmt->execute(array_merge($ticketIds, $deptParams));
$allowedTickets = $stmt->fetchAll();
if (count($allowedTickets) < count($ticketIds)) {
$bulkErrors[] = 'Einige ausgewählte Tickets gehören zu Abteilungen, auf die Sie keinen Zugriff haben, und wurden übersprungen.';
}
$now = db_now();
foreach ($allowedTickets as $t) {
if ($bulkAction === 'assign') {
$newAgent = $_POST['bulk_assigned_staff_id'] !== '' ? (int) $_POST['bulk_assigned_staff_id'] : null;
$pdo->prepare('UPDATE tickets SET assigned_staff_id=?, updated_at=? WHERE id=?')->execute([$newAgent, $now, $t['id']]);
log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Mitarbeiter zugewiesen');
} elseif ($bulkAction === 'priority') {
$newPrio = (int) $_POST['bulk_priority_id'];
$pdo->prepare('UPDATE tickets SET priority_id=?, updated_at=? WHERE id=?')->execute([$newPrio, $now, $t['id']]);
log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Priorität geändert');
} elseif ($bulkAction === 'status') {
$newStatus = $_POST['bulk_status'];
$resolvedAt = $newStatus === 'resolved' ? $now : $t['resolved_at'];
$closedAt = $newStatus === 'closed' ? $now : $t['closed_at'];
$pdo->prepare('UPDATE tickets SET status=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?')
->execute([$newStatus, $resolvedAt, $closedAt, $now, $t['id']]);
log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Status geändert');
} elseif ($bulkAction === 'delete') {
delete_ticket($pdo, $t, $staff['id']);
} elseif ($bulkAction === 'ban') {
$email = $t['requester_email'];
$domain = $email ? substr($email, strpos($email,'@')+1) : '';
if ($email) {
$chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
$chk->execute([$email]);
if (!$chk->fetchColumn()) {
$pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
->execute(['email', $email, 'Bulk-Sperre', $staff['id'], $now]);
}
// Alle Tickets dieses Absenders schließen
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE requester_email=? AND status NOT IN ('closed')")
->execute([$now, $now, $email]);
}
}
}
if (!$bulkErrors) {
$verb = ['assign' => 'zugewiesen', 'priority' => 'Priorität geändert', 'status' => 'Status geändert', 'delete' => 'gelöscht'][$bulkAction] ?? 'aktualisiert';
flash('success', count($allowedTickets) . ' Ticket(s) ' . $verb . '.');
} else {
foreach ($bulkErrors as $e) flash('error', $e);
}
redirect('/staff/tickets.php?' . http_build_query($_GET));
}
}
$filterDept = $_GET['department_id'] ?? '';
$filterStatus = $_GET['status'] ?? '';
$filterPrio = $_GET['priority_id'] ?? '';
$filterAssign = $_GET['assigned'] ?? '';
$filterTag = $_GET['tag_id'] ?? '';
$filterSpam = $_GET['spam'] ?? '';
$filterSla = $_GET['sla'] ?? '';
$search = trim($_GET['q'] ?? '');
$activeQuick = $_GET['quick'] ?? ''; // 'me', 'unassigned', 'overdue', 'highprio'
$where = [];
$params = [];
// Spam-Tickets sind standardmäßig aus der normalen Ticketliste ausgeblendet.
if ($filterSpam === '1') {
$where[] = 't.is_spam = 1';
} else {
$where[] = 't.is_spam = 0';
}
[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 't.department_id');
if ($deptFilter !== '') { $where[] = substr($deptFilter, 5); array_push($params, ...$deptParams); }
// Schnellfilter-Tabs
if ($activeQuick === 'me') {
$where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id'];
$where[] = "t.status NOT IN ('resolved','closed')";
} elseif ($activeQuick === 'unassigned') {
$where[] = 't.assigned_staff_id IS NULL';
$where[] = "t.status NOT IN ('resolved','closed')";
} elseif ($activeQuick === 'overdue') {
$where[] = 't.resolution_due_at < ?'; $params[] = db_now();
$where[] = "t.status NOT IN ('resolved','closed')";
} elseif ($activeQuick === 'highprio') {
$where[] = 't.priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)';
$where[] = "t.status NOT IN ('resolved','closed')";
}
if ($filterDept !== '') { $where[] = 't.department_id = ?'; $params[] = $filterDept; }
if ($filterStatus !== '' && !isset($_GET['tab'])) { $where[] = 't.status = ?'; $params[] = $filterStatus; }
if ($filterPrio !== '') { $where[] = 't.priority_id = ?'; $params[] = $filterPrio; }
if ($filterAssign === 'me') { $where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id']; }
elseif ($filterAssign === 'unassigned') { $where[] = 't.assigned_staff_id IS NULL'; }
elseif ($filterAssign !== '') { $where[] = 't.assigned_staff_id = ?'; $params[] = $filterAssign; }
if ($filterTag !== '') {
$where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)';
$params[] = $filterTag;
}
// SLA-Status: "at_risk" = Lösungsfrist läuft innerhalb der nächsten 4 Stunden ab, aber
// noch nicht überschritten. "overdue"/"on_time" ergänzen sich mit is_overdue (siehe
// cron/check_sla.php), berücksichtigen aber auch Tickets, deren Frist gerade erst
// (zwischen zwei Cron-Läufen) verstrichen ist, statt sich blind auf das Flag zu verlassen.
if ($filterSla !== '') {
$now = db_now();
$riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
if ($filterSla === 'overdue') {
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
$params[] = $now;
} elseif ($filterSla === 'at_risk') {
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
array_push($params, $now, $riskThreshold);
} elseif ($filterSla === 'on_time') {
$where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
$params[] = $riskThreshold;
}
}
if ($search !== '') {
$like = '%' . $search . '%';
if (!empty($_GET['fulltext'])) {
$where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? OR t.requester_name LIKE ?
OR t.id IN (SELECT ticket_id FROM ticket_threads WHERE message LIKE ?))';
array_push($params, $like, $like, $like, $like, $like);
} else {
$where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? OR t.requester_name LIKE ?)';
array_push($params, $like, $like, $like, $like);
}
}
$activeTab = $_GET['tab'] ?? 'open';
// Bedingung: Ticket ist von gesperrtem Absender
$notBannedSql = "(t.requester_email NOT IN (SELECT value FROM banlist WHERE match_type='email')
AND " . sql_email_domain("t.requester_email") . " NOT IN (SELECT value FROM banlist WHERE match_type='domain'))";
if ($activeTab === 'banned') {
// Nur gesperrte Absender zeigen
$where[] = "(t.requester_email IN (SELECT value FROM banlist WHERE match_type='email')
OR " . sql_email_domain("t.requester_email") . " IN (SELECT value FROM banlist WHERE match_type='domain'))";
} else {
// Gesperrte Absender in ALLEN anderen Tabs ausblenden
$where[] = $notBannedSql;
if ($activeTab === 'open') {
$where[] = "t.status IN ('open','pending','answered')";
} elseif ($activeTab === 'on_hold') {
$where[] = "t.status = 'on_hold'";
} elseif ($activeTab === 'resolved') {
$where[] = "t.status = 'resolved'";
} elseif ($activeTab === 'closed') {
$where[] = "t.status = 'closed'";
} elseif ($activeTab === 'all') {
// kein weiterer Filter
} else {
$where[] = "t.status IN ('open','pending','answered')";
}
}
$sql = "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id";
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
// Gesamtanzahl
$countSql = "SELECT COUNT(*) FROM tickets t JOIN priorities p ON p.id = t.priority_id JOIN departments d ON d.id = t.department_id LEFT JOIN staff s ON s.id = t.assigned_staff_id"
. ($where ? ' WHERE ' . implode(' AND ', $where) : '');
$countStmt = $pdo->prepare($countSql);
$countStmt->execute($params);
$totalCount = (int)$countStmt->fetchColumn();
$totalPages = max(1, (int)ceil($totalCount / $perPage));
$sql .= ' ORDER BY t.created_at DESC LIMIT ' . $perPage . ' OFFSET ' . $offset;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$tickets = $stmt->fetchAll();
// Tags je angezeigtem Ticket nachladen (eine Abfrage für alle sichtbaren Tickets)
$ticketTags = [];
if ($tickets) {
$ids = array_column($tickets, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$tagStmt = $pdo->prepare(
"SELECT tt.ticket_id, tg.id, tg.name, tg.color FROM ticket_tags tt
JOIN tags tg ON tg.id = tt.tag_id WHERE tt.ticket_id IN ({$placeholders})"
);
$tagStmt->execute($ids);
foreach ($tagStmt->fetchAll() as $row) {
$ticketTags[$row['ticket_id']][] = $row;
}
}
$allTags = $pdo->query('SELECT * FROM tags ORDER BY name')->fetchAll();
// Sichtbare Abteilungen für das Filter-Dropdown auf Zugriffsrechte beschränken
$visibleDepartments = $myDeptIds === null ? $departments : array_filter($departments, fn($d) => in_array((int) $d['id'], $myDeptIds, true));
// Schnellfilter-Zähler
$_stMe = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id=? AND status NOT IN ('resolved','closed')");
$_stMe->execute([$staff['id']]);
$countMe = (int)$_stMe->fetchColumn();
$countUnassigned= (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')")->fetchColumn();
$_stOd = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolution_due_at < ? AND status NOT IN ('resolved','closed')");
$_stOd->execute([db_now()]);
$countOverdue = (int)$_stOd->fetchColumn();
$countHighPrio = (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)")->fetchColumn();
// Live-Refresh der Ticketliste ohne Seiten-Reload (siehe <script> weiter unten): liefert
// bei ?partial=1 nur die Tabelle/Pagination aus derselben Query-Logik wie oben, damit
// Filter/Seite/Sortierung 1:1 übereinstimmen und nichts doppelt gepflegt werden muss.
if (($_GET['partial'] ?? '') === '1') {
require __DIR__ . '/_tickets_list_partial.php';
exit;
}
$pageTitle = 'Tickets';
$activePage = 'tickets';
require __DIR__ . '/_header.php';
?>
<?php
// Ticket-Zähler für Tabs
$tabCounts = [];
foreach (['open'=>"status IN ('open','pending','answered')", 'on_hold'=>"status='on_hold'", 'resolved'=>"status='resolved'", 'closed'=>"status='closed'"] as $tk => $tcond) {
$tabCounts[$tk] = (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE $tcond")->fetchColumn();
}
// Gesperrt-Tab: sicher mit try/catch
try {
$tabCounts['banned'] = (int)$pdo->query(
"SELECT COUNT(*) FROM tickets WHERE requester_email IN (SELECT value FROM banlist WHERE match_type='email')
OR " . sql_email_domain("requester_email") . " IN (SELECT value FROM banlist WHERE match_type='domain')"
)->fetchColumn();
} catch (Exception $e) { $tabCounts['banned'] = 0; }
?>
<div class="topbar">
<h1>Tickets</h1>
<a href="/staff/ticket_create.php" class="btn">+ Ticket erstellen</a>
</div>
<!-- Tabs + Schnellfilter-Dropdown -->
<?php
$activeQuick = $_GET['quick'] ?? '';
$statusTabs = [
'open' => ['Offen', $tabCounts['open']],
'on_hold' => ['Pausiert', $tabCounts['on_hold']],
'resolved' => ['Gelöst', $tabCounts['resolved']],
'closed' => ['Geschlossen', $tabCounts['closed']],
'banned' => ['⛔ Gesperrt', $tabCounts['banned']],
'all' => ['Alle', array_sum($tabCounts)],
];
$quickFilters = [
'me' => ['Mir zugewiesen', $countMe, 'var(--primary)', '#e6f4f3'],
'unassigned' => ['Nicht zugewiesen', $countUnassigned, '#64748b', '#f1f5f9'],
'overdue' => ['Überfällig', $countOverdue, '#ef4444', '#fef2f2'],
'highprio' => ['Hohe Priorität', $countHighPrio, '#f59e0b', '#fffbeb'],
];
$anyQuickActive = $activeQuick !== '';
?>
<div style="display:flex;gap:0;border-bottom:2px solid var(--border);margin-bottom:20px;flex-wrap:wrap;align-items:flex-end;position:relative">
<?php foreach ($statusTabs as $tk => [$tl, $tc]):
$isActive = $activeTab === $tk && !$anyQuickActive;
$href = '/staff/tickets.php?' . http_build_query(array_merge(array_diff_key($_GET, ['tab'=>'','page'=>'','quick'=>'']), ['tab'=>$tk]));
?>
<a href="<?= e($href) ?>"
style="padding:9px 16px;font-size:.84rem;font-weight:<?=$isActive?'700':'400'?>;color:<?=$isActive?'var(--primary)':'var(--muted)'?>;text-decoration:none;border-bottom:2px solid <?=$isActive?'var(--primary)':'transparent'?>;margin-bottom:-2px;white-space:nowrap;display:flex;align-items:center;gap:6px">
<?= $tl ?>
<span style="background:<?=$isActive?'var(--primary)':'var(--border)'?>;color:<?=$isActive?'#fff':'var(--muted)'?>;font-size:.7rem;padding:1px 7px;border-radius:20px;font-weight:700"><?= number_format($tc) ?></span>
</a>
<?php endforeach; ?>
<!-- Schnellfilter-Dropdown -->
<div style="margin-left:auto;margin-bottom:-2px;position:relative" id="qf-wrap">
<button id="qf-btn" onclick="toggleQF(event)" type="button"
style="display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:.83rem;font-weight:<?=$anyQuickActive?'700':'500'?>;color:<?=$anyQuickActive?'var(--primary)':'var(--muted)'?>;background:none;border:none;border-bottom:2px solid <?=$anyQuickActive?'var(--primary)':'transparent'?>;cursor:pointer;white-space:nowrap;margin-bottom:0">
<?php if ($anyQuickActive): ?>
<span style="width:7px;height:7px;border-radius:50%;background:<?= $quickFilters[$activeQuick][2] ?>;display:inline-block;flex-shrink:0"></span>
<?= e($quickFilters[$activeQuick][0]) ?>
<span style="background:<?= $quickFilters[$activeQuick][2] ?>;color:#fff;font-size:.68rem;padding:1px 6px;border-radius:20px;font-weight:700"><?= $quickFilters[$activeQuick][1] ?></span>
<span style="font-size:.7rem;opacity:.6">✕</span>
<?php else: ?>
Schnellfilter <span style="font-size:.7rem">▾</span>
<?php if (($countOverdue + $countMe) > 0): ?>
<span style="background:#ef4444;color:#fff;font-size:.68rem;padding:1px 6px;border-radius:20px;font-weight:700"><?= $countOverdue + $countMe ?></span>
<?php endif; ?>
<?php endif; ?>
</button>
<div id="qf-panel" style="display:none;position:absolute;right:0;top:calc(100% + 6px);width:260px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow);z-index:200;padding:6px;overflow:hidden">
<?php foreach ($quickFilters as $qk => [$ql, $qc, $qcolor, $qbg]):
$qActive = $activeQuick === $qk;
$qHref = $qActive ? '/staff/tickets.php' : '/staff/tickets.php?' . http_build_query(['quick' => $qk]);
?>
<a href="<?= e($qHref) ?>"
style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;text-decoration:none;background:<?=$qActive?$qbg:'transparent'?>;transition:background .12s;margin-bottom:2px"
onmouseover="this.style.background='<?=$qActive?$qbg:"var(--border-soft)"?>'"
onmouseout="this.style.background='<?=$qActive?$qbg:"transparent"?>'">
<span style="width:8px;height:8px;border-radius:50%;background:<?=$qcolor?>;flex-shrink:0;opacity:<?=$qActive?'1':'.5'?>"></span>
<span style="flex:1;font-size:.85rem;font-weight:<?=$qActive?'600':'500'?>;color:<?=$qActive?$qcolor:'var(--ink-soft)'?>;white-space:nowrap"><?= e($ql) ?></span>
<span style="background:<?=$qc>0?$qcolor:'var(--border)'?>;color:<?=$qc>0?'#fff':'var(--muted)'?>;font-size:.72rem;padding:2px 9px;border-radius:20px;font-weight:700;flex-shrink:0"><?= $qc ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
function toggleQF(e) {
e.stopPropagation();
var p = document.getElementById('qf-panel');
p.style.display = p.style.display === 'none' ? 'block' : 'none';
}
document.addEventListener('click', function() {
var p = document.getElementById('qf-panel');
if (p) p.style.display = 'none';
});
document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.stopPropagation(); });
</script>
<?php foreach ($bulkErrors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php
$__queues = queues_for($pdo, (int) $staff['id']);
$__currentFilters = queue_filters_from_web($_GET);
$__currentQuery = http_build_query($_GET);
?>
<div class="card" style="margin-bottom:12px;padding:10px 14px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong style="font-size:.8rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">Ansichten</strong>
<a href="/staff/tickets.php" class="btn secondary" style="padding:3px 12px;font-size:.82rem<?= !$_GET ? ';border-color:var(--primary);color:var(--primary)' : '' ?>">Standard</a>
<?php foreach ($__queues as $__q):
$__active = $__q['filters'] && queue_filters_to_web($__q['filters']) == array_filter($_GET, fn($v) => $v !== '' && $v !== null && $v !== []);
?>
<span style="display:inline-flex;align-items:center;gap:2px">
<a href="/staff/tickets.php?<?= e(http_build_query(queue_filters_to_web($__q['filters']))) ?>" class="btn secondary" title="<?= $__q['mine'] ? 'Meine Ansicht' : 'Geteilt von ' . e($__q['owner_name'] ?? '') ?>"
style="padding:3px 12px;font-size:.82rem<?= $__active ? ';border-color:var(--primary);color:var(--primary)' : '' ?>"><?= e($__q['name']) ?><?= $__q['is_shared'] ? ' ⇄' : '' ?></a>
<?php if (queue_can_edit($__q, $staff)): ?>
<form method="post" style="margin:0" onsubmit="return confirm('Ansicht löschen?')">
<?= csrf_field() ?><input type="hidden" name="queue_action" value="delete"><input type="hidden" name="queue_id" value="<?= (int) $__q['id'] ?>">
<button type="submit" title="Ansicht löschen" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:.9rem;padding:0 4px">✕</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
<details style="margin-left:auto;position:relative">
<summary class="btn secondary" style="padding:3px 12px;font-size:.82rem;cursor:pointer;list-style:none">Aktuelle Filter als Ansicht speichern</summary>
<form method="post" class="card" style="position:absolute;right:0;top:34px;z-index:20;min-width:280px;padding:14px;box-shadow:var(--shadow-md, 0 8px 24px rgba(0,0,0,.2))">
<?= csrf_field() ?>
<input type="hidden" name="queue_action" value="save">
<input type="hidden" name="return_query" value="<?= e($__currentQuery) ?>">
<label for="queue_name" style="margin-top:0">Name der Ansicht</label>
<input type="text" id="queue_name" name="queue_name" maxlength="80" placeholder="z. B. Meine überfälligen" required>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="queue_shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
<p class="muted" style="font-size:.76rem;margin:8px 0 0">Gespeichert werden: <?= e($__currentFilters ? implode(', ', array_keys($__currentFilters)) : 'keine Filter (alle Tickets)') ?></p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</details>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<!-- ── Filter ── -->
<form method="get">
<div style="display:grid;grid-template-columns:1fr repeat(4,auto) auto;gap:10px;align-items:end;margin-bottom:10px">
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Suche</label>
<input type="search" name="q" value="<?= e($search) ?>"
placeholder="Betreff · Ticket-Nr. · Kundenname · E-Mail"
<?= $search ? 'autofocus' : '' ?>>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Abteilung</label>
<select name="department_id">
<option value="">Alle</option>
<?php foreach ($visibleDepartments as $d): ?>
<option value="<?= $d['id'] ?>" <?= $filterDept == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Status</label>
<select name="status">
<option value="">Alle außer Geschlossen</option>
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','answered'=>'Beantwortet','on_hold'=>'Pausiert','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
<option value="<?= $val ?>" <?= $filterStatus === $val ? 'selected' : '' ?>><?= $lbl ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">E-Mail</label>
<select name="spam">
<option value="">Normale Tickets</option>
<option value="1" <?= $filterSpam === '1' ? 'selected' : '' ?>>🚫 Spam</option>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Priorität</label>
<select name="priority_id">
<option value="">Alle</option>
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= $filterPrio == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Zuweisung</label>
<select name="assigned">
<option value="">Alle</option>
<option value="me" <?= $filterAssign==='me'?'selected':'' ?>>Mir zugewiesen</option>
<option value="unassigned" <?= $filterAssign==='unassigned'?'selected':'' ?>>Nicht zugewiesen</option>
<?php foreach ($agents as $a): ?>
<option value="<?= $a['id'] ?>" <?= $filterAssign == $a['id'] ? 'selected' : '' ?>><?= e($a['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($allTags): ?>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Tag</label>
<select name="tag_id">
<option value="">Alle</option>
<?php foreach ($allTags as $tg): ?>
<option value="<?= $tg['id'] ?>" <?= $filterTag == $tg['id'] ? 'selected' : '' ?>><?= e($tg['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">SLA-Status</label>
<select name="sla">
<option value="">Alle</option>
<option value="overdue" <?= $filterSla==='overdue'?'selected':'' ?>>🔴 Überfällig</option>
<option value="at_risk" <?= $filterSla==='at_risk'?'selected':'' ?>>🟡 Bald fällig (&lt; 4 Std.)</option>
<option value="on_time" <?= $filterSla==='on_time'?'selected':'' ?>>🟢 Im Zeitplan</option>
</select>
</div>
<div style="display:flex;gap:6px;align-items:flex-end">
<button type="submit" class="btn">Filtern</button>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:5px;font-size:.78rem;font-weight:400;cursor:pointer;color:var(--muted);margin:0">
<input type="checkbox" name="fulltext" value="1" style="width:auto;margin:0" <?= !empty($_GET['fulltext']) ? 'checked' : '' ?>>
Auch Nachrichten durchsuchen
</label>
<div style="flex:1"></div>
<?php if ($staff['role'] === 'admin'): ?>
<a href="/staff/export.php?type=tickets&<?= http_build_query($_GET) ?>" class="btn secondary" style="font-size:.8rem;padding:5px 12px">↓ CSV exportieren</a>
<?php endif; ?>
<?php if ($search || $filterStatus || $filterDept || $filterPrio || $filterAssign || $filterTag || $filterSpam): ?>
<a href="/staff/tickets.php" class="btn secondary" style="font-size:.8rem;padding:5px 12px;color:var(--muted)">✕ Filter zurücksetzen</a>
<?php endif; ?>
</div>
</form>
<!-- ── Sammelaktionen ── -->
<form method="post" id="bulk-form" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
<?= csrf_field() ?>
<input type="hidden" name="bulk_action" id="bulk_action" value="">
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<span style="font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Auswahl:</span>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_assigned_staff_id" name="bulk_assigned_staff_id">
<option value="">– nicht zugewiesen –</option>
<?php foreach ($agents as $a): ?><option value="<?= $a['id'] ?>"><?= e($a['name']) ?></option><?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('assign')">Zuweisen</button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_priority_id" name="bulk_priority_id">
<?php foreach ($priorities as $p): ?><option value="<?= $p['id'] ?>"><?= e($p['name']) ?></option><?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('priority')">Priorität setzen</button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_status" name="bulk_status">
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
<option value="<?= $val ?>"><?= $lbl ?></option>
<?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('status')">Status setzen</button>
</div>
<div style="margin-left:auto;display:flex;gap:8px">
<button type="button" class="btn secondary" style="border-color:#f59e0b;color:#92400e;white-space:nowrap"
onclick="if(confirm('Alle Absender der markierten Tickets sperren und Tickets schließen?')) submitBulk('ban')">⛔ Absender sperren</button>
<button type="button" class="btn danger" style="white-space:nowrap"
onclick="if(confirm('Markierte Tickets wirklich unwiderruflich löschen?')) submitBulk('delete')">Löschen</button>
</div>
</div>
</form>
</div>
<?php require __DIR__ . '/_tickets_list_partial.php'; ?>
<script>
function submitBulk(action) {
const checked = document.querySelectorAll('.ticket-check:checked');
if (checked.length === 0) { alert('Bitte mindestens ein Ticket markieren.'); return; }
// Copy checked ticket IDs into the top bulk-form and submit
const form = document.getElementById('bulk-form');
document.getElementById('bulk_action').value = action;
// Remove any previously cloned ticket_id inputs
form.querySelectorAll('input[name="ticket_ids[]"]').forEach(el => el.remove());
// Clone the checked ticket IDs into the bulk-form
checked.forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'ticket_ids[]';
inp.value = cb.value;
form.appendChild(inp);
});
form.submit();
}
// Live-Refresh: holt alle paar Sekunden die aktuelle Ticketliste (gleiche Filter/Seite
// über die URL) im Hintergrund und tauscht nur die Tabelle aus – so tauchen neue oder
// geänderte Tickets automatisch auf, ohne dass ein Mitarbeiter die Seite neu laden muss.
// Pausiert bewusst, solange gerade Tickets markiert sind (Sammelaktion in Vorbereitung)
// oder der Nutzer gerade in einem Formularfeld tippt/eine Auswahl offen hat, damit nichts
// unter den Fingern weggezogen wird.
(function () {
var region = document.getElementById('tickets-live-region');
if (!region) return;
var refreshing = false;
function partialUrl() {
var params = new URLSearchParams(window.location.search);
params.set('partial', '1');
return window.location.pathname + '?' + params.toString();
}
function refreshTicketList() {
if (refreshing) return;
if (document.querySelectorAll('.ticket-check:checked').length > 0) return;
var active = document.activeElement;
if (active && ['INPUT', 'SELECT', 'TEXTAREA'].includes(active.tagName)) return;
refreshing = true;
fetch(partialUrl(), { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
var fresh = tmp.querySelector('#tickets-live-region');
if (fresh) {
region.replaceWith(fresh);
region = fresh;
}
})
.catch(function () {})
.finally(function () { refreshing = false; });
}
setInterval(refreshTicketList, 15000);
})();
</script>
<?php require __DIR__ . '/_footer.php'; ?>
+120
View File
@@ -0,0 +1,120 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_admin_or_permission('view_time_entries');
$pdo = get_db();
$filterStaff = $_GET['staff_id'] ?? '';
$filterFrom = $_GET['from'] ?? date('Y-m-01');
$filterTo = $_GET['to'] ?? date('Y-m-d');
$allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
$where = ['te.logged_at BETWEEN ? AND ?'];
$params = [$filterFrom . ' 00:00:00', $filterTo . ' 23:59:59'];
if ($filterStaff !== '') {
$where[] = 'te.staff_id = ?';
$params[] = $filterStaff;
}
$stmt = $pdo->prepare(
"SELECT te.*, s.name AS staff_name, t.ticket_number, t.subject
FROM ticket_time_entries te
JOIN staff s ON s.id = te.staff_id
JOIN tickets t ON t.id = te.ticket_id
WHERE " . implode(' AND ', $where) . "
ORDER BY te.logged_at DESC
LIMIT 500"
);
$stmt->execute($params);
$entries = $stmt->fetchAll();
// Summen je Mitarbeiter
$byStaff = [];
foreach ($entries as $e) {
$byStaff[$e['staff_name']] = ($byStaff[$e['staff_name']] ?? 0) + (int)$e['minutes'];
}
$totalMinutes = array_sum(array_column($entries, 'minutes'));
$pageTitle = 'Zeiterfassung';
$activePage = 'time_entries';
require __DIR__ . '/_header.php';
function fmt_minutes(int $m): string {
return floor($m / 60) . 'h ' . ($m % 60) . 'min';
}
?>
<div class="topbar">
<h1>⏱ Zeiterfassung</h1>
</div>
<form method="get" class="filters card" style="margin-bottom:16px">
<div>
<label>Von</label>
<input type="date" name="from" value="<?= e($filterFrom) ?>">
</div>
<div>
<label>Bis</label>
<input type="date" name="to" value="<?= e($filterTo) ?>">
</div>
<div>
<label>Mitarbeiter</label>
<select name="staff_id">
<option value="">Alle</option>
<?php foreach ($allStaff as $s): ?>
<option value="<?= $s['id'] ?>" <?= $filterStaff == $s['id'] ? 'selected' : '' ?>><?= e($s['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div><button type="submit" class="btn secondary">Filtern</button></div>
</form>
<?php if ($byStaff): ?>
<div class="stat-grid" style="margin-bottom:20px">
<?php foreach ($byStaff as $name => $mins): ?>
<div class="stat-card">
<div class="num" style="font-size:1.4rem"><?= fmt_minutes($mins) ?></div>
<div class="lbl"><?= e($name) ?></div>
</div>
<?php endforeach; ?>
<div class="stat-card" style="border-color:var(--primary)">
<div class="num" style="font-size:1.4rem; color:var(--primary)"><?= fmt_minutes($totalMinutes) ?></div>
<div class="lbl">Gesamt</div>
</div>
</div>
<?php endif; ?>
<div class="card">
<?php if (!$entries): ?>
<div class="empty-state">Keine Zeiteinträge im gewählten Zeitraum.</div>
<?php else: ?>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Mitarbeiter</th>
<th>Ticket</th>
<th>Betreff</th>
<th>Zeit</th>
<th>Notiz</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $e): ?>
<tr>
<td class="muted"><?= format_dt($e['logged_at']) ?></td>
<td><?= e($e['staff_name']) ?></td>
<td><a href="/staff/ticket_view.php?n=<?= urlencode($e['ticket_number']) ?>" class="ticket-no">#<?= e($e['ticket_number']) ?></a></td>
<td><?= e($e['subject']) ?></td>
<td style="white-space:nowrap; font-weight:600"><?= fmt_minutes((int)$e['minutes']) ?></td>
<td class="muted"><?= e($e['note'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php require __DIR__ . '/_footer.php'; ?>
+420
View File
@@ -0,0 +1,420 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/backup.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
const UPDATE_REPO_API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/deskhub';
/** Lädt eine https-Adresse (Weiterleitungen nur auf https). Gibt [Status, Inhalt, Fehlertext] zurück. */
function update_download(string $url, array $headers, int $timeout): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => $headers,
]);
$data = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$code, is_string($data) ? $data : '', $err];
}
// ── ZIP installieren ─────────────────────────────────────────────────────────
function do_install(string $zipPath): array
{
$zip = new ZipArchive();
$openResult = $zip->open($zipPath);
if ($openResult !== true) {
return ['ok' => false, 'error' => 'ZIP konnte nicht geöffnet werden (Code: ' . $openResult . ', Datei: ' . $zipPath . ', Größe: ' . filesize($zipPath) . ')'];
}
$rootDir = str_replace('/', DIRECTORY_SEPARATOR, realpath(__DIR__ . '/..'));
$protected = [
'config/config.php',
'database/deskhub.sqlite',
'database/deskhub.db',
'.env',
'version.php',
'staff/update.php', // Sich selbst nicht überschreiben während des Updates
'includes/bootstrap.php', // Bootstrap nicht mid-request überschreiben
];
// Gitea-Prefix erkennen (z.B. "deskhub/")
$prefix = '';
for ($i = 0; $i < min($zip->numFiles, 5); $i++) {
$n = $zip->getNameIndex($i);
if (substr($n, -1) === '/' && substr_count(rtrim($n, '/'), '/') === 0) {
$prefix = $n;
break;
}
}
$extracted = 0;
$failed = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
// Verzeichnisse überspringen
if (substr($name, -1) === '/') continue;
// Prefix entfernen
if ($prefix) {
if (strpos($name, $prefix) !== 0) continue;
$rel = substr($name, strlen($prefix));
} else {
$rel = $name;
}
if ($rel === '') continue;
// Pfad prüfen: kein Verlassen des Installationsordners (Zip-Slip), keine absoluten Pfade
$relNorm = str_replace(chr(92), '/', $rel);
if ($relNorm[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $relNorm) || preg_match('#^[A-Za-z]:#', $relNorm) || strpos($relNorm, "\0") !== false) {
$failed[] = $rel;
continue;
}
// Die Plugin-Webseite und Entwickler-Werkzeuge gehören nicht auf Kunden-Installationen; Plugins, App-Dateien, Uploads und Backups gehören dem System und werden von Updates nie angefasst
if (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm) || preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm)) continue;
// Geschützte Dateien überspringen
$skip = false;
foreach ($protected as $p) {
if ($rel === $p || str_replace('/', DIRECTORY_SEPARATOR, $rel) === str_replace('/', DIRECTORY_SEPARATOR, $p)) {
$skip = true;
break;
}
}
if ($skip) continue;
// Windows-Pfad
$dest = $rootDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
$dir = dirname($dest);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$data = $zip->getFromIndex($i);
if ($data === false) continue;
$written = file_put_contents($dest, $data);
if ($written !== false) {
$extracted++;
} else {
$failed[] = $rel;
}
}
$zip->close();
if ($extracted === 0) {
$msg = 'Keine Dateien extrahiert!';
if ($prefix) $msg .= ' Prefix: "' . $prefix . '"';
if ($failed) $msg .= ' Fehlgeschlagen: ' . implode(', ', array_slice($failed, 0, 3));
return ['ok' => false, 'error' => $msg];
}
return ['ok' => true, 'extracted' => $extracted, 'failed' => $failed];
}
// ── POST ─────────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF prüfen ohne Token-Rotation (wegen Datei-Upload multipart)
$csrfToken = $_POST['csrf_token'] ?? '';
if (!$csrfToken || !hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
http_response_code(403);
die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.');
}
$action = $_POST['action'] ?? '';
// Manueller Upload
if ($action === 'upload_update') {
$f = $_FILES['update_zip'] ?? [];
if (($f['error'] ?? 99) !== UPLOAD_ERR_OK) {
$errors[] = 'Upload-Fehler Code: ' . ($f['error'] ?? 'unbekannt');
} elseif (strtolower(pathinfo($f['name'] ?? '', PATHINFO_EXTENSION)) !== 'zip') {
$errors[] = 'Nur ZIP-Dateien erlaubt.';
} else {
$tmp = $f['tmp_name'];
$bk = create_backup();
$res = do_install($tmp);
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
// Schema-Migrationen nach Update einspielen
require_once __DIR__ . '/../database/migrate.php';
$mig = run_migrations(get_db());
$migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : '';
if ($mig['errors']) $errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], "Update installiert: {$res['extracted']} Dateien{$migNote}");
flash('success', "✅ {$res['extracted']} Dateien aktualisiert.{$migNote}" . ($bk['ok'] ? " Backup: {$bk['filename']}" : ''));
redirect('/staff/update.php');
}
}
}
// Nur die Datenbank auf den Stand des installierten Codes bringen (z. B. nach Datei-Upload per FTP)
if ($action === 'migrate_db') {
require_once __DIR__ . '/../database/migrate.php';
$pending = migrations_pending($pdo);
if (!$pending) {
flash('success', 'Die Datenbank ist bereits auf dem aktuellen Stand.');
redirect('/staff/update.php');
}
$bk = create_backup();
if (!$bk['ok']) {
$errors[] = 'Backup fehlgeschlagen – die Datenbank wurde nicht verändert: ' . ($bk['error'] ?? 'unbekannter Fehler');
} else {
$mig = run_migrations($pdo);
if ($mig['errors']) {
$errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], 'Datenbank-Update mit Fehlern: ' . implode('; ', $mig['errors']));
} else {
audit_log($staff['id'], "Datenbank aktualisiert: {$mig['applied']} Migration(en)");
flash('success', "✅ Datenbank aktualisiert ({$mig['applied']} Migration(en)). Backup: {$bk['filename']}");
redirect('/staff/update.php');
}
}
}
// Auto-Download vom Update-Server. Ist ein Herausgeber-Schlüssel eingebaut, wird nur ein signiertes Release installiert.
if ($action === 'auto_update') {
$tagName = trim($_POST['tag_name'] ?? '');
$headers = update_http_headers();
if (!preg_match('/^v?\d+\.\d+\.\d+([\-+][0-9A-Za-z.\-]+)?$/', $tagName)) {
$errors[] = 'Kein gültiges Release gewählt.';
} else {
$verifyRequired = update_signature_required();
$zipUrl = UPDATE_REPO_API === '' ? '' : 'https://git.viper.ipv64.net/M_Viper/deskhub/archive/' . rawurlencode($tagName) . '.zip';
$sigUrl = null;
[$relCode, $relBody] = update_download(UPDATE_REPO_API . '/releases/tags/' . rawurlencode($tagName), $headers, 10);
$release = $relCode === 200 ? (json_decode($relBody, true) ?: []) : [];
$assets = update_find_assets($release, $tagName);
if ($assets['zip']) { $zipUrl = $assets['zip']; $sigUrl = $assets['sig']; }
if ($verifyRequired && (!$assets['zip'] || !$sigUrl)) {
$errors[] = 'Dieses Release ist nicht signiert (Anhänge deskhub-' . ltrim($tagName, 'vV') . '.zip und .zip.sig fehlen). Aus Sicherheitsgründen wird es nicht installiert.';
} else {
[$code, $data, $err] = update_download($zipUrl, $headers, 120);
if ($err) {
$errors[] = 'cURL-Fehler: ' . $err . (stripos($err, 'certificate') !== false || stripos($err, 'SSL') !== false ? ' – Das Serverzertifikat konnte nicht geprüft werden. Bitte das CA-Zertifikatspaket des Servers aktualisieren (php.ini: curl.cainfo). Die Prüfung wird aus Sicherheitsgründen nicht abgeschaltet.' : '');
} elseif ($code !== 200) {
$errors[] = "Download fehlgeschlagen (HTTP $code).";
} else {
$tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip';
file_put_contents($tmp, $data);
$sigError = null;
if ($verifyRequired) {
[$sc, $sigBody] = update_download($sigUrl, $headers, 20);
$sigError = $sc === 200 ? update_verify_package($tagName, $tmp, $sigBody) : "Die Signatur konnte nicht geladen werden (HTTP $sc).";
}
if ($sigError !== null) {
@unlink($tmp);
audit_log($staff['id'], "Update $tagName abgelehnt: $sigError");
$errors[] = $sigError;
} else {
$bk = create_backup();
$res = do_install($tmp);
@unlink($tmp);
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
// Schema-Migrationen nach Update einspielen
require_once __DIR__ . '/../database/migrate.php';
$mig = run_migrations(get_db());
$migNote = $mig['applied'] > 0 ? " · {$mig['applied']} Migration(en) eingespielt" : '';
if ($mig['errors']) $errors = array_merge($errors, $mig['errors']);
audit_log($staff['id'], "Auto-Update $tagName: {$res['extracted']} Dateien{$migNote}" . ($verifyRequired ? ' (Signatur geprüft)' : ''));
// Versionsnummer in version.php aktualisieren
$newVer = ltrim($tagName, 'v');
$verFile = realpath(__DIR__ . '/../version.php');
if ($verFile && $newVer) {
$vc = file_get_contents($verFile);
$vc = preg_replace("/define\('DESKHUB_VERSION',\s*'[^']+'\)/", "define('DESKHUB_VERSION', '$newVer')", $vc);
file_put_contents($verFile, $vc);
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
flash('success', "✅ Update auf $newVer: {$res['extracted']} Dateien." . ($verifyRequired ? ' Signatur geprüft.' : '') . ($bk['ok'] ? " Backup: {$bk['filename']}" : ''));
redirect('/staff/update.php');
}
}
}
}
}
}
}
// ── Releases laden ────────────────────────────────────────────────────────────
$releases = [];
$relErr = '';
[$code, $resp] = update_download(UPDATE_REPO_API . '/releases?limit=20', update_http_headers(), 8);
if ($code === 200) {
$all = json_decode($resp, true) ?: [];
usort($all, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name']));
$releases = $all;
} else {
$relErr = "API HTTP $code" . (update_http_headers() === [] ? ' (Ist das Repository privat? Dann in config/config.php unter update → token ein Lese-Token eintragen.)' : '');
}
$signedOnly = update_signature_required();
// Version frisch aus Datei lesen
$verFile = realpath(__DIR__ . '/../version.php') ?: '';
$verData = $verFile ? file_get_contents($verFile) : '';
preg_match("/define\s*\(\s*'DESKHUB_VERSION'\s*,\s*'([^']+)'/", $verData, $vm);
$curVer = $vm[1] ?? DESKHUB_VERSION;
$latest = $releases[0] ?? null;
$hasUpdate = $latest && version_compare($latest['tag_name'], $curVer, '>');
require_once __DIR__ . '/../database/migrate.php';
$pendingMigrations = migrations_pending($pdo);
$pageTitle = 'System-Update';
$activePage = 'update';
require __DIR__ . '/_header.php';
?>
<div class="topbar"><h1>🔄 System-Update</h1></div>
<?php foreach ($errors as $e): ?>
<div class="flash error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<?php if ($hasUpdate): ?>
<div style="background:#e0f2f1;border:1px solid #0f6f6a;border-radius:10px;padding:16px 20px;margin-bottom:20px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<strong style="color:#0f6f6a">Update verfügbar: Version <?= htmlspecialchars($latest['tag_name']) ?></strong>
<span style="color:#64748b;font-size:.85rem;margin-left:10px">vom <?= date('d.m.Y', strtotime($latest['published_at'])) ?></span>
</div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="auto_update">
<input type="hidden" name="tag_name" value="<?= htmlspecialchars($latest['tag_name']) ?>">
<button type="submit" class="btn" onclick="return confirm('Update auf <?= htmlspecialchars($latest['tag_name']) ?> installieren?')">Jetzt installieren</button>
</form>
</div>
<?php endif; ?>
<div class="card" style="margin-bottom:20px;<?= $pendingMigrations ? 'border-color:#d97706' : '' ?>">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Datenbank</div>
<?php if ($pendingMigrations): ?>
<strong style="color:#b45309"><?= count($pendingMigrations) ?> ausstehende Datenbank-Änderung(en)</strong>
<div style="color:var(--muted);font-size:.82rem;margin-top:4px">Der Code ist neuer als die Datenbank. Vor dem Einspielen wird automatisch ein Backup erstellt.</div>
<details style="margin-top:6px;font-size:.8rem;color:var(--muted)"><summary>Details</summary><?= htmlspecialchars(implode(', ', $pendingMigrations)) ?></details>
<?php else: ?>
<strong style="color:#15803d">✓ Die Datenbank ist aktuell</strong>
<div style="color:var(--muted);font-size:.82rem;margin-top:4px">Alle bekannten Datenbank-Änderungen sind eingespielt.</div>
<?php endif; ?>
</div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="migrate_db">
<button type="submit" class="btn <?= $pendingMigrations ? '' : 'secondary' ?>" <?= $pendingMigrations ? "onclick=\"return confirm('Datenbank jetzt aktualisieren? Es wird zuerst ein Backup erstellt.')\"" : 'disabled' ?>>🗄 Datenbank aktualisieren</button>
</form>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px">
<div class="card" style="margin:0">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Installierte Version</div>
<div style="font-size:3rem;font-weight:800;color:var(--primary);line-height:1"><?= htmlspecialchars($curVer) ?></div>
<div style="color:var(--muted);font-size:.82rem;margin-top:6px">DeskHub Ticketsystem</div>
<?php if ($releases): ?>
<button onclick="document.getElementById('rel-modal').style.display='flex'" class="btn secondary" style="margin-top:16px">📋 Alle Releases</button>
<?php endif; ?>
</div>
<div class="card" style="margin:0">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Manuelles Update</div>
<p style="color:var(--muted);font-size:.85rem;margin-bottom:14px">ZIP-Datei von Gitea hochladen und installieren.</p>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="action" value="upload_update">
<input type="file" name="update_zip" accept=".zip" required style="margin-bottom:12px">
<button type="submit" class="btn secondary">⬆️ Hochladen &amp; installieren</button>
</form>
</div>
</div>
<?php if (count($releases) > 1): ?>
<div class="card" style="margin-bottom:20px">
<h3>Bestimmte Version installieren</h3>
<form method="post" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
<?= csrf_field() ?>
<input type="hidden" name="action" value="auto_update">
<div style="flex:1;min-width:200px">
<select name="tag_name" required>
<option value="">– Version wählen –</option>
<?php foreach ($releases as $r): $isCur = $r['tag_name']===$curVer; ?>
<option value="<?= htmlspecialchars($r['tag_name']) ?>" <?= $isCur?'disabled':'' ?>>
<?= htmlspecialchars($r['tag_name']) ?><?= $isCur?' (installiert)':'' ?> – <?= date('d.m.Y', strtotime($r['published_at'])) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn secondary" onclick="return confirm('Version installieren?')">Installieren</button>
</form>
</div>
<?php endif; ?>
<div class="card">
<h3>Hinweise</h3>
<ul style="padding-left:20px;line-height:2;font-size:.88rem;color:var(--muted)">
<li><strong style="color:var(--ink)">config/config.php</strong> und <strong style="color:var(--ink)">database/deskhub.sqlite</strong> werden niemals überschrieben</li>
<?php if ($signedOnly): ?><li><strong style="color:var(--ink)">Nur signierte Releases:</strong> Updates werden nur installiert, wenn sie mit dem Schlüssel des Herausgebers signiert sind. Ein manipuliertes Paket wird abgelehnt. (Ein manuell hochgeladenes ZIP wird nicht geprüft: Laden Sie nur Pakete aus vertrauenswürdiger Quelle hoch.)</li><?php endif; ?>
<?php if ($relErr): ?><li style="color:#b45309">Release-Liste konnte nicht geladen werden: <?= htmlspecialchars($relErr) ?></li><?php endif; ?>
<li>Nach dem Update Browser-Cache leeren <kbd>Strg+F5</kbd></li>
</ul>
</div>
<!-- Releases Modal -->
<div id="rel-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9999;align-items:center;justify-content:center;padding:20px">
<div style="background:#fff;border-radius:14px;width:100%;max-width:600px;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;padding:18px 24px;border-bottom:1px solid var(--border)">
<h3 style="margin:0">Releases</h3>
<button onclick="document.getElementById('rel-modal').style.display='none'" style="background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted)">&times;</button>
</div>
<div style="overflow-y:auto;padding:20px 24px">
<?php foreach ($releases as $r):
$isNew = version_compare($r['tag_name'], $curVer, '>');
$isCur = $r['tag_name'] === $curVer;
?>
<div style="border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:10px<?= $isNew?';border-color:var(--primary)':'' ?>">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div style="display:flex;gap:8px;align-items:center">
<strong><?= htmlspecialchars($r['tag_name']) ?></strong>
<?php if ($isNew): ?><span style="background:var(--primary);color:#fff;font-size:.7rem;padding:2px 8px;border-radius:20px;font-weight:700">NEU</span><?php endif; ?>
<?php if ($isCur): ?><span style="background:#e2f5f3;color:var(--primary);font-size:.7rem;padding:2px 8px;border-radius:20px;font-weight:700">INSTALLIERT</span><?php endif; ?>
</div>
<span style="color:var(--muted);font-size:.78rem"><?= date('d.m.Y', strtotime($r['published_at'])) ?></span>
</div>
<?php if (!empty($r['body'])): ?>
<div style="font-size:.83rem;color:var(--muted);white-space:pre-wrap;margin-bottom:10px"><?= htmlspecialchars(mb_substr($r['body'], 0, 200)) ?><?= mb_strlen($r['body'])>200?'…':'' ?></div>
<?php endif; ?>
<?php if (!$isCur): ?>
<form method="post" onsubmit="return confirm('<?= htmlspecialchars($r['tag_name']) ?> installieren?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="auto_update">
<input type="hidden" name="tag_name" value="<?= htmlspecialchars($r['tag_name']) ?>">
<button type="submit" class="btn <?= $isNew?'':'secondary' ?>" style="font-size:.82rem;padding:6px 14px">⬆️ Installieren</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
document.getElementById('rel-modal')?.addEventListener('click', function(e){ if(e.target===this) this.style.display='none'; });
document.addEventListener('keydown', function(e){ if(e.key==='Escape') document.getElementById('rel-modal').style.display='none'; });
</script>
<?php require __DIR__ . '/_footer.php'; ?>
@@ -0,0 +1,24 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_admin();
$zipName = $_SESSION['update_zip'] ?? '';
if (!$zipName || !preg_match('/^deskhub-update-[\w\.\-]+\.zip$/', $zipName)) {
http_response_code(400); die('Kein Update-ZIP vorhanden.');
}
$zipPath = realpath(__DIR__ . '/../downloads') . '/' . $zipName;
if (!file_exists($zipPath)) {
http_response_code(404); die('Datei nicht gefunden.');
}
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $zipName . '"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: private, no-cache');
readfile($zipPath);
// Nach Download aufräumen
unlink($zipPath);
unset($_SESSION['update_zip'], $_SESSION['update_version'], $_SESSION['update_backup']);
exit;
@@ -0,0 +1,56 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_staff_login();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Nur POST erlaubt.']);
exit;
}
verify_csrf();
if (empty($_FILES['image'])) {
http_response_code(422);
echo json_encode(['error' => 'Bitte eine Bilddatei auswählen.']);
exit;
}
$file = $_FILES['image'];
$allowedImageExts = array_values(array_intersect(get_allowed_extensions(), ['png', 'jpg', 'jpeg', 'gif']));
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
if ($file['error'] !== UPLOAD_ERR_OK) {
http_response_code(422);
echo json_encode(['error' => 'Der Upload ist fehlgeschlagen.']);
exit;
}
if (!in_array($ext, $allowedImageExts, true)) {
http_response_code(422);
echo json_encode(['error' => 'Nur Bilder erlaubt (' . implode(', ', array_map('strtoupper', $allowedImageExts)) . ').']);
exit;
}
if ($file['size'] > 5 * 1024 * 1024) {
http_response_code(422);
echo json_encode(['error' => 'Die Datei ist zu groß (max. 5 MB).']);
exit;
}
$result = handle_ticket_upload($file);
if (isset($result['error'])) {
http_response_code(422);
echo json_encode(['error' => $result['error']]);
exit;
}
$staff = current_staff();
audit_log($staff['id'], "Bild hochgeladen: {$result['stored_name']}");
// verify_csrf() hat den Session-Token bereits rotiert – der neue Wert muss zurück an die Seite,
// sonst schlägt das nachfolgende Absenden des umgebenden Formulars (z.B. Signatur speichern) fehl.
echo json_encode([
'url' => base_url() . '/uploads/' . $result['stored_name'],
'csrfToken' => csrf_token(),
]);
+160
View File
@@ -0,0 +1,160 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
$eventLabels = webhook_event_labels();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create' || $action === 'update') {
$id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
try {
webhook_save($pdo, $id, [
'name' => $_POST['name'] ?? '', 'url' => $_POST['url'] ?? '', 'secret' => $_POST['secret'] ?? '',
'is_active' => ($_POST['status'] ?? 'active') === 'active', 'events' => $_POST['events'] ?? [],
]);
audit_log($staff['id'], ($action === 'create' ? 'Webhook erstellt: ' : 'Webhook aktualisiert: ') . trim($_POST['name'] ?? ''));
flash('success', $action === 'create' ? 'Webhook erstellt.' : 'Änderungen gespeichert.');
redirect('/staff/webhooks.php');
} catch (AdminModelException $e) {
$errors[] = $e->getMessage();
}
}
if ($action === 'delete') {
$pdo->prepare('DELETE FROM webhooks WHERE id = ?')->execute([(int) $_POST['id']]);
audit_log($staff['id'], 'Webhook gelöscht: #' . (int) $_POST['id']);
flash('success', 'Webhook gelöscht.');
redirect('/staff/webhooks.php');
}
if ($action === 'test') {
$hook = $pdo->prepare('SELECT * FROM webhooks WHERE id = ?');
$hook->execute([(int) $_POST['id']]);
if ($row = $hook->fetch()) {
$payload = ['event' => 'test', 'occurredAt' => gmdate('c'), 'message' => 'Test-Ereignis von DeskHub'];
[$status, $error] = webhook_deliver($row, $payload);
$error === null ? flash('success', "Test erfolgreich zugestellt (HTTP {$status}).") : flash('error', 'Test fehlgeschlagen: ' . $error);
}
redirect('/staff/webhooks.php?edit=' . (int) $_POST['id']);
}
if ($action === 'reactivate') {
$pdo->prepare('UPDATE webhooks SET is_active = 1, fail_count = 0 WHERE id = ?')->execute([(int) $_POST['id']]);
redirect('/staff/webhooks.php');
}
}
$isNew = isset($_GET['new']);
$editId = (int) ($_GET['edit'] ?? 0);
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
$hooks = $pdo->query('SELECT * FROM webhooks ORDER BY name')->fetchAll();
$editHook = null;
if ($editId) {
$stmt = $pdo->prepare('SELECT * FROM webhooks WHERE id = ?');
$stmt->execute([$editId]);
$editHook = $stmt->fetch() ?: null;
}
$pageTitle = 'Webhooks';
$activePage = 'webhooks';
require __DIR__ . '/_header.php';
?>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($showForm): ?>
<?php
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
$v = fn(string $k, string $col, $default = '') => $f($k, $editHook[$col] ?? $default);
$curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editHook['is_active'] ?? 1);
$curEvents = $_POST ? (array) ($_POST['events'] ?? []) : ($editHook ? explode(',', $editHook['events']) : array_keys($eventLabels));
?>
<h1>
<?= $editHook ? 'Webhook aktualisieren' : 'Neuer Webhook' ?>
<?php if ($editHook): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editHook['name']) ?></span><?php endif; ?>
</h1>
<form method="post" class="card" style="padding:0" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="<?= $editHook ? 'update' : 'create' ?>">
<?php if ($editHook): ?><input type="hidden" name="id" value="<?= (int) $editHook['id'] ?>"><?php endif; ?>
<?php ui_tab_bar(['hook' => 'Webhook', 'events' => 'Ereignisse']); ?>
<div class="tab-pane" data-pane="hook" style="padding:16px 20px">
<h3 style="margin-top:0">Ziel</h3>
<label for="name">Name *</label>
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" placeholder="z. B. Slack-Benachrichtigung" required>
<label for="url">URL *</label>
<input type="url" id="url" name="url" value="<?= e($v('url', 'url')) ?>" placeholder="https://example.de/hooks/deskhub" required>
<label for="secret">Geheimnis zum Signieren</label>
<input type="password" id="secret" name="secret" placeholder="<?= $editHook ? '(unverändert)' : 'leer = wird automatisch erzeugt' ?>" autocomplete="new-password">
<p class="muted" style="font-size:.8rem">Jede Zustellung enthält den Header <code>X-DeskHub-Signature: sha256=…</code> (HMAC-SHA256 des Körpers mit diesem Geheimnis), damit dein System die Herkunft prüfen kann.</p>
<?php ui_status_radios($curActive); ?>
<?php if ($editHook): ?>
<p class="muted" style="font-size:.82rem">
Letzte Zustellung: <?= e(cron_last_run_label($editHook['last_delivery_at'] ?? null)) ?>
<?= $editHook['last_status'] ? ' · HTTP ' . (int) $editHook['last_status'] : '' ?>
<?= $editHook['last_error'] ? ' · <span style="color:#dc2626">' . e($editHook['last_error']) . '</span>' : '' ?>
<?= (int) $editHook['fail_count'] > 0 ? ' · ' . (int) $editHook['fail_count'] . ' Fehler in Folge (nach 10 wird der Webhook automatisch deaktiviert)' : '' ?>
</p>
<?php endif; ?>
</div>
<div class="tab-pane" data-pane="events" style="padding:16px 20px;display:none">
<h3 style="margin-top:0">Ereignisse</h3>
<?php foreach ($eventLabels as $key => $label): ?>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin:6px 0">
<input type="checkbox" name="events[]" value="<?= e($key) ?>" style="width:auto" <?= in_array($key, $curEvents, true) ? 'checked' : '' ?>>
<?= e($label) ?> <code class="muted"><?= e($key) ?></code>
</label>
<?php endforeach; ?>
<p class="muted" style="font-size:.82rem;margin-top:12px">Der Körper ist JSON mit <code>event</code>, <code>occurredAt</code> und den Ticketdaten (Nummer, Betreff, Status, Priorität, Abteilung, Anfragender).</p>
</div>
<div class="btn-row" style="padding:0 20px 18px">
<button type="submit" class="btn"><?= $editHook ? 'Änderungen speichern' : 'Erstellen' ?></button>
<a href="/staff/webhooks.php" class="btn secondary">Abbrechen</a>
</div>
</form>
<?php if ($editHook): ?>
<form method="post" style="margin-top:10px"><?= csrf_field() ?><input type="hidden" name="action" value="test"><input type="hidden" name="id" value="<?= (int) $editHook['id'] ?>">
<button type="submit" class="btn secondary">Test-Ereignis senden</button> <span class="muted" style="font-size:.8rem">(verwendet die gespeicherten Werte)</span></form>
<?php endif; ?>
<?php ui_tab_assets('hook'); ?>
<?php else: ?>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h1 style="margin:0">Webhooks</h1>
<a href="/staff/webhooks.php?new=1" class="btn">Neuen Webhook hinzufügen</a>
</div>
<p class="muted">DeskHub meldet Ereignisse (Ticket erstellt, beantwortet, Status geändert, zugewiesen) per HTTP-POST an die hinterlegten URLs – z. B. für Chat-Benachrichtigungen oder Ihr eigenes System. Tickets von außen anlegen: <a href="/staff/api_keys.php">API-Schlüssel</a>.</p>
<div class="card">
<table>
<thead><tr><th>Name</th><th>URL</th><th>Ereignisse</th><th>Status</th><th>Letzte Zustellung</th><th></th></tr></thead>
<tbody>
<?php foreach ($hooks as $h): ?>
<tr>
<td><a href="/staff/webhooks.php?edit=<?= (int) $h['id'] ?>"><?= e($h['name']) ?></a></td>
<td class="muted" style="font-size:.82rem;word-break:break-all"><?= e($h['url']) ?></td>
<td style="font-size:.8rem"><?= e(implode(', ', array_map(fn($k) => $eventLabels[$k] ?? $k, explode(',', $h['events'])))) ?></td>
<td><strong><?= $h['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></strong><?= !$h['is_active'] && (int) $h['fail_count'] >= 10 ? '<div class="muted" style="font-size:.74rem">wegen Fehlern abgeschaltet</div>' : '' ?></td>
<td class="muted" style="font-size:.82rem"><?= e(cron_last_run_label($h['last_delivery_at'] ?? null)) ?><?= $h['last_error'] ? '<div style="color:#dc2626">' . e($h['last_error']) . '</div>' : ($h['last_status'] ? ' · HTTP ' . (int) $h['last_status'] : '') ?></td>
<td style="white-space:nowrap">
<a href="/staff/webhooks.php?edit=<?= (int) $h['id'] ?>">Bearbeiten</a>
<?php if (!$h['is_active']): ?> · <form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="action" value="reactivate"><input type="hidden" name="id" value="<?= (int) $h['id'] ?>"><button type="submit" style="background:none;border:none;color:var(--primary);cursor:pointer;padding:0;font-size:.9rem">Aktivieren</button></form><?php endif; ?> ·
<form method="post" style="display:inline" onsubmit="return confirm('Webhook löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= (int) $h['id'] ?>">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button></form>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$hooks): ?><tr><td colspan="6" class="muted">Noch keine Webhooks angelegt.</td></tr><?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>
+40
View File
@@ -0,0 +1,40 @@
/**
* DeskHub Service Worker – Mitarbeiterbereich (scope: /staff/)
*/
const CACHE = 'deskhub-staff-v1';
const OFFLINE_URL = '/offline.html';
const PRECACHE = ['/offline.html', '/assets/css/style.css'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
if (e.request.method !== 'GET') return;
if (e.request.url.match(/\.(css|js|woff2?|png|svg|ico|webp)(\?.*)?$/)) {
e.respondWith(
caches.match(e.request).then(cached =>
cached || fetch(e.request).then(resp => {
caches.open(CACHE).then(c => c.put(e.request, resp.clone()));
return resp;
})
)
);
return;
}
e.respondWith(
fetch(e.request).catch(() => caches.match(OFFLINE_URL))
);
});
+42
View File
@@ -0,0 +1,42 @@
/**
* DeskHub Service Worker – Kundenportal (scope: /)
*/
const CACHE = 'deskhub-customer-v1';
const OFFLINE_URL = '/offline.html';
const PRECACHE = ['/offline.html', '/assets/css/style.css'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
if (e.request.method !== 'GET') return;
// Staff-Bereich nicht vom Kunden-SW abfangen
if (e.request.url.includes('/staff/')) return;
if (e.request.url.match(/\.(css|js|woff2?|png|svg|ico|webp)(\?.*)?$/)) {
e.respondWith(
caches.match(e.request).then(cached =>
cached || fetch(e.request).then(resp => {
caches.open(CACHE).then(c => c.put(e.request, resp.clone()));
return resp;
})
)
);
return;
}
e.respondWith(
fetch(e.request).catch(() => caches.match(OFFLINE_URL))
);
});
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
/**
* Prüft die gemeinsame Logik der osTicket-artigen Verwaltung:
* E-Mail-Adressen (inkl. IMAP/SMTP, Deaktivieren), Rollen pro Abteilung,
* Abteilungs-Status, Hilfethemen-Hierarchie und Team-Benachrichtigung.
* Läuft gegen eine frische In-Memory-SQLite-Datenbank.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$throws = function (callable $fn, string $code) use ($check): void {
try { $fn(); $check(false, "erwartete Fehler {$code}"); }
catch (EmailAddressException $e) { $check($e->errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); }
};
$pdo = get_db();
$now = db_now();
$dept1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Zweite Abteilung', '', $now]);
$dept2 = (int) $pdo->lastInsertId();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
// ── E-Mail-Adressen ─────────────────────────────────────────────────────────
$id = email_address_save($pdo, 0, ['email' => 'Support@Example.Test', 'label' => 'Support', 'department_id' => $dept1, 'priority_id' => $prio, 'is_default' => true]);
$row = $pdo->query("SELECT * FROM email_addresses WHERE id = {$id}")->fetch();
$check($row['email'] === 'support@example.test', 'E-Mail wird kleingeschrieben gespeichert');
$check((int) $row['is_active'] === 1, 'neue Adresse ist aktiv');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'kaputt', 'department_id' => $dept1]), 'INVALID_EMAIL');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'support@example.test', 'department_id' => $dept1]), 'DUPLICATE_EMAIL');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'x@example.test', 'department_id' => 9999]), 'INVALID_DEPARTMENT');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'imap@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => '']), 'IMAP_HOST_REQUIRED');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'imap@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => 'imap.example.test']), 'IMAP_PASSWORD_REQUIRED');
$throws(fn() => email_address_save($pdo, 0, ['email' => 'smtp@example.test', 'department_id' => $dept1, 'smtp_enabled' => true, 'smtp_host' => '']), 'SMTP_HOST_REQUIRED');
// IMAP anlegen, Passwort bleibt bei leerem Feld erhalten, Deaktivieren pausiert den Abruf
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept1, 'imap_enabled' => true, 'imap_host' => 'imap.example.test', 'imap_password' => 'geheim']);
$imap = email_address_imap($pdo, $id);
$check($imap !== null && (int) $imap['is_active'] === 1 && (int) $imap['department_id'] === $dept1, 'IMAP-Postfach angelegt und verknüpft');
$check(decrypt_secret($imap['password_enc']) === 'geheim', 'IMAP-Passwort verschlüsselt gespeichert');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test', 'imap_password' => '']);
$imap = email_address_imap($pdo, $id);
$check(decrypt_secret($imap['password_enc']) === 'geheim', 'leeres Passwortfeld lässt das Passwort unverändert');
$check((int) $imap['department_id'] === $dept2 && $imap['host'] === 'imap2.example.test', 'Abteilung/Host werden am Postfach nachgezogen');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'is_active' => false, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test']);
$check((int) email_address_imap($pdo, $id)['is_active'] === 0, 'deaktivierte Adresse pausiert den IMAP-Abruf');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'imap_enabled' => true, 'imap_host' => 'imap2.example.test']);
$check((int) $pdo->query("SELECT is_active FROM email_addresses WHERE id = {$id}")->fetchColumn() === 0, 'fehlendes is_active lässt den Status unverändert');
// SMTP pro Adresse: nur aktive Adresse mit aktiviertem SMTP wird verwendet; Daten bleiben beim Abschalten erhalten
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'is_active' => true, 'smtp_enabled' => true, 'smtp_host' => 'smtp.example.test', 'smtp_port' => 465, 'smtp_encryption' => 'ssl', 'smtp_username' => 'u', 'smtp_password' => 'pw']);
$smtp = email_address_smtp_for($pdo, 'Support <support@example.test>');
$check($smtp !== null && $smtp['smtp_host'] === 'smtp.example.test' && (int) $smtp['smtp_port'] === 465, 'eigener SMTP wird für die Absenderadresse gefunden');
$check($smtp !== null && decrypt_secret($smtp['smtp_password_enc']) === 'pw', 'SMTP-Passwort verschlüsselt gespeichert');
$check(email_address_smtp_for($pdo, 'andere@example.test') === null, 'fremde Absenderadresse nutzt keinen eigenen SMTP');
email_address_save($pdo, $id, ['email' => 'support@example.test', 'department_id' => $dept2, 'smtp_enabled' => false]);
$check(email_address_smtp_for($pdo, 'support@example.test') === null, 'ausgeschalteter SMTP wird nicht verwendet');
$kept = $pdo->query("SELECT smtp_host, smtp_password_enc FROM email_addresses WHERE id = {$id}")->fetch();
$check($kept['smtp_host'] === 'smtp.example.test' && $kept['smtp_password_enc'] !== null, 'SMTP-Zugangsdaten bleiben beim Ausschalten erhalten');
// Absenderauswahl ignoriert deaktivierte Adressen
$pdo->prepare('UPDATE email_addresses SET is_active = 0 WHERE id = ?')->execute([$id]);
$check(resolve_from_address($dept2) === null, 'deaktivierte Adresse wird nicht als Absender gewählt');
$pdo->prepare('UPDATE email_addresses SET is_active = 1 WHERE id = ?')->execute([$id]);
$check(resolve_from_address($dept2) !== null, 'aktive Adresse wird als Absender gewählt');
// Löschen entfernt das Postfach mit
$deleted = email_address_delete($pdo, $id);
$check($deleted === 'support@example.test', 'Löschen gibt die E-Mail zurück');
$check(email_address_imap($pdo, $id) === null, 'Postfach wird mit der Adresse gelöscht');
// ── Abteilungen: Status ─────────────────────────────────────────────────────
$pdo->prepare('UPDATE departments SET is_active = 0 WHERE id = ?')->execute([$dept2]);
$select = array_map('intval', array_column(departments_for_select($pdo), 'id'));
$check(!in_array($dept2, $select, true), 'deaktivierte Abteilung fehlt in Auswahllisten');
$select = array_map('intval', array_column(departments_for_select($pdo, [$dept2]), 'id'));
$check(in_array($dept2, $select, true), 'bereits gewählte deaktivierte Abteilung bleibt auswählbar');
$check(active_department_id($pdo, $dept2) === $dept1, 'deaktivierte Abteilung weicht auf eine aktive aus');
$check(active_department_id($pdo, $dept1) === $dept1, 'aktive Abteilung bleibt');
$pdo->prepare('UPDATE departments SET is_active = 1 WHERE id = ?')->execute([$dept2]);
// ── Hilfethemen: Hierarchie, Typ, Abteilungsstatus ──────────────────────────
$ins = $pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, is_public, parent_id, sort_order) VALUES (?,?,?,?,?,?,?)');
$ins->execute(['Eltern', $dept1, $prio, 1, 1, null, 1]);
$parent = (int) $pdo->lastInsertId();
$ins->execute(['Kind', $dept1, $prio, 1, 1, $parent, 2]);
$child = (int) $pdo->lastInsertId();
$ins->execute(['Intern', $dept1, $prio, 1, 0, null, 3]);
$ins->execute(['In zweiter Abteilung', $dept2, $prio, 1, 1, null, 4]);
$paths = array_column(help_topic_options($pdo, true), 'path');
$check(in_array('Eltern / Kind', $paths, true), 'Kind-Thema hat den Pfad "Eltern / Kind"');
$check(!in_array('Intern', $paths, true), 'interne Themen sind für Kunden nicht sichtbar');
$check(in_array('Intern', array_column(help_topic_options($pdo, false), 'path'), true), 'interne Themen sind für Mitarbeiter sichtbar');
$check(array_search('Eltern', $paths, true) < array_search('Eltern / Kind', $paths, true), 'Eltern stehen vor ihren Kindern');
$pdo->prepare('UPDATE departments SET is_active = 0 WHERE id = ?')->execute([$dept2]);
$check(!in_array('In zweiter Abteilung', array_column(help_topic_options($pdo, true), 'path'), true), 'Themen deaktivierter Abteilungen sind im Kundenportal verborgen');
$pdo->prepare('UPDATE help_topics SET parent_id = ? WHERE id = ?')->execute([$child, $parent]); // Zyklus
$check(count(help_topic_options($pdo, false)) >= 1, 'zyklische Hierarchie lässt die Auflistung nicht hängen');
$pdo->prepare('UPDATE help_topics SET parent_id = NULL WHERE id = ?')->execute([$parent]);
$pdo->prepare('UPDATE departments SET is_active = 1 WHERE id = ?')->execute([$dept2]);
// ── Rollen pro Abteilung ────────────────────────────────────────────────────
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at) VALUES (?,?,1,?)')
->execute(['Nur lesen', json_encode(['reply_ticket' => false, 'delete_ticket' => false, 'close_ticket' => false]), $now]);
$readOnly = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at) VALUES (?,?,1,?)')
->execute(['Löscher', json_encode(['reply_ticket' => true, 'delete_ticket' => true]), $now]);
$deleter = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Agent','agent@example.test','x','agent',?,?,1,?)")
->execute([$readOnly, $dept1, $now]);
$agentId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,1,?)')->execute([$agentId, $dept2, $deleter]);
$agent = $pdo->query("SELECT * FROM staff WHERE id = {$agentId}")->fetch();
$check(!has_permission($agent, 'reply_ticket'), 'Hauptrolle gilt ohne Abteilungsangabe');
$check(!has_permission($agent, 'reply_ticket', $dept1), 'Hauptrolle gilt in der Hauptabteilung');
$check(has_permission($agent, 'reply_ticket', $dept2) && has_permission($agent, 'delete_ticket', $dept2), 'eigene Rolle gilt in der erweiterten Abteilung');
$pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE staff_id = ? AND department_id = ?')->execute([$agentId, $dept2]);
// Cache der Funktion ist pro Prozess: neuen Mitarbeiter mit gleicher Konstellation prüfen
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES ('Agent2','agent2@example.test','x','agent',?,?,1,?)")
->execute([$readOnly, $dept1, $now]);
$agent2Id = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,1,NULL)')->execute([$agent2Id, $dept2]);
$agent2 = $pdo->query("SELECT * FROM staff WHERE id = {$agent2Id}")->fetch();
$check(!has_permission($agent2, 'reply_ticket', $dept2), 'ohne eigene Rolle gilt in der erweiterten Abteilung die Hauptrolle');
$pdo->prepare('UPDATE roles SET is_active = 0 WHERE id = ?')->execute([$readOnly]);
$agent3 = $agent2; $agent3['id'] = 987654; // frischer Cache-Schlüssel, Rollen-Cache bleibt: neue Rolle prüfen
$pdo->prepare("INSERT INTO roles (name, permissions, is_active, created_at) VALUES ('Aus', ?, 0, ?)")->execute([json_encode(['reply_ticket' => false]), $now]);
$offId = (int) $pdo->lastInsertId();
$agent3['role_id'] = $offId; $agent3['department_id'] = $dept1;
$check(has_permission($agent3, 'reply_ticket') === (bool) default_permissions('agent')['reply_ticket'], 'deaktivierte Rolle fällt auf Standardrechte zurück');
$admin = ['id' => 1, 'role' => 'admin', 'role_id' => null, 'department_id' => $dept1];
$check(has_permission($admin, 'delete_ticket', $dept2), 'Administrator darf immer');
// ── Team-Benachrichtigung ───────────────────────────────────────────────────
$pdo->prepare('INSERT INTO teams (name, is_active, no_alerts, created_at) VALUES (?,?,?,?)')->execute(['Team A', 1, 1, $now]);
$teamQuiet = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO teams (name, is_active, no_alerts, created_at) VALUES (?,?,?,?)')->execute(['Team B', 0, 0, $now]);
$teamOff = (int) $pdo->lastInsertId();
$ticket = ['ticket_number' => 'T-1', 'subject' => 'Test'];
$check(notify_team_assignment($pdo, $ticket, $teamQuiet) === 0, 'Team mit deaktivierter Benachrichtigung sendet nichts');
$check(notify_team_assignment($pdo, $ticket, $teamOff) === 0, 'deaktiviertes Team sendet nichts');
$check(notify_team_assignment($pdo, $ticket, 99999) === 0, 'unbekanntes Team sendet nichts');
// ── Abteilung löschen: nie ein Absturz, immer eine verständliche Meldung ──────────
$deptCode = function (callable $fn): string {
try { $fn(); return 'OK'; }
catch (AdminModelException $e) { return $e->errorCode() . '|' . $e->getMessage(); }
catch (Throwable $e) { return 'ABSTURZ ' . get_class($e) . ': ' . $e->getMessage(); }
};
$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Zum Löschen', '', $now]);
$delDept = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['Thema der Abteilung', $delDept, $prio, $now]);
$topicOfDept = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, '1 Hilfethemen'), 'Abteilung mit Hilfethema: verständliche Meldung statt Absturz: ' . $res);
$check((int) $pdo->query("SELECT COUNT(*) FROM departments WHERE id = {$delDept}")->fetchColumn() === 1, 'die Abteilung bleibt dabei erhalten');
$pdo->exec("DELETE FROM help_topics WHERE id = {$topicOfDept}");
$pdo->prepare('INSERT INTO email_addresses (email, label, department_id, is_active, created_at) VALUES (?,?,?,1,?)')->execute(['zum-loeschen@example.test', 'X', $delDept, $now]);
$mailId = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, 'E-Mail-Adressen'), 'Abteilung mit E-Mail-Adresse wird nicht gelöscht (die Adresse ginge sonst verloren): ' . $res);
$pdo->exec("DELETE FROM email_addresses WHERE id = {$mailId}");
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')->execute(['DEL-1', 'x', 'k', 'k@example.test', $delDept, $prio, 'open', $now, $now]);
$ticketOfDept = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, '1 Tickets'), 'Abteilung mit Tickets wird nicht gelöscht: ' . $res);
$pdo->exec("DELETE FROM tickets WHERE id = {$ticketOfDept}");
$check(str_starts_with($deptCode(fn() => department_delete($pdo, $dept1)), 'DEPARTMENT_IS_DEFAULT|') || str_starts_with($deptCode(fn() => department_delete($pdo, $dept1)), 'DEPARTMENT_IN_USE|'), 'Standardabteilung kann nicht gelöscht werden');
$check(str_starts_with($deptCode(fn() => department_delete($pdo, 999999)), 'NOT_FOUND|'), 'unbekannte Abteilung liefert eine Meldung');
// zusätzliche Mitgliedschaft und Textbaustein: werden beim Löschen aufgeräumt
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Mitglied','mitglied@example.test','x','agent',?,1,?)")->execute([$dept1, $now]);
$memberId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO staff_department (staff_id, department_id) VALUES (?,?)')->execute([$memberId, $delDept]);
$pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)')->execute(['Baustein', 'Text', $delDept, $now]);
$canId = (int) $pdo->lastInsertId();
$res = $deptCode(fn() => department_delete($pdo, $delDept));
$check($res === 'OK', 'leere Abteilung lässt sich löschen: ' . $res);
$check((int) $pdo->query("SELECT COUNT(*) FROM departments WHERE id = {$delDept}")->fetchColumn() === 0, 'Abteilung ist weg');
$check((int) $pdo->query("SELECT COUNT(*) FROM staff_department WHERE department_id = {$delDept}")->fetchColumn() === 0, 'zusätzliche Mitgliedschaften wurden mit aufgeräumt');
$check($pdo->query("SELECT department_id FROM canned_responses WHERE id = {$canId}")->fetchColumn() === null, 'Textbausteine der Abteilung gelten danach für alle');
// Mitarbeiter mit dieser Hauptabteilung blockieren das Löschen
$pdo->prepare('INSERT INTO departments (name, email, is_default, created_at) VALUES (?,?,0,?)')->execute(['Mit Mitarbeiter', '', $now]);
$withStaff = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Haupt','haupt@example.test','x','agent',?,1,?)")->execute([$withStaff, $now]);
$res = $deptCode(fn() => department_delete($pdo, $withStaff));
$check(str_starts_with($res, 'DEPARTMENT_IN_USE|') && str_contains($res, 'Hauptabteilung'), 'Mitarbeiter mit dieser Hauptabteilung blockieren das Löschen: ' . $res);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Admin models integration checks passed.\n";
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* Prüft die gemeinsam genutzte Verwaltungslogik (includes/admin_models.php) für Abteilungen,
* Hilfethemen, Teams und SLA-Pläne sowie die Überwachung des Mail-Abrufs (includes/health.php).
* Läuft gegen eine frische In-Memory-SQLite-Datenbank.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$throwsAm = function (callable $fn, string $code) use ($check): void {
try { $fn(); $check(false, "erwartete Fehler {$code}"); }
catch (AdminModelException $e) { $check($e->errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); }
};
$pdo = get_db();
$now = db_now();
$dept1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Agent','agent@example.test','x','agent',?,1,?)")->execute([$dept1, $now]);
$agentId = (int) $pdo->lastInsertId();
// ── Abteilungen ─────────────────────────────────────────────────────────────
$newDept = department_save($pdo, 0, ['name' => 'Vertrieb', 'email' => '', 'is_default' => false]);
$check($newDept > 0, 'Abteilung wird angelegt');
$check((int) $pdo->query("SELECT is_active FROM departments WHERE id = {$newDept}")->fetchColumn() === 1, 'neue Abteilung ist aktiv');
$throwsAm(fn() => department_save($pdo, 0, ['name' => '']), 'NAME_REQUIRED');
$throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'is_default' => true, 'is_active' => false]), 'DEFAULT_INACTIVE');
$throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'manager_id' => 99999]), 'INVALID_MANAGER');
$throwsAm(fn() => department_save($pdo, $newDept, ['name' => 'Vertrieb', 'sla_plan_id' => 99999]), 'INVALID_SLA_PLAN');
department_save($pdo, $newDept, ['name' => 'Vertrieb Nord', 'is_active' => false]);
department_save($pdo, $newDept, ['name' => 'Vertrieb Nord']);
$check((int) $pdo->query("SELECT is_active FROM departments WHERE id = {$newDept}")->fetchColumn() === 0, 'fehlendes is_active lässt den Abteilungsstatus unverändert');
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id) VALUES (?,?,?,?,?,?)')
->execute(['AM-1', 'Test', 'X', 'x@example.test', $newDept, $prio]);
$throwsAm(fn() => department_delete($pdo, $newDept), 'DEPARTMENT_IN_USE');
// ── Hilfethemen ─────────────────────────────────────────────────────────────
$tParent = help_topic_save($pdo, 0, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio]);
$tChild = help_topic_save($pdo, 0, ['name' => 'B', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tParent]);
$throwsAm(fn() => help_topic_save($pdo, $tParent, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tChild]), 'INVALID_PARENT');
$throwsAm(fn() => help_topic_save($pdo, $tParent, ['name' => 'A', 'department_id' => $dept1, 'priority_id' => $prio, 'parent_id' => $tParent]), 'INVALID_PARENT');
$throwsAm(fn() => help_topic_save($pdo, 0, ['name' => 'C', 'department_id' => 99999, 'priority_id' => $prio]), 'INVALID_DEPARTMENT');
help_topic_delete($pdo, $tParent);
$check((int) $pdo->query("SELECT COALESCE(parent_id, 0) FROM help_topics WHERE id = {$tChild}")->fetchColumn() === 0, 'Unterthema rückt beim Löschen des Elternthemas nach oben');
// ── Teams ───────────────────────────────────────────────────────────────────
$teamA = team_save($pdo, 0, ['name' => 'Alpha', 'members' => [$agentId, $agentId, 0]]);
$check((int) $pdo->query("SELECT COUNT(*) FROM team_members WHERE team_id = {$teamA}")->fetchColumn() === 1, 'Teammitglieder werden ohne Duplikate gespeichert');
$throwsAm(fn() => team_save($pdo, 0, ['name' => 'alpha']), 'NAME_TAKEN');
$throwsAm(fn() => team_save($pdo, 0, ['name' => 'Beta', 'lead_id' => 99999]), 'INVALID_LEAD');
team_save($pdo, $teamA, ['name' => 'Alpha', 'is_active' => false, 'no_alerts' => true]);
team_save($pdo, $teamA, ['name' => 'Alpha 2']);
$row = $pdo->query("SELECT is_active, no_alerts FROM teams WHERE id = {$teamA}")->fetch();
$check((int) $row['is_active'] === 0 && (int) $row['no_alerts'] === 1, 'fehlende Team-Schalter bleiben unverändert');
// ── SLA-Pläne ───────────────────────────────────────────────────────────────
$throwsAm(fn() => sla_plan_save($pdo, 0, ['name' => 'Ungültig', 'response_hours' => 10, 'resolution_hours' => 5]), 'INVALID_HOURS');
$planDept = sla_plan_save($pdo, 0, ['name' => 'Abteilungsplan', 'response_hours' => 2, 'resolution_hours' => 8]);
$planTopic = sla_plan_save($pdo, 0, ['name' => 'Themenplan', 'response_hours' => 1, 'resolution_hours' => 3]);
$throwsAm(fn() => sla_plan_save($pdo, 0, ['name' => 'abteilungsplan', 'response_hours' => 1, 'resolution_hours' => 2]), 'NAME_TAKEN');
$pdo->prepare('UPDATE departments SET sla_plan_id = ? WHERE id = ?')->execute([$planDept, $dept1]);
$topicSla = help_topic_save($pdo, 0, ['name' => 'Mit Plan', 'department_id' => $dept1, 'priority_id' => $prio, 'sla_plan_id' => $planTopic]);
$prioRow = $pdo->query("SELECT * FROM priorities WHERE id = {$prio}")->fetch();
$from = '2026-01-05 08:00:00';
$none = calc_sla_dates($prioRow, $from);
$viaDept = calc_sla_dates($prioRow, $from, $dept1, null);
$viaTopic = calc_sla_dates($prioRow, $from, $dept1, $topicSla);
$check(strtotime($viaDept['resolution_due_at']) - strtotime($from) === 8 * 3600, 'Abteilungs-SLA-Plan ersetzt die Lösungszeit der Priorität');
$check(strtotime($viaTopic['resolution_due_at']) - strtotime($from) === 3 * 3600 && strtotime($viaTopic['first_response_due_at']) - strtotime($from) === 3600, 'Themen-SLA-Plan hat Vorrang vor dem der Abteilung');
$check($none['resolution_due_at'] !== $viaDept['resolution_due_at'], 'ohne Plan gelten die Zeiten der Priorität');
$pdo->prepare('UPDATE sla_plans SET is_active = 0 WHERE id = ?')->execute([$planTopic]);
$viaInactive = calc_sla_dates($prioRow, $from, $dept1, $topicSla);
$check(strtotime($viaInactive['resolution_due_at']) - strtotime($from) === 8 * 3600, 'deaktivierter Themenplan fällt auf den Abteilungsplan zurück');
sla_plan_delete($pdo, $planDept);
$check(calc_sla_dates($prioRow, $from, $dept1, null)['resolution_due_at'] === $none['resolution_due_at'], 'nach dem Löschen gelten wieder die Zeiten der Priorität');
// ── Überwachung des Mail-Abrufs ─────────────────────────────────────────────
$pdo->prepare("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, is_active, created_at) VALUES ('Test','imap.example.test',993,'ssl','u','x','INBOX','seen','Processed','Junk',1,?)")->execute([$now]);
$accId = (int) $pdo->lastInsertId();
$acc = fn() => $pdo->query("SELECT * FROM imap_accounts WHERE id = {$accId}")->fetch();
$hasCode = fn(string $code) => in_array($code, array_column(system_health($pdo), 'code'), true);
health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt');
$check($hasCode('mailbox_flaky') && !$hasCode('mailbox_down'), 'ein einzelner Fehlversuch ist nur ein Hinweis');
health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt');
health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt');
$check($hasCode('mailbox_down') && !empty($acc()['alert_sent_at']), 'nach drei Fehlversuchen: Störung gemeldet und Alarm vermerkt');
$alertAt = $acc()['alert_sent_at'];
health_record_mail_result($pdo, $acc(), 'Verbindung abgelehnt');
$check($acc()['alert_sent_at'] === $alertAt, 'der Alarm wird nicht bei jedem Fehlversuch wiederholt');
health_record_mail_result($pdo, $acc(), null);
$check(!$hasCode('mailbox_down') && !$hasCode('mailbox_flaky') && (int) $acc()['fail_count'] === 0 && empty($acc()['alert_sent_at']), 'nach Erfolg ist die Störung behoben');
set_setting('cron_fetch_emails_last_run', date('Y-m-d H:i:s', time() - 3600));
$check($hasCode('fetch_stale'), 'veralteter Abruf-Job wird gemeldet');
set_setting('cron_fetch_emails_last_run', db_now());
$check(!$hasCode('fetch_stale'), 'frischer Abruf-Job ist unauffällig');
// ── Bericht-Baukasten ───────────────────────────────────────────────────────
$cfg = report_clean_config($pdo, ['dimension' => "x'; DROP TABLE tickets;--", 'metrics' => ['total', 'bogus', 'x) FROM tickets--'], 'filters' => ['departmentId' => '1', 'evil_column' => '1', 'status' => 'open'], 'sort' => 'evil', 'from' => 'kein-datum', 'to' => '2026-02-01']);
$check($cfg['dimension'] === 'department', 'unbekannte Gruppierung fällt auf den Standard zurück');
$check($cfg['metrics'] === ['total'], 'unbekannte Kennzahlen werden verworfen');
$check(array_keys($cfg['filters']) === ['departmentId', 'status'], 'unbekannte Filter werden verworfen');
$check($cfg['sort'] === 'label', 'unbekannte Sortierung fällt auf "Gruppe" zurück');
$check(preg_match('/^\d{4}-\d{2}-\d{2}$/', $cfg['from']) === 1, 'ungültiges Datum wird durch einen Standard ersetzt');
$swapped = report_clean_config($pdo, ['from' => '2026-03-10', 'to' => '2026-03-01']);
$check($swapped['from'] === '2026-03-01' && $swapped['to'] === '2026-03-10', 'vertauschter Zeitraum wird korrigiert');
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute(['RB-1', 'Bericht', 'X', 'x@example.test', $dept1, $prio, 'resolved', '2026-03-05 10:00:00', '2026-03-05 10:00:00']);
$run = report_run($pdo, ['dimension' => 'status', 'metrics' => ['total', 'resolved'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null);
$check(count($run['rows']) === 1 && $run['rows'][0]['label'] === 'resolved' && $run['rows'][0]['total'] === 1 && $run['totals']['resolved'] === 1, 'Bericht zählt Tickets im Zeitraum');
$none = report_run($pdo, ['dimension' => 'status', 'from' => '2025-01-01', 'to' => '2025-01-31'], null);
$check($none['rows'] === [], 'ohne Tickets im Zeitraum bleibt der Bericht leer');
$scoped = report_run($pdo, ['dimension' => 'status', 'from' => '2026-03-01', 'to' => '2026-03-31'], [999999]);
$check($scoped['rows'] === [], 'der Abteilungsbereich des Mitarbeiters schränkt den Bericht ein');
$monthRun = report_run($pdo, ['dimension' => 'month', 'metrics' => ['total'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null);
$check(($monthRun['rows'][0]['label'] ?? '') === '2026-03', 'Gruppierung nach Monat liefert "JJJJ-MM"');
$pdo->prepare('INSERT INTO departments (name, email, created_at) VALUES (?,?,?)')->execute(['=HYPERLINK("x")', '', $now]);
$csvDept = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute(['RB-2', 'Bericht', 'X', 'x@example.test', $csvDept, $prio, 'open', '2026-03-06 10:00:00', '2026-03-06 10:00:00']);
$csv = report_to_csv(report_run($pdo, ['dimension' => 'department', 'metrics' => ['total'], 'from' => '2026-03-01', 'to' => '2026-03-31'], null));
$check(str_contains($csv, "\"'=HYPERLINK") || str_contains($csv, "'=HYPERLINK"), 'CSV schützt vor Formel-Injektion in Gruppennamen');
$check(str_starts_with($csv, "\xEF\xBB\xBF"), 'CSV beginnt mit UTF-8-BOM für Excel');
// ── Datenbank-Update (Erkennung ausstehender Migrationen) ────────────────────
require_once dirname(__DIR__) . '/database/migrate.php';
$check(count(migrations_known()) >= 30, 'bekannte Migrationen werden erkannt');
$check(migrations_pending($pdo) === [], 'frische Datenbank hat keine ausstehenden Migrationen');
$pdo->exec("DELETE FROM schema_migrations WHERE migration = 'php_0030_saved_reports'");
$check(migrations_pending($pdo) === ['php_0030_saved_reports'], 'fehlende Migration wird als ausstehend gemeldet');
$pdo->exec("INSERT INTO schema_migrations (migration) VALUES ('php_0030_saved_reports')");
$check(migrations_pending($pdo) === [], 'nach dem Einspielen ist nichts mehr ausstehend');
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Admin shared logic checks passed.\n";
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/** Prüft die App-Downloads (includes/app_downloads.php): per FTP abgelegte Dateien, Prüfsumme mit Zwischenspeicher, Ordnerschutz. */
$dir = sys_get_temp_dir() . '/deskhub-apps-' . bin2hex(random_bytes(4));
putenv('DESKHUB_APPS_DIR=' . $dir);
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$check(app_download_info('android') === null && app_download_info('windows') === null, 'ohne Dateien ist nichts hinterlegt (Ordner existiert noch nicht)');
$check(app_download_info('ios') === null && app_download_info('../x') === null, 'unbekannte Plattform liefert nichts');
$check(app_downloads_prepare_dir() && is_dir($dir), 'Ordner wird angelegt');
$check(is_file($dir . '/.htaccess') && str_contains((string) file_get_contents($dir . '/.htaccess'), 'Require all denied'), 'Ordner ist per .htaccess gesperrt');
$check(app_download_info('android') === null, 'leerer Ordner: nichts hinterlegt');
$apk = 'PK' . random_bytes(4000);
file_put_contents($dir . '/DeskHub.apk', $apk);
$info = app_download_info('android');
$check($info !== null && $info['size'] === strlen($apk) && $info['sha256'] === hash('sha256', $apk), 'per FTP abgelegte APK wird erkannt, Größe und Prüfsumme stimmen');
$check($info && $info['filename'] === 'DeskHub.apk' && $info['mime'] === 'application/vnd.android.package-archive' && $info['modified_at'] !== '', 'Dateiname, Typ und Datum');
$check(app_download_info('windows') === null, 'die andere Plattform bleibt leer');
$exe = 'MZ' . random_bytes(6000);
file_put_contents($dir . '/DeskHub-Setup.exe', $exe);
$check(app_download_info('windows')['sha256'] === hash('sha256', $exe), 'Windows-Datei wird erkannt');
// Zwischenspeicher: gleiche Datei -> gleiche Prüfsumme ohne Neuberechnung; geänderte Datei -> neue Prüfsumme
$cacheValue = get_setting('app_android_hash_cache', '');
app_download_info('android');
$check(get_setting('app_android_hash_cache', '') === $cacheValue && $cacheValue !== '', 'Prüfsumme wird zwischengespeichert');
$apk2 = 'PK' . random_bytes(5000);
file_put_contents($dir . '/DeskHub.apk', $apk2);
touch($dir . '/DeskHub.apk', time() + 5);
$info2 = app_download_info('android');
$check($info2['sha256'] === hash('sha256', $apk2) && $info2['size'] === strlen($apk2), 'ausgetauschte Datei bekommt eine neue Prüfsumme');
file_put_contents($dir . '/DeskHub.apk', 'PK');
$check(app_download_info('android') === null, 'abgebrochene/winzige Datei wird nicht angeboten');
$check(app_format_size(110 * 1048576) === '110,0 MB' && app_format_size(2048) === '2 KB', 'Größenformat');
foreach (scandir($dir) ?: [] as $e) { if ($e !== '.' && $e !== '..') @unlink($dir . '/' . $e); }
@rmdir($dir);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "App downloads checks passed.\n";
+169
View File
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
/**
* Prüft Backup, Prüfung und Wiederherstellung (includes/backup.php, includes/backup_restore.php) mit einer echten SQLite-Datenbank:
* Backup erstellen -> Daten verändern/zerstören -> wiederherstellen -> alles ist wieder da. Dazu beschädigte, unvollständige und
* manipulierte Backups, die nicht zurückgespielt werden dürfen.
*/
if (!class_exists('ZipArchive')) { echo "Backup-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); }
$tmp = sys_get_temp_dir() . '/dh-bk-' . bin2hex(random_bytes(4));
mkdir($tmp . '/root/uploads', 0755, true);
mkdir($tmp . '/root/plugins/demo', 0755, true);
mkdir($tmp . '/root/config', 0755, true);
mkdir($tmp . '/root/includes', 0755, true);
mkdir($tmp . '/backups', 0755, true);
putenv('DESKHUB_SQLITE_PATH=' . $tmp . '/live.sqlite');
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/includes/backup_restore.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$root = $tmp . '/root';
$backupDir = $tmp . '/backups';
// ── Ausgangsdaten ────────────────────────────────────────────────────────────
file_put_contents($root . '/uploads/anhang.txt', "Wichtiger Anhang \u{00e4}\u{00f6}\u{00fc}");
file_put_contents($root . '/plugins/demo/plugin.json', '{"slug":"demo"}');
file_put_contents($root . '/config/config.php', "<?php // Konfiguration VOR dem Backup\n");
file_put_contents($root . '/includes/code.php', "<?php // Programmcode VOR dem Backup\n");
$now = db_now();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY id LIMIT 1')->fetchColumn();
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$ins = $pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)');
foreach ([['B-1', "Drucker 'kaputt', (dringend) ✓"], ['B-2', 'Rechnung \\ fehlt'], ['B-3', 'Normal']] as [$num, $subj]) $ins->execute([$num, $subj, 'Kunde', 'k@example.test', $dept, $prio, 'open', $now, $now]);
set_setting('display_name', 'Vor dem Backup');
$ticketsBefore = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn();
$usersBefore = (int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn();
$maxIdBefore = (int) $pdo->query('SELECT MAX(id) FROM tickets')->fetchColumn();
// ── Backup erstellen und prüfen ──────────────────────────────────────────────
$bk = create_backup($root, $backupDir, $pdo);
$check($bk['ok'] && is_file($bk['path']), 'Backup wird erstellt: ' . json_encode($bk));
$check(basename($bk['path']) === $bk['filename'] && str_starts_with($bk['filename'], 'backup_'), 'Backup hat den üblichen Dateinamen');
$zip = new ZipArchive();
$zip->open($bk['path']);
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) $names[] = $zip->getNameIndex($i);
$manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
$zip->close();
$check(in_array('database_backup.sqlite', $names, true) && in_array('uploads/anhang.txt', $names, true) && in_array('backup_manifest.json', $names, true), 'Backup enthält Datenbank-Abzug, Dateien und Manifest');
$check(($manifest['driver'] ?? '') === 'sqlite' && (int) $manifest['tables'] > 20 && ($manifest['version'] ?? '') === DESKHUB_VERSION, 'Manifest nennt Datenbanktyp, Tabellenzahl und Programmversion');
$check(!in_array('live.sqlite', $names, true) && !array_filter($names, fn($n) => str_ends_with($n, '.sqlite') && $n !== 'database_backup.sqlite'), 'die laufende Datenbankdatei wird nicht als Kopie ins Backup gelegt');
$v = backup_verify($bk['path']);
$check($v['ok'] && $v['info']['driver'] === 'sqlite' && $v['info']['tickets'] === $ticketsBefore && $v['info']['staff'] === $usersBefore, 'Prüfung bestätigt das Backup und nennt die Anzahl Tickets und Mitarbeiter: ' . json_encode($v));
// ── Beschädigte oder manipulierte Backups werden erkannt ─────────────────────
$makeZip = function (array $files): string {
$p = tempnam(sys_get_temp_dir(), 'bz') . '.zip';
$z = new ZipArchive();
$z->open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($files as $n => $c) $z->addFromString($n, $c);
$z->close();
return $p;
};
$check(!backup_verify($tmp . '/gibtesnicht.zip')['ok'], 'fehlende Datei wird erkannt');
$trunc = $tmp . '/abgeschnitten.zip';
file_put_contents($trunc, substr((string) file_get_contents($bk['path']), 0, (int) (filesize($bk['path']) / 2)));
$check(!backup_verify($trunc)['ok'], 'abgeschnittenes Backup wird erkannt');
$check(!backup_verify($makeZip(['uploads/a.txt' => 'x']))['ok'], 'Backup ohne Datenbank-Abzug wird erkannt');
$check(!backup_verify($makeZip(['database_backup.sqlite' => str_repeat('kein sqlite ', 200)]))['ok'], 'Datenbank-Abzug, der keine SQLite-Datei ist, wird erkannt');
$check(!backup_verify($makeZip(['../evil.txt' => 'x', 'database_backup.sqlite' => 'x']))['ok'], 'Pfad mit ".." im Backup wird abgelehnt');
$corrupt = $tmp . '/korrupt.sqlite';
$z0 = new ZipArchive(); $z0->open($bk['path']); file_put_contents($corrupt, $z0->getFromName('database_backup.sqlite')); $z0->close();
$raw = (string) file_get_contents($corrupt);
file_put_contents($corrupt, substr($raw, 0, 100) . str_repeat("\0", 2000) . substr($raw, 2100));
$check(!backup_verify($makeZip(['database_backup.sqlite' => (string) file_get_contents($corrupt)]))['ok'], 'beschädigte Datenbank im Backup wird erkannt (Integritätsprüfung)');
$noTickets = new PDO('sqlite::memory:');
$onlySettings = tempnam(sys_get_temp_dir(), 'ns');
$o = new PDO('sqlite:' . $onlySettings); $o->exec('CREATE TABLE settings (k TEXT)'); $o = null;
$check(!backup_verify($makeZip(['database_backup.sqlite' => (string) file_get_contents($onlySettings)]))['ok'], 'Datenbank ohne die Kerntabellen (tickets, users …) wird erkannt');
// ── Zerstören und wiederherstellen ───────────────────────────────────────────
$pdo->exec('DELETE FROM tickets');
$pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES ('NACH-1', 'Nach dem Backup', 'X', 'x@example.test', {$dept}, {$prio}, 'open', '{$now}', '{$now}')");
set_setting('display_name', 'Nach dem Backup');
unlink($root . '/uploads/anhang.txt');
unlink($root . '/plugins/demo/plugin.json');
file_put_contents($root . '/config/config.php', "<?php // Konfiguration NACH dem Backup\n");
file_put_contents($root . '/includes/code.php', "<?php // Programmcode NACH dem Backup\n");
$pdo->exec("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Nur nach Backup', 'neu@example.test', 'x', 'agent', {$dept}, 1, '{$now}')");
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === 1, 'Ausgangslage: Daten wurden verändert');
$countBackups = fn() => count(glob($backupDir . '/backup_*.zip'));
$beforeCount = $countBackups();
$r = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir]);
$check($r['ok'] && $r['tables'] > 20 && $r['rows'] > 0 && $r['files'] >= 2, 'Wiederherstellung läuft durch: ' . json_encode($r));
$check($countBackups() === $beforeCount + 1 && !empty($r['safety']) && is_file($backupDir . '/' . $r['safety']), 'vor der Wiederherstellung wird ein Sicherheits-Backup erstellt');
$check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $ticketsBefore, 'alle Tickets sind wieder da');
$subjects = $pdo->query('SELECT subject FROM tickets ORDER BY ticket_number')->fetchAll(PDO::FETCH_COLUMN);
$check($subjects === ["Drucker 'kaputt', (dringend) ✓", 'Rechnung \\ fehlt', 'Normal'], 'Inhalte mit Sonderzeichen sind unverändert: ' . json_encode($subjects, JSON_UNESCAPED_UNICODE));
$check((int) $pdo->query("SELECT COUNT(*) FROM tickets WHERE ticket_number = 'NACH-1'")->fetchColumn() === 0, 'Daten, die erst nach dem Backup entstanden, sind weg');
$check((int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn() === $usersBefore, 'Mitarbeiter-Stand entspricht dem Backup');
get_setting('display_name'); // Zwischenspeicher der Einstellungen
$check($pdo->query("SELECT setting_value FROM settings WHERE setting_key = 'display_name'")->fetchColumn() === 'Vor dem Backup', 'Einstellungen sind zurückgesetzt');
$pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, created_at, updated_at) VALUES ('B-4', 'Neu', 'X', 'x@example.test', {$dept}, {$prio}, 'open', '{$now}', '{$now}')");
$check((int) $pdo->query("SELECT id FROM tickets WHERE ticket_number = 'B-4'")->fetchColumn() === $maxIdBefore + 1, 'die Nummernvergabe läuft nach der Wiederherstellung am richtigen Punkt weiter');
$check(file_exists($root . '/uploads/anhang.txt') && str_contains((string) file_get_contents($root . '/uploads/anhang.txt'), 'Wichtiger Anhang'), 'hochgeladene Dateien sind wieder da');
$check(is_file($root . '/plugins/demo/plugin.json'), 'Plugins sind wieder da');
$check(str_contains((string) file_get_contents($root . '/config/config.php'), 'NACH dem Backup'), 'die Konfiguration wird nie überschrieben');
$check(str_contains((string) file_get_contents($root . '/includes/code.php'), 'NACH dem Backup'), 'Programmdateien werden nie überschrieben (dafür gibt es das System-Update)');
// ── Optionen und Schutz ──────────────────────────────────────────────────────
unlink($root . '/uploads/anhang.txt');
$pdo->exec("UPDATE tickets SET subject = 'geändert'");
$r2 = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir, 'dirs' => [], 'database' => true]);
$check($r2['ok'] && !file_exists($root . '/uploads/anhang.txt') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'geändert'")->fetchColumn() == 0, 'ohne Dateien wird nur die Datenbank zurückgespielt');
$pdo->exec("UPDATE tickets SET subject = 'noch einmal geändert'");
$r3 = backup_restore($pdo, $bk['path'], ['root' => $root, 'backup_dir' => $backupDir, 'database' => false, 'dirs' => ['uploads']]);
$check($r3['ok'] && file_exists($root . '/uploads/anhang.txt') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'noch einmal geändert'")->fetchColumn() > 0, 'ohne Datenbank werden nur die gewählten Ordner zurückgespielt');
$bad = backup_restore($pdo, $trunc, ['root' => $root, 'backup_dir' => $backupDir]);
$check(!$bad['ok'] && str_contains($bad['error'], 'nicht wiederherstellbar') && $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'noch einmal geändert'")->fetchColumn() > 0, 'ein beschädigtes Backup wird nicht zurückgespielt, die Daten bleiben unangetastet');
$foreign = $makeZip(['database_backup.sql' => "-- x\nSET FOREIGN_KEY_CHECKS=0;\nDROP TABLE IF EXISTS `tickets`;\nCREATE TABLE `tickets` (id int);\nCREATE TABLE `users` (id int);\nCREATE TABLE `staff` (id int);\nCREATE TABLE `settings` (id int);\nSET FOREIGN_KEY_CHECKS=1;\n"]);
$vm = backup_verify($foreign);
$check($vm['ok'] && $vm['info']['driver'] === 'mysql' && $vm['info']['tables'] === 4, 'MySQL-Abzug wird erkannt und geprüft');
$mismatch = backup_restore($pdo, $foreign, ['root' => $root, 'backup_dir' => $backupDir]);
$check(!$mismatch['ok'] && str_contains($mismatch['error'], 'MYSQL') && str_contains($mismatch['error'], 'SQLITE'), 'ein Backup eines anderen Datenbanktyps wird abgelehnt');
$incomplete = $makeZip(['database_backup.sql' => "SET FOREIGN_KEY_CHECKS=0;\nCREATE TABLE `tickets` (id int);\nCREATE TABLE `users` (id int);\nCREATE TABLE `staff` (id int);\nCREATE TABLE `settings` (id int);\nINSERT INTO `tickets` (id) VALUES\n(1),\n"]);
$check(!backup_verify($incomplete)['ok'], 'abgebrochener MySQL-Abzug (Ende fehlt) wird erkannt');
// SQL-Zerlegung: Semikolons und Zeilenumbrüche in Texten
$sqlFile = $tmp . '/t.sql';
file_put_contents($sqlFile, "-- Kommentar\nSET A=1;\n\nINSERT INTO t (a) VALUES\n('x; y'),\n('it\\'s; here'),\n('z');\nSELECT 2;\n");
$stmts = iterator_to_array(backup_sql_statements($sqlFile), false);
$check(count($stmts) === 3 && str_contains($stmts[1], "it\\'s; here") && $stmts[2] === 'SELECT 2;', 'SQL-Anweisungen werden richtig getrennt (Semikolon in Texten, Anführungszeichen mit Backslash)');
// Prüfergebnis merken, Hinweis im System, sicherer Zugriff auf Backup-Dateien
$rec = backup_verify_and_record($bk['path']);
$saved = json_decode((string) get_setting('backup_last_check', ''), true);
$check($rec['ok'] && ($saved['ok'] ?? null) === true && $saved['file'] === $bk['filename'], 'das Prüfergebnis wird gespeichert');
$check(!array_filter(system_health($pdo), fn($i) => $i['code'] === 'backup_broken'), 'ein gutes Backup löst keinen Hinweis aus');
backup_verify_and_record($trunc);
$broken = array_values(array_filter(system_health($pdo), fn($i) => $i['code'] === 'backup_broken'));
$check($broken && $broken[0]['level'] === 'error' && str_contains($broken[0]['message'], 'nicht wiederherstellbar'), 'ein nicht wiederherstellbares Backup erzeugt einen Fehler-Hinweis im System');
backup_verify_and_record($bk['path']);
$check(backup_path_for($bk['filename'], $backupDir) !== null && backup_path_for('../../etc/passwd', $backupDir) === null && backup_path_for('backup_x.zip', $backupDir) === null && backup_path_for('config.php', $backupDir) === null, 'Backups werden nur über echte Dateinamen im Backup-Ordner angesprochen');
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($tmp);
@unlink($onlySettings);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Backup restore checks passed ({$total} Prüfungen).\n";
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
/** Prüft die Mini-Vorschau im Katalog: Adresse aus dem Katalog, nur gleiche Domain, nur echte Bilder, Zwischenspeicher. */
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('PLUGIN_SITE_CONFIG=' . sys_get_temp_dir() . '/gibtesnicht.php');
putenv('PLUGIN_SITE_STORAGE=' . ($tmp = sys_get_temp_dir() . '/dh-prev-' . bin2hex(random_bytes(4))));
putenv('PLUGIN_SITE_BASE_URL=https://plugins.example.test');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once __DIR__ . '/fixtures/paths.php';
$siteDir = website_dir_or_skip('catalog_preview');
require_once $siteDir . '/inc/lib.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
// Ein echtes PNG (1x1) als Vorschau
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
$pngFile = tempnam(sys_get_temp_dir(), 'pv') . '.png';
file_put_contents($pngFile, $png);
$z = tempnam(sys_get_temp_dir(), 'pk') . '.zip';
$zip = new ZipArchive();
$zip->open($z, ZipArchive::CREATE);
$zip->addFromString('theme.css', "/*\n * Version: 1.0.0\n */\na{}");
$zip->close();
// Webseite: Vorschau-Adresse im Katalog nur bei Einträgen mit Bild
$check(site_publish('theme', $z, ['slug' => 'mitbild', 'name' => 'Mit Bild'], $pngFile, 'vorschau.png')['ok'], 'Theme mit Vorschaubild wird veröffentlicht');
$zip2 = tempnam(sys_get_temp_dir(), 'pk') . '.zip';
$zz = new ZipArchive(); $zz->open($zip2, ZipArchive::CREATE); $zz->addFromString('theme.css', "/*\n * Version: 1.0.0\n */\nb{}"); $zz->close();
$check(site_publish('theme', $zip2, ['slug' => 'ohnebild', 'name' => 'Ohne Bild'])['ok'], 'Theme ohne Vorschaubild wird veröffentlicht');
$cat = site_catalog('theme');
$byslug = array_column($cat['themes'], null, 'slug');
$check(isset($byslug['mitbild']['previewUrl']) && str_starts_with($byslug['mitbild']['previewUrl'], 'https://plugins.example.test/preview.php?file=') && !isset($byslug['ohnebild']['previewUrl']), 'Katalog nennt die Vorschau-Adresse nur für Einträge mit Bild');
// DeskHub: Adresse prüfen
$base = 'https://plugins.example.test/catalog.php?type=themes';
$check(catalog_preview_url($base, 'https://plugins.example.test/preview.php?file=a.png') !== '', 'Vorschau auf derselben Domain wird übernommen');
$check(catalog_preview_url($base, 'https://anderer.example.test/a.png') === '', 'Vorschau auf einer anderen Domain wird verworfen');
$check(catalog_preview_url($base, 'http://plugins.example.test/a.png') === '' && catalog_preview_url($base, '') === '', 'http und leere Adressen werden verworfen');
// DeskHub: Katalog lesen, Bild laden, prüfen, zwischenspeichern
set_setting('theme_catalog_url', $base);
$requests = [];
$serve = ['body' => $png];
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($cat, &$requests, &$serve): array {
$requests[] = $url;
if (str_contains($url, 'catalog.php')) return [200, json_encode($cat), null];
if (str_contains($url, 'preview.php')) return [200, $serve['body'], null];
return [404, null, 'HTTP 404'];
};
$tc = theme_catalog_fetch();
$by = array_column($tc['themes'], null, 'slug');
$check(($by['mitbild']['preview'] ?? '') !== '' && ($by['ohnebild']['preview'] ?? 'x') === '', 'DeskHub übernimmt die Vorschau-Adresse aus dem Theme-Katalog');
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f);
$img = catalog_preview_image('theme', 'mitbild');
$check($img !== null && $img['mime'] === 'image/png' && $img['data'] === $png, 'Vorschaubild wird geladen und mit dem richtigen Typ geliefert');
$n = count($requests);
$img2 = catalog_preview_image('theme', 'mitbild');
$check($img2 !== null && count($requests) === $n, 'zweiter Aufruf kommt aus dem Zwischenspeicher (kein neuer Abruf)');
$check(catalog_preview_image('theme', 'ohnebild') === null, 'Eintrag ohne Vorschau liefert kein Bild');
$check(catalog_preview_image('theme', 'gibtesnicht') === null && catalog_preview_image('theme', '../x') === null && catalog_preview_image('auto', 'mitbild') === null, 'unbekannte Einträge, Pfadtricks und falsche Arten liefern nichts');
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f);
$serve['body'] = '<html><script>alert(1)</script></html>';
$check(catalog_preview_image('theme', 'mitbild') === null, 'eine Datei, die kein Bild ist, wird nicht ausgeliefert');
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f);
$serve['body'] = "GIF89a" . str_repeat("\0", 30);
$check(catalog_preview_image('theme', 'mitbild') === null, 'andere Bildtypen (z. B. GIF) werden nicht ausgeliefert');
// Ein fehlgeschlagener Abruf wird nur 5 Minuten gemerkt (früher einen Tag): Sobald das Bild da ist, erscheint es
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f);
$serve['body'] = 'kein Bild';
$check(catalog_preview_image('theme', 'mitbild') === null, 'Abruf ohne gültiges Bild liefert nichts');
$serve['body'] = $png;
$check(catalog_preview_image('theme', 'mitbild') === null, 'der Fehlschlag wird kurz gemerkt (kein Dauerabruf bei kaputtem Bild)');
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) touch($f, time() - 400);
$check(catalog_preview_image('theme', 'mitbild') !== null, 'nach 5 Minuten wird erneut geladen und das Bild erscheint');
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) touch($f, time() - 400);
$check(catalog_preview_image('theme', 'mitbild') !== null, 'ein erfolgreich geladenes Bild bleibt länger als 5 Minuten im Zwischenspeicher');
// Installierte Themes: Vorschau aus dem Katalog (gleicher Name) oder aus preview.png im Theme-Ordner
$themesDir = sys_get_temp_dir() . '/dh-thumb-themes-' . bin2hex(random_bytes(3));
mkdir($themesDir, 0755, true);
putenv('DESKHUB_THEMES_DIR=' . $themesDir);
$zt = tempnam(sys_get_temp_dir(), 'th') . '.zip';
$q = new ZipArchive(); $q->open($zt, ZipArchive::CREATE); $q->addFromString('theme.css', "/*
* Version: 1.0.0
*/
a{}"); $q->addFromString('preview.png', $png); $q->close();
$inst = install_custom_theme_archive($zt, 'Mit Bild');
$ohne = tempnam(sys_get_temp_dir(), 'th') . '.zip';
$q = new ZipArchive(); $q->open($ohne, ZipArchive::CREATE); $q->addFromString('theme.css', "/*
* Version: 1.0.0
*/
c{}"); $q->close();
$inst2 = install_custom_theme_archive($ohne, 'Ganz Ohne');
$catThemes = [['slug' => 'mitbild', 'name' => 'Mit-Bild', 'preview' => 'https://plugins.example.test/preview.php?file=a.png']];
$p1 = theme_installed_preview(['id' => $inst['id'], 'name' => 'Mit Bild'], $catThemes);
$check($p1 && $p1['kind'] === 'catalog' && $p1['slug'] === 'mitbild', 'installiertes Theme bekommt die Vorschau aus dem Katalog (Name "Mit Bild" = "Mit-Bild")');
$p2 = theme_installed_preview(['id' => $inst['id'], 'name' => 'Mit Bild'], []);
$check($p2 && $p2['kind'] === 'file' && str_ends_with($p2['url'], '/preview.png'), 'ohne Katalog gilt die preview.png aus dem Theme-Ordner');
$check(theme_installed_preview(['id' => $inst2['id'], 'name' => 'Ganz Ohne'], $catThemes) === null, 'ohne Katalogbild und ohne preview.png gibt es keine Vorschau');
$check(str_contains(installed_theme_thumb(['id' => $inst2['id'], 'name' => 'Ganz Ohne'], []), '>G<'), 'Platzhalter zeigt den Anfangsbuchstaben');
$check(str_contains(installed_theme_thumb(['id' => $inst['id'], 'name' => 'Mit Bild'], $catThemes), 'catalog_preview.php?kind=theme&amp;slug=mitbild'), 'Mini-Vorschau verweist auf die Auslieferung durch DeskHub');
$rmT = function (string $d) use (&$rmT) { foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rmT("$d/$e") : @unlink("$d/$e"); } @rmdir($d); };
$rmT($themesDir);
putenv('DESKHUB_THEMES_DIR');
// Platzhalter und Bild-Markup
$check(str_contains(catalog_thumb('theme', ['slug' => 'mitbild', 'name' => 'Mit Bild', 'preview' => 'https://x']), 'catalog_preview.php?kind=theme&amp;slug=mitbild'), 'Mini-Vorschau zeigt das Bild über die DeskHub-Adresse');
$ph = catalog_thumb('plugin', ['slug' => 'x', 'name' => '<script>', 'preview' => '']);
$check(str_contains($ph, '&lt;') && !str_contains($ph, '<script>') && str_contains($ph, 'aria-hidden'), 'Platzhalter zeigt den Anfangsbuchstaben und maskiert Sonderzeichen');
// Automatisches Laden: Zwischenspeicher, Hintergrundaktualisierung, Update-Hinweis
set_setting('theme_catalog_cache', '');
$before = count($requests);
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://plugins.example.test/catalog.php?type=plugins', 'themes_url' => $base, 'publisher_key' => 'x'];
set_setting('plugin_catalog_cache', '');
$pluginCat = ['plugins' => [['slug' => 'tagesbericht', 'name' => 'Tagesbericht', 'version' => '1.2.0', 'description' => '', 'author' => '', 'permissions' => [], 'downloadUrl' => 'https://plugins.example.test/d', 'sha256' => str_repeat('a', 64), 'signature' => '']]];
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($cat, $pluginCat, &$requests): array {
$requests[] = $url;
if (str_contains($url, 'type=plugins')) return [200, json_encode($pluginCat), null];
if (str_contains($url, 'catalog.php')) return [200, json_encode($cat), null];
return [404, null, 'HTTP 404'];
};
$t1 = theme_catalog_cached();
$n1 = count($requests);
$t2 = theme_catalog_cached();
$check($t1['ok'] && $t2['ok'] && count($requests) === $n1 && $n1 === $before + 1, 'Theme-Katalog wird beim ersten Aufruf geladen und danach aus dem Zwischenspeicher gezeigt');
$check(plugin_updates_available($pdo = get_db()) === [], 'ohne installierte Plugins gibt es keine Update-Hinweise');
catalog_refresh_background();
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
$check(is_array($cached) && count($cached['plugins']) === 1, 'Hintergrundaktualisierung füllt den Plugin-Katalog ohne Seitenaufruf');
$after = count($requests);
catalog_refresh_background();
$check(count($requests) === $after + 2, 'jede Hintergrundaktualisierung holt beide Kataloge neu');
$pdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at) VALUES ('tagesbericht', 'Tagesbericht', '1.0.0', 0, datetime('now'))");
$upd = plugin_updates_available($pdo);
$check(count($upd) === 1 && $upd[0]['installed'] === '1.0.0' && $upd[0]['available'] === '1.2.0', 'neuere Version im Katalog wird als Update gemeldet');
$pdo->exec("UPDATE plugins SET version = '1.2.0' WHERE slug = 'tagesbericht'");
$check(plugin_updates_available($pdo) === [], 'aktuelle Plugins werden nicht gemeldet');
$before = count($requests);
plugin_updates_available($pdo);
$check(count($requests) === $before, 'der Update-Hinweis fragt nie den Server ab (nur den Zwischenspeicher)');
// Fällt der Katalog-Server aus, bleibt der alte Stand erhalten
$GLOBALS['deskhub_http_get'] = fn(string $url, int $max): array => [0, null, 'Verbindung fehlgeschlagen'];
catalog_refresh_background();
$kept = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
$check(is_array($kept) && count($kept['plugins']) === 1, 'bei Serverausfall bleibt der zuletzt geladene Katalog erhalten');
unset($GLOBALS['deskhub_catalog_defaults']);
unset($GLOBALS['deskhub_http_get']);
foreach (glob(sys_get_temp_dir() . '/deskhub_preview_*.bin') ?: [] as $f) @unlink($f);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($tmp);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Catalog preview checks passed ({$total} Prüfungen).\n";
+129
View File
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/includes/themes.php';
if (!class_exists('ZipArchive')) {
echo "Theme-Tests übersprungen: PHP-ZIP ist nicht installiert.\n";
exit(0);
}
$testDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub-theme-test-' . bin2hex(random_bytes(6));
$archiveDirectory = $testDirectory . DIRECTORY_SEPARATOR . 'archives';
$themesDirectory = $testDirectory . DIRECTORY_SEPARATOR . 'themes';
mkdir($archiveDirectory, 0755, true);
try {
$validArchive = $archiveDirectory . DIRECTORY_SEPARATOR . 'valid.zip';
create_test_theme_archive($validArchive, [
'theme.css' => "/*\n * Author: Test Autor\n * Version: 2.3.1\n */\n:root { --primary: #123456; }",
'images/background.png' => 'not-a-real-image',
]);
$theme = install_custom_theme_archive($validArchive, 'Test Theme', $themesDirectory);
$themes = list_custom_themes($themesDirectory);
assert_true(count($themes) === 1, 'Das installierte Theme wird nicht aufgelistet.');
assert_true($themes[0]['id'] === $theme['id'], 'Die Theme-ID stimmt nicht überein.');
assert_true($themes[0]['author'] === 'Test Autor', 'Der Theme-Autor wurde nicht aus theme.css übernommen.');
assert_true($themes[0]['theme_version'] === '2.3.1', 'Die Theme-Version wurde nicht aus theme.css übernommen.');
assert_true(
is_file($themesDirectory . DIRECTORY_SEPARATOR . $theme['id'] . DIRECTORY_SEPARATOR . 'theme.css'),
'theme.css wurde nicht installiert.'
);
$namedUpload = $archiveDirectory . DIRECTORY_SEPARATOR . 'My-Theme_Name.zip';
copy($validArchive, $namedUpload);
$uploadedTheme = install_custom_theme_upload([
'name' => 'My-Theme_Name.zip',
'tmp_name' => $namedUpload,
'size' => filesize($namedUpload),
'error' => UPLOAD_ERR_OK,
], $themesDirectory);
assert_true($uploadedTheme['name'] === 'My Theme Name', 'Der Theme-Name wird nicht aus dem ZIP-Dateinamen übernommen.');
assert_true(!$uploadedTheme['replaced'], 'Der erste Upload wurde fälschlich als Aktualisierung markiert.');
$replacementArchive = $archiveDirectory . DIRECTORY_SEPARATOR . 'replacement.zip';
create_test_theme_archive($replacementArchive, [
'theme.css' => ':root { --primary: #abcdef; }',
]);
$replacementTheme = install_custom_theme_upload([
'name' => 'My-Theme_Name.zip',
'tmp_name' => $replacementArchive,
'size' => filesize($replacementArchive),
'error' => UPLOAD_ERR_OK,
], $themesDirectory);
assert_true($replacementTheme['replaced'], 'Der erneute Upload wurde nicht als Aktualisierung erkannt.');
assert_true($replacementTheme['id'] === $uploadedTheme['id'], 'Beim Aktualisieren wurde die Theme-ID geändert.');
assert_true(
count(list_custom_themes($themesDirectory)) === 2,
'Der erneute Upload hat einen doppelten Listeneintrag erzeugt.'
);
assert_true(
file_get_contents(
$themesDirectory . DIRECTORY_SEPARATOR . $replacementTheme['id'] . DIRECTORY_SEPARATOR . 'theme.css'
) === ':root { --primary: #abcdef; }',
'Die Theme-Dateien wurden beim erneuten Upload nicht ersetzt.'
);
$traversalArchive = $archiveDirectory . DIRECTORY_SEPARATOR . 'traversal.zip';
create_test_theme_archive($traversalArchive, [
'theme.css' => ':root {}',
'../escape.css' => 'body {}',
]);
assert_install_fails($traversalArchive, $themesDirectory, 'unsicheren Dateipfad');
assert_true(!file_exists($testDirectory . DIRECTORY_SEPARATOR . 'escape.css'), 'ZIP-Pfad hat das Zielverzeichnis verlassen.');
$executableArchive = $archiveDirectory . DIRECTORY_SEPARATOR . 'executable.zip';
create_test_theme_archive($executableArchive, [
'theme.css' => ':root {}',
'payload.php' => '<?php echo "unsafe";',
]);
assert_install_fails($executableArchive, $themesDirectory, 'Dateityp nicht erlaubt');
$missingStylesheetArchive = $archiveDirectory . DIRECTORY_SEPARATOR . 'missing-css.zip';
create_test_theme_archive($missingStylesheetArchive, ['images/background.png' => 'image']);
assert_install_fails($missingStylesheetArchive, $themesDirectory, 'fehlt die Datei theme.css');
assert_true(delete_custom_theme($replacementTheme['id'], $themesDirectory), 'Das aktualisierte Theme konnte nicht gelöscht werden.');
assert_true(delete_custom_theme($theme['id'], $themesDirectory), 'Das Theme konnte nicht gelöscht werden.');
assert_true(list_custom_themes($themesDirectory) === [], 'Das gelöschte Theme wird weiterhin aufgelistet.');
echo "Theme-Upload, Aktualisierung, automatische Namensgebung, Schutzprüfungen und Löschen funktionieren.\n";
} finally {
if (is_dir($testDirectory)) {
remove_custom_theme_directory($testDirectory);
}
}
function create_test_theme_archive(string $path, array $files): void
{
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException("Test-ZIP konnte nicht erstellt werden: {$path}");
}
foreach ($files as $name => $contents) {
$zip->addFromString($name, $contents);
}
$zip->close();
}
function assert_install_fails(string $archive, string $themesDirectory, string $expectedMessage): void
{
try {
install_custom_theme_archive($archive, 'Ungültiges Theme', $themesDirectory);
} catch (RuntimeException $e) {
assert_true(
str_contains($e->getMessage(), $expectedMessage),
"Unerwartete Fehlermeldung: {$e->getMessage()}"
);
return;
}
throw new RuntimeException("Ungültiges Archiv wurde akzeptiert: {$archive}");
}
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/includes/db.php';
require_once dirname(__DIR__) . '/includes/functions.php';
require_once dirname(__DIR__) . '/database/migrate.php';
$root = dirname(__DIR__);
$schema = file_get_contents($root . '/database/schema.sql');
if ($schema === false) {
fail('Datenbankschema konnte nicht gelesen werden.');
}
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(convert_schema_to_sqlite($schema));
$result = run_migrations($pdo, true);
if ($result['errors']) {
fail('SQLite-Migrationen fehlgeschlagen: ' . implode('; ', $result['errors']));
}
$secondRun = run_migrations($pdo, true);
if ($secondRun['errors'] || $secondRun['applied'] !== 0) {
fail('SQLite-Migrationen sind bei wiederholter Ausführung nicht idempotent.');
}
foreach ([
['push_subscriptions', 'staff_id', 'staff'],
['ticket_thread_comments', 'thread_id', 'ticket_threads'],
['ticket_thread_comments', 'staff_id', 'staff'],
] as [$table, $column, $parent]) {
if (!sqlite_has_foreign_key($pdo, $table, $column, $parent)) {
fail("SQLite-Migration erzeugt {$table}.{$column} ohne FK auf {$parent}.");
}
}
$sqliteReportExpression = report_avg_response_hours_expression('sqlite');
$mysqlReportExpression = report_avg_response_hours_expression('mysql');
if (stripos($sqliteReportExpression, 'julianday') === false) {
fail('Der SQLite-Reportausdruck verwendet keine SQLite-kompatible Stundenberechnung.');
}
if (stripos($mysqlReportExpression, 'TIMESTAMPDIFF(SECOND') === false
|| stripos($mysqlReportExpression, 'julianday') !== false) {
fail('Der MySQL/MariaDB-Reportausdruck ist nicht treibersicher.');
}
$reportPdo = new PDO('sqlite::memory:');
$reportHours = $reportPdo->query(
"SELECT {$sqliteReportExpression}
FROM (SELECT '2026-08-03 08:00:00' AS created_at, '2026-08-03 09:30:00' AS first_responded_at) t"
)->fetchColumn();
if (abs((float) $reportHours - 1.5) > 0.0001) {
fail('Der SQLite-Reportausdruck berechnet Antwortstunden falsch.');
}
$legacy = new PDO('sqlite::memory:');
$legacy->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$legacy->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$legacy->exec('PRAGMA foreign_keys = ON');
$legacy->exec('CREATE TABLE staff (id INTEGER PRIMARY KEY)');
$legacy->exec('CREATE TABLE ticket_threads (id INTEGER PRIMARY KEY)');
$legacy->exec("CREATE TABLE push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT, staff_id INT NOT NULL, endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at DATETIME NOT NULL
)");
$legacy->exec("CREATE TABLE ticket_thread_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INT NOT NULL, staff_id INT NOT NULL,
message TEXT NOT NULL, created_at DATETIME NOT NULL
)");
$legacy->exec('INSERT INTO staff (id) VALUES (1)');
$legacy->exec('INSERT INTO ticket_threads (id) VALUES (1)');
$legacy->exec("INSERT INTO push_subscriptions VALUES (1, 1, 'endpoint', 'key', 'auth', CURRENT_TIMESTAMP)");
$legacy->exec("INSERT INTO ticket_thread_comments VALUES (1, 1, 1, 'Kommentar', CURRENT_TIMESTAMP)");
repair_sqlite_foreign_keys($legacy);
if ((int) $legacy->query('SELECT COUNT(*) FROM push_subscriptions')->fetchColumn() !== 1
|| (int) $legacy->query('SELECT COUNT(*) FROM ticket_thread_comments')->fetchColumn() !== 1) {
fail('Die SQLite-FK-Reparatur bewahrt bestehende Daten nicht.');
}
$legacy->exec('DELETE FROM staff WHERE id = 1');
if ((int) $legacy->query('SELECT COUNT(*) FROM push_subscriptions')->fetchColumn() !== 0
|| (int) $legacy->query('SELECT COUNT(*) FROM ticket_thread_comments')->fetchColumn() !== 0) {
fail('Die reparierten SQLite-FKs führen ON DELETE CASCADE nicht aus.');
}
$forbiddenTokens = [
'cron/process_backups.php' => ['INSERT OR REPLACE'],
'staff/backup.php' => ['INSERT OR REPLACE'],
'staff/ticket_presence.php' => ['INSERT OR REPLACE', "datetime('now'"],
'staff/availability.php' => ['INSERT OR REPLACE', 'REPLACE INTO'],
'cron/process_recurring.php' => ['DATE_ADD('],
'cron/check_escalations.php' => ["julianday('now')"],
'staff/recurring_tickets.php' => ['DATE_ADD(', 'CURDATE('],
];
foreach ($forbiddenTokens as $relativePath => $tokens) {
$source = file_get_contents($root . '/' . $relativePath);
if ($source === false) {
fail("Datei {$relativePath} konnte nicht gelesen werden.");
}
foreach ($tokens as $token) {
if (stripos($source, $token) !== false) {
fail("{$relativePath} enthält weiterhin den fehlerhaften Dialekt-Token {$token}.");
}
}
}
echo "Datenbankdialekte und SQLite-Migrationen sind kompatibel.\n";
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
+3
View File
@@ -0,0 +1,3 @@
<?php
// Tests laufen ohne offiziellen Katalog (sonst gilt der geprüfte Modus der echten includes/catalog_defaults.php).
return [];
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Hilfsfunktionen für Tests: findet Ordner, die auch außerhalb von DeskHub liegen können (Plugin-Webseite, Quellen
* der mitgelieferten Plugins). Fehlt ein Ordner, werden die betroffenen Tests übersprungen (Exit-Code 0).
*/
/** Projektordner (DeskHub). */
function tests_project_root(): string
{
return dirname(__DIR__, 2);
}
/** Ordner der Plugin-Webseite: DESKHUB_WEBSITE_DIR, sonst plugin-webseite/ im Projekt oder daneben. */
function website_dir_or_skip(string $testName): string
{
$candidates = array_filter([getenv('DESKHUB_WEBSITE_DIR') ?: null, tests_project_root() . '/plugin-webseite', dirname(tests_project_root()) . '/plugin-webseite']);
foreach ($candidates as $dir) {
if (is_file($dir . '/inc/lib.php')) return rtrim(str_replace(chr(92), '/', $dir), '/');
}
echo "{$testName}: übersprungen (Ordner plugin-webseite nicht gefunden; Pfad mit DESKHUB_WEBSITE_DIR angeben).\n";
exit(0);
}
/** Quellordner eines mitgelieferten Plugins/Beispiels (docs/, ../Plugins oder DESKHUB_PLUGIN_SRC), sonst null. */
function plugin_source_dir(string $name): ?string
{
$base = [getenv('DESKHUB_PLUGIN_SRC') ?: null, tests_project_root() . '/docs', dirname(tests_project_root()) . '/Plugins'];
foreach (array_filter($base) as $b) {
foreach (["{$b}/plugins/{$name}", "{$b}/{$name}"] as $dir) {
if (is_dir($dir) && is_file($dir . '/plugin.json')) return rtrim(str_replace(chr(92), '/', $dir), '/');
}
}
return null;
}
function plugin_source_dir_or_skip(string $name, string $testName): string
{
$dir = plugin_source_dir($name);
if ($dir !== null) return $dir;
echo "{$testName}: übersprungen (Quellordner '{$name}' nicht gefunden; Pfad mit DESKHUB_PLUGIN_SRC angeben).\n";
exit(0);
}
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/**
* Prüft die Schnittstellen für Fremdsysteme: API-Schlüssel, öffentliche Ticket-API (über einen echten
* PHP-Testserver), Webhooks inkl. Signatur und gespeicherte Ansichten.
*/
$root = dirname(__DIR__);
$tempDb = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub_integrations_' . bin2hex(random_bytes(4)) . '.sqlite';
putenv('DESKHUB_SQLITE_PATH=' . $tempDb);
putenv('DESKHUB_TESTING=1'); // erlaubt dem Test, Webhooks an einen lokalen Empfänger zu senden
require_once $root . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$throws = function (callable $fn, string $code) use ($check): void {
try { $fn(); $check(false, "erwartete Fehler {$code}"); }
catch (AdminModelException $e) { $check($e->errorCode() === $code, "Fehlercode {$code}, erhalten {$e->errorCode()}"); }
};
$pdo = get_db();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Helfer','helfer@example.test','x','agent',1,?)")->execute([db_now()]);
$staffId = (int) $pdo->lastInsertId();
// ── API-Schlüssel ───────────────────────────────────────────────────────────
$throws(fn() => api_key_create($pdo, '', true, false, '', $staffId), 'NAME_REQUIRED');
$throws(fn() => api_key_create($pdo, 'X', false, false, '', $staffId), 'NO_PERMISSION');
$throws(fn() => api_key_create($pdo, 'X', true, false, 'kein-ip', $staffId), 'INVALID_IP');
$created = api_key_create($pdo, 'Website', true, true, '', $staffId);
$check(preg_match('/^dh_[a-f0-9]{48}$/', $created['key']) === 1, 'Schlüssel hat das erwartete Format');
$stored = $pdo->query('SELECT * FROM api_keys WHERE id = ' . $created['id'])->fetch();
$check($stored['key_hash'] === hash('sha256', $created['key']) && !str_contains(json_encode($stored), $created['key']), 'nur der Hash des Schlüssels wird gespeichert');
$check(api_key_verify($pdo, $created['key'], '203.0.113.5') !== null, 'gültiger Schlüssel wird akzeptiert');
$check(api_key_verify($pdo, 'dh_' . str_repeat('0', 48), '203.0.113.5') === null, 'unbekannter Schlüssel wird abgelehnt');
$check(api_key_verify($pdo, 'falsch', '203.0.113.5') === null, 'Schlüssel mit falschem Format wird abgelehnt');
$restricted = api_key_create($pdo, 'Nur ein Server', true, false, '198.51.100.7', $staffId);
$check(api_key_verify($pdo, $restricted['key'], '198.51.100.7') !== null && api_key_verify($pdo, $restricted['key'], '203.0.113.5') === null, 'IP-Beschränkung wird durchgesetzt');
$pdo->prepare('UPDATE api_keys SET is_active = 0 WHERE id = ?')->execute([$restricted['id']]);
$check(api_key_verify($pdo, $restricted['key'], '198.51.100.7') === null, 'deaktivierter Schlüssel wird abgelehnt');
// ── Ticket anlegen (direkt) ─────────────────────────────────────────────────
$throws(fn() => public_ticket_create($pdo, ['name' => '', 'email' => 'a@example.test', 'subject' => 's', 'message' => 'm'], '203.0.113.5'), 'INVALID_NAME');
$throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'kaputt', 'subject' => 's', 'message' => 'm'], '203.0.113.5'), 'INVALID_EMAIL');
$throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'a@example.test', 'subject' => '', 'message' => 'm'], '203.0.113.5'), 'INVALID_SUBJECT');
$throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'a@example.test', 'subject' => 's', 'message' => ''], '203.0.113.5'), 'INVALID_MESSAGE');
$throws(fn() => public_ticket_create($pdo, ['name' => 'A', 'email' => 'a@example.test', 'subject' => 's', 'message' => 'm', 'helpTopicId' => 99999], '203.0.113.5'), 'INVALID_TOPIC');
$pdo->prepare("INSERT INTO banlist (match_type, value, created_at) VALUES ('email', 'boese@example.test', ?)")->execute([db_now()]);
$throws(fn() => public_ticket_create($pdo, ['name' => 'B', 'email' => 'boese@example.test', 'subject' => 's', 'message' => 'm'], '203.0.113.5'), 'BANNED');
$ok = public_ticket_create($pdo, ['name' => 'Max', 'email' => 'max@example.test', 'subject' => 'Hilfe', 'message' => 'Es geht nicht'], '203.0.113.5');
$row = $pdo->query("SELECT * FROM tickets WHERE ticket_number = '{$ok['number']}'")->fetch();
$check($row && $row['source'] === 'api' && $row['status'] === 'open', 'Ticket wird mit Quelle "api" angelegt');
$check((int) $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ok['id']}")->fetchColumn() === 1, 'die erste Nachricht wird gespeichert');
$check(!empty($row['first_response_due_at']) && !empty($row['resolution_due_at']), 'SLA-Fristen werden gesetzt');
// ── Webhooks ────────────────────────────────────────────────────────────────
$throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'ftp://example.test/x', 'events' => ['ticket.created']]), 'INVALID_URL');
$throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'http://127.0.0.1/x', 'events' => ['ticket.created']]), 'INVALID_URL');
$throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'http://10.0.0.5/x', 'events' => ['ticket.created']]), 'INVALID_URL');
$throws(fn() => webhook_save($pdo, 0, ['name' => 'X', 'url' => 'https://93.184.216.34/x', 'events' => []]), 'NO_EVENTS');
$throws(fn() => webhook_save($pdo, 0, ['name' => '', 'url' => 'https://93.184.216.34/x', 'events' => ['ticket.created']]), 'NAME_REQUIRED');
$hookId = webhook_save($pdo, 0, ['name' => 'Extern', 'url' => 'https://93.184.216.34/hook', 'events' => ['ticket.created', 'bogus'], 'secret' => 'geheim']);
$hook = $pdo->query("SELECT * FROM webhooks WHERE id = {$hookId}")->fetch();
$check($hook['events'] === 'ticket.created', 'unbekannte Ereignisse werden verworfen');
$check(decrypt_secret($hook['secret_enc']) === 'geheim' && $hook['secret_enc'] !== 'geheim', 'das Webhook-Geheimnis wird verschlüsselt gespeichert');
webhook_save($pdo, $hookId, ['name' => 'Extern', 'url' => 'https://93.184.216.34/hook', 'events' => ['ticket.created'], 'secret' => '']);
$check(decrypt_secret($pdo->query("SELECT secret_enc FROM webhooks WHERE id = {$hookId}")->fetchColumn()) === 'geheim', 'leeres Geheimnis lässt das bisherige unverändert');
$payload = webhook_payload($pdo, 'ticket.created', $ok['id']);
$check($payload['event'] === 'ticket.created' && $payload['ticket']['number'] === $ok['number'] && $payload['ticket']['requesterEmail'] === 'max@example.test', 'Webhook-Nutzlast enthält die Ticketdaten');
// Zustellung an einen echten Empfänger (lokaler Testserver); das Speichern prüft interne Ziele, die Zustellung selbst nicht
$recvDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deskhub_recv_' . bin2hex(random_bytes(4));
mkdir($recvDir);
file_put_contents($recvDir . '/hook.php', '<?php file_put_contents(__DIR__ . "/got.json", json_encode(["body" => file_get_contents("php://input"), "sig" => $_SERVER["HTTP_X_DESKHUB_SIGNATURE"] ?? "", "event" => $_SERVER["HTTP_X_DESKHUB_EVENT"] ?? ""])); http_response_code(204);');
$recvPort = random_int(20000, 20999);
$recv = proc_open([PHP_BINARY, '-S', "127.0.0.1:{$recvPort}", '-t', $recvDir], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $recvPipes);
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $recvPort)) break; usleep(100000); }
$localHook = $hook; $localHook['url'] = "http://127.0.0.1:{$recvPort}/hook.php";
putenv('DESKHUB_TESTING=0');
[$bs, $be] = webhook_deliver($localHook, $payload);
$check($bs === 0 && $be !== null && !is_file($recvDir . '/got.json'), 'Zustellung an eine interne Adresse wird beim Senden blockiert (DNS-Rebinding-Schutz)');
putenv('DESKHUB_TESTING=1');
[$status, $error] = webhook_deliver($localHook, $payload);
$got = json_decode((string) @file_get_contents($recvDir . '/got.json'), true) ?: [];
$check($error === null && $status === 204, 'Webhook wird zugestellt (HTTP 204)');
$check(($got['event'] ?? '') === 'ticket.created', 'Ereignisname steht im Header');
$check(($got['sig'] ?? '') === 'sha256=' . hash_hmac('sha256', (string) ($got['body'] ?? ''), 'geheim'), 'die Signatur passt zum Körper und Geheimnis');
$deadHook = $hook; $deadHook['url'] = 'http://127.0.0.1:1/none';
[$status, $error] = webhook_deliver($deadHook, $payload);
$check($error !== null, 'nicht erreichbares Ziel meldet einen Fehler');
proc_terminate($recv);
// ── Öffentliche API über echten Server ──────────────────────────────────────
$apiPort = random_int(21000, 21999);
$api = proc_open([PHP_BINARY, '-d', 'max_execution_time=20', '-S', "127.0.0.1:{$apiPort}", '-t', $root], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $apiPipes, $root);
for ($i = 0; $i < 80; $i++) { if (@fsockopen('127.0.0.1', $apiPort)) break; usleep(100000); }
$call = function (string $method, string $query, ?array $json, ?string $key) use ($apiPort): array {
$headers = ['Content-Type: application/json', 'Accept: application/json'];
if ($key !== null) $headers[] = 'X-API-Key: ' . $key;
$ctx = stream_context_create(['http' => ['method' => $method, 'header' => implode("\r\n", $headers), 'content' => $json !== null ? json_encode($json) : '', 'ignore_errors' => true, 'timeout' => 20]]);
$body = @file_get_contents("http://127.0.0.1:{$apiPort}/api/public/v1/tickets.php{$query}", false, $ctx);
$status = 0;
foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1];
return [$status, json_decode((string) $body, true) ?: []];
};
[$s, $b] = $call('POST', '', ['name' => 'X'], null);
$check($s === 401, 'ohne Schlüssel: 401');
[$s, $b] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'Per API', 'message' => 'Hallo'], $created['key']);
$check($s === 201 && !empty($b['number']), 'Ticket per API angelegt (201)');
$apiNumber = $b['number'] ?? '';
[$s, $b] = $call('POST', '', ['name' => 'Eva', 'email' => 'kaputt', 'subject' => 'x', 'message' => 'y'], $created['key']);
$check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_EMAIL', 'ungültige Eingabe: 422 mit Fehlercode');
[$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=eva@example.test', null, $created['key']);
$check($s === 200 && ($b['ticket']['status'] ?? '') === 'open' && count($b['ticket']['messages'] ?? []) === 1, 'Status per API abgefragt');
[$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=fremd@example.test', null, $created['key']);
$check($s === 404, 'falsche E-Mail-Adresse: Ticket bleibt verborgen (404)');
$readOnly = api_key_create($pdo, 'Nur lesen', false, true, '', $staffId);
[$s] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'x', 'message' => 'y'], $readOnly['key']);
$check($s === 403, 'Schlüssel ohne Recht zum Anlegen: 403');
$createOnly = api_key_create($pdo, 'Nur anlegen', true, false, '', $staffId);
[$s] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=eva@example.test', null, $createOnly['key']);
$check($s === 403, 'Schlüssel ohne Recht zum Lesen: 403');
proc_terminate($api);
// ── Gespeicherte Ansichten ──────────────────────────────────────────────────
$web = queue_filters_from_web(['q' => ' Rechnung ', 'department_id' => '2', 'status' => 'open', 'unbekannt' => 'x', 'tab' => '']);
$check($web === ['q' => 'Rechnung', 'status' => 'open', 'departmentId' => '2'], 'Web-Filter werden in neutrale Filter übersetzt und bereinigt');
$check(queue_filters_to_web($web) === ['q' => 'Rechnung', 'status' => 'open', 'department_id' => '2'], 'neutrale Filter lassen sich zurückübersetzen');
$check(queue_filters_to_web(['status' => 'open,pending', 'q' => 'x'])['tab'] === 'open', 'mehrere Status aus den Apps werden zum Web-Reiter "Offen"');
$check(queue_filters_to_api(['tab' => 'closed', 'quick' => 'me'])['status'] === 'closed' && queue_filters_to_api(['quick' => 'me'])['assigned'] === 'me', 'Web-Reiter und Schnellfilter werden für die Apps übersetzt');
$q1 = queue_create($pdo, $staffId, 'Meine offenen', ['status' => 'open', 'assigned' => 'me'], false);
$throws(fn() => queue_create($pdo, $staffId, 'meine OFFENEN', [], false), 'NAME_TAKEN');
$throws(fn() => queue_create($pdo, $staffId, ' ', [], false), 'NAME_REQUIRED');
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES ('Kollege','kollege@example.test','x','agent',1,?)")->execute([db_now()]);
$otherId = (int) $pdo->lastInsertId();
$qShared = queue_create($pdo, $otherId, 'Team-Überblick', ['sla' => 'overdue'], true);
queue_create($pdo, $otherId, 'Privat des Kollegen', [], false);
$names = array_column(queues_for($pdo, $staffId), 'name');
$check(in_array('Meine offenen', $names, true) && in_array('Team-Überblick', $names, true) && !in_array('Privat des Kollegen', $names, true), 'eigene und geteilte Ansichten sind sichtbar, fremde private nicht');
$sharedRow = $pdo->query("SELECT * FROM saved_queues WHERE id = {$qShared}")->fetch();
$check(!queue_can_edit($sharedRow, ['id' => $staffId, 'role' => 'agent']) && queue_can_edit($sharedRow, ['id' => $staffId, 'role' => 'admin']) && queue_can_edit($sharedRow, ['id' => $otherId, 'role' => 'agent']), 'fremde Ansichten darf nur der Besitzer oder ein Administrator ändern');
foreach ([$tempDb, $recvDir . '/got.json', $recvDir . '/hook.php'] as $f) @unlink($f);
@rmdir($recvDir);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Integrations API checks passed.\n";
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
$sourceDb = $root . '/database/deskhub.sqlite';
$tempDb = tempnam(sys_get_temp_dir(), 'deskhub-api-');
if ($tempDb === false || !copy($sourceDb, $tempDb)) {
fwrite(STDERR, "Testdatenbank konnte nicht erstellt werden.\n");
exit(1);
}
$pdo = new PDO('sqlite:' . $tempDb);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$password = 'MobileApi-Test-Password-42';
$pdo->prepare('UPDATE staff SET password_hash = ?, is_active = 1 WHERE id = 1')
->execute([password_hash($password, PASSWORD_DEFAULT)]);
$agentPassword = 'MobileApi-Agent-Password-42';
$agentHash = password_hash($agentPassword, PASSWORD_DEFAULT);
$pdo->prepare(
"INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at)
VALUES ('Abteilungsagent', 'dept-agent@example.com', ?, 'agent', 2, 1, ?)"
)->execute([$agentHash, $now ?? date('Y-m-d H:i:s')]);
$pdo->prepare(
"INSERT INTO roles (name, permissions, created_at) VALUES ('Nur Antworten', '{\"reply_ticket\":true}', ?)"
)->execute([date('Y-m-d H:i:s')]);
$restrictedRoleId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at)
VALUES ('Eingeschränkter Agent', 'restricted-agent@example.com', ?, 'agent', ?, 1, 1, ?)"
)->execute([$agentHash, $restrictedRoleId, date('Y-m-d H:i:s')]);
$number = 'API-' . random_int(100000, 999999);
$now = date('Y-m-d H:i:s');
$pdo->prepare(
"INSERT INTO tickets
(ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at)
VALUES (?,?,?,?,?,?,'open','web',?,?)"
)->execute([$number, 'Mobiler Integrationstest', 'Test Kunde', 'kunde@example.test', 1, 2, $now, $now]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at)
VALUES (?,'customer',?,0,?)"
)->execute([$ticketId, 'Bitte mobil bearbeiten.', $now]);
$port = random_int(18080, 18999);
$command = [PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $root];
$previousDbPath = getenv('DESKHUB_SQLITE_PATH');
$previousInsecure = getenv('DESKHUB_API_ALLOW_INSECURE');
putenv('DESKHUB_SQLITE_PATH=' . $tempDb);
putenv('DESKHUB_API_ALLOW_INSECURE=1');
$process = proc_open(
$command,
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
$pipes,
$root,
null,
);
putenv($previousDbPath === false ? 'DESKHUB_SQLITE_PATH' : 'DESKHUB_SQLITE_PATH=' . $previousDbPath);
putenv($previousInsecure === false ? 'DESKHUB_API_ALLOW_INSECURE' : 'DESKHUB_API_ALLOW_INSECURE=' . $previousInsecure);
if (!is_resource($process)) {
unlink($tempDb);
fwrite(STDERR, "PHP-Testserver konnte nicht gestartet werden.\n");
exit(1);
}
try {
$ready = false;
for ($attempt = 0; $attempt < 30; $attempt++) {
$socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.1);
if ($socket) {
fclose($socket);
$ready = true;
break;
}
usleep(100000);
}
if (!$ready) {
$stderr = stream_get_contents($pipes[2]);
throw new RuntimeException('PHP-Testserver ist nicht erreichbar. ' . trim($stderr));
}
[$status] = request($port, 'GET', '/api/staff/v1/me.php');
assert_same(401, $status, 'Geschützte Endpunkte müssen ohne Token 401 liefern.');
[$status, $login] = request($port, 'POST', '/api/staff/v1/login.php', [
'email' => 'admin@example.com',
'password' => $password,
'deviceName' => 'Integrationstest',
]);
assert_same(200, $status, 'Mitarbeiter-Login muss erfolgreich sein.');
$token = $login['token'] ?? '';
assert_true(strlen($token) >= 40, 'Login muss ein starkes Bearer-Token liefern.');
$headers = ['Authorization: Bearer ' . $token];
[$status, $metadata] = request($port, 'GET', '/api/staff/v1/metadata.php', null, $headers);
assert_same(200, $status, 'Metadaten müssen abrufbar sein.');
assert_same('admin@example.com', $metadata['staff']['email'] ?? null, 'Falsches Mitarbeiterprofil.');
[$status, $tickets] = request(
$port,
'GET',
'/api/staff/v1/tickets.php?q=' . rawurlencode('Mobiler Integrationstest'),
null,
$headers,
);
assert_same(200, $status, 'Ticketliste muss abrufbar sein.');
assert_same($number, $tickets['items'][0]['number'] ?? null, 'Ticketsuche liefert nicht das Testticket.');
[, $departmentLogin] = request($port, 'POST', '/api/staff/v1/login.php', [
'email' => 'dept-agent@example.com',
'password' => $agentPassword,
'deviceName' => 'Abteilungstest',
]);
$departmentHeaders = ['Authorization: Bearer ' . ($departmentLogin['token'] ?? '')];
[$status] = request(
$port,
'GET',
'/api/staff/v1/ticket.php?number=' . rawurlencode($number),
null,
$departmentHeaders,
);
assert_same(403, $status, 'Abteilungsfremde Tickets müssen serverseitig gesperrt sein.');
[, $restrictedLogin] = request($port, 'POST', '/api/staff/v1/login.php', [
'email' => 'restricted-agent@example.com',
'password' => $agentPassword,
'deviceName' => 'Rollentest',
]);
$restrictedHeaders = ['Authorization: Bearer ' . ($restrictedLogin['token'] ?? '')];
[$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [
'number' => $number,
'type' => 'note',
'message' => 'Diese Notiz darf nicht gespeichert werden.',
], $restrictedHeaders);
assert_same(403, $status, 'Fehlende Rollenrechte müssen serverseitig gesperrt sein.');
[$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [
'number' => $number,
'type' => 'reply',
'message' => 'Diese Antwort darf das Ticket nicht schließen.',
'setStatus' => 'closed',
], $restrictedHeaders);
assert_same(403, $status, 'Antwortrechte dürfen das Recht zum Schließen nicht umgehen.');
[$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [
'number' => $number,
'type' => 'reply',
'message' => 'Öffentliche Antwort aus dem Integrationstest',
'setStatus' => 'answered',
], $restrictedHeaders);
assert_same(201, $status, 'Erlaubte öffentliche Antworten müssen erstellt werden.');
[$status, $detail] = request(
$port,
'GET',
'/api/staff/v1/ticket.php?number=' . rawurlencode($number),
null,
$headers,
);
assert_same(200, $status, 'Ticketdetail muss abrufbar sein.');
assert_same('Bitte mobil bearbeiten.', $detail['ticket']['threads'][0]['message'] ?? null, 'Thread fehlt.');
[$status, $updated] = request($port, 'PATCH', '/api/staff/v1/ticket_update.php', [
'number' => $number,
'status' => 'pending',
'assignedStaffId' => 1,
], $headers);
assert_same(200, $status, 'Status und Zuständigkeit müssen änderbar sein.');
assert_same('pending', $updated['ticket']['status'] ?? null, 'Status wurde nicht gespeichert.');
[$status] = request($port, 'POST', '/api/staff/v1/ticket_message.php', [
'number' => $number,
'type' => 'note',
'message' => 'Interne Notiz aus dem Integrationstest',
], $headers);
assert_same(201, $status, 'Interne Notiz muss erstellt werden.');
[$status] = request($port, 'POST', '/api/staff/v1/logout.php', [], $headers);
assert_same(200, $status, 'Logout muss erfolgreich sein.');
[$status] = request($port, 'GET', '/api/staff/v1/me.php', null, $headers);
assert_same(401, $status, 'Widerrufenes Token darf nicht weiter funktionieren.');
$check = new PDO('sqlite:' . $tempDb);
$stmt = $check->prepare('SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 1');
$stmt->execute([$ticketId]);
assert_same(1, (int) $stmt->fetchColumn(), 'Interne Notiz fehlt in der Datenbank.');
echo "Mobile API integration test passed.\n";
} finally {
proc_terminate($process);
foreach ($pipes as $pipe) {
if (is_resource($pipe)) fclose($pipe);
}
proc_close($process);
@unlink($tempDb);
}
function request(int $port, string $method, string $path, ?array $body = null, array $headers = []): array
{
$headers[] = 'Accept: application/json';
$content = null;
if ($body !== null) {
$content = json_encode($body, JSON_THROW_ON_ERROR);
$headers[] = 'Content-Type: application/json';
}
$context = stream_context_create([
'http' => [
'method' => $method,
'header' => implode("\r\n", $headers),
'content' => $content,
'ignore_errors' => true,
'timeout' => 10,
],
]);
$raw = file_get_contents("http://127.0.0.1:$port$path", false, $context);
$statusLine = $http_response_header[0] ?? '';
preg_match('/\s(\d{3})\s/', $statusLine, $matches);
$status = (int) ($matches[1] ?? 0);
$decoded = $raw !== false && $raw !== '' ? json_decode($raw, true) : [];
return [$status, is_array($decoded) ? $decoded : []];
}
function assert_true(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
function assert_same(mixed $expected, mixed $actual, string $message): void
{
if ($expected !== $actual) {
throw new RuntimeException($message . ' Erwartet: ' . var_export($expected, true)
. ', erhalten: ' . var_export($actual, true));
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
/**
* Prüft den osTicket-Import (includes/osticket_import*.php) mit einem selbst erzeugten Dump in zwei Formaten:
* phpMyAdmin (Spaltenliste, ein Datensatz je Zeile, hex-Blobs) und mysqldump (ohne Spaltenliste, alle Datensätze in einer Zeile,
* anderes Tabellenpräfix, maskierte Blobs). Enthält die schwierigen Fälle: Anführungszeichen, Backslashes, Klammern und Kommas im
* Text, Zeilenumbrüche, leere Nachrichten, gelöschte Tickets, Anhänge, zu kleine Analyse-Blöcke.
*/
$uploads = sys_get_temp_dir() . '/dh-os-uploads-' . bin2hex(random_bytes(4));
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/includes/osticket_import_passes.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
// ── Dump erzeugen ────────────────────────────────────────────────────────────
$q = fn($s) => $s === null ? 'NULL' : "'" . str_replace(["\\", "'", "\n", "\r", "\0", "\x1a"], ["\\\\", "\\'", "\\n", "\\r", "\\0", "\\Z"], (string) $s) . "'";
$ticketTable = [
// id, number, user, status, dept, topic, staff, source, created, closed, lastupdate, overdue
[1, '100001', 1, 1, 1, 1, 1, 'Web', '2024-01-10 08:00:00', null, '2024-01-10 09:00:00', 1],
[2, '100002', 2, 2, 5, 2, 0, 'Email', '2024-01-11 08:00:00', '2024-01-12 10:00:00', '2024-01-12 10:00:00', 0],
[3, '100003', 3, 3, 1, 0, 1, 'Phone', '2024-01-12 08:00:00', '2024-01-13 10:00:00', '2024-01-13 10:00:00', 0],
[4, '100004', 1, 5, 1, 1, 0, 'API', '2024-01-13 08:00:00', null, '2024-01-13 08:00:00', 0], // gelöscht
[5, '100005', 2, 4, 1, 1, 0, 'Other', '2024-01-14 08:00:00', '2024-01-15 08:00:00', '2024-01-15 08:00:00', 0], // archiviert
];
$subjects = [1 => "Drucker 'kaputt', (dringend)", 2 => 'Rechnung \\ fehlt', 3 => 'Ünïcödé Ticket ✓', 4 => 'Gelöscht', 5 => 'Archiviert'];
$prioOfTicket = [1 => 4, 2 => 1, 3 => 2, 4 => 3, 5 => 3];
$bigBody = "<p>Hallo,</p><p>ich habe ein Problem mit dem Gerät (Modell X-1, 'Pro'), siehe \\\\server\\share.</p><p>Zeile 1<br>Zeile 2),(1,'x',</p><blockquote>zitierter Text</blockquote>";
$entries = [
// id, thread, staff, user, type, poster, body, format, created
[1, 1, 0, 1, 'M', 'Kunde Eins', $bigBody, 'html', '2024-01-10 08:00:00'],
[2, 1, 1, 0, 'R', 'Support Admin', "<p>Wir kümmern uns.</p>\n<p>Gruß</p>", 'html', '2024-01-10 09:00:00'],
[3, 1, 1, 0, 'N', 'Support Admin', 'Interne Notiz: Kunde ist VIP', 'text', '2024-01-10 09:05:00'],
[4, 2, 0, 2, 'M', 'Kunde Zwei', 'Wo ist meine Rechnung?', 'text', '2024-01-11 08:00:00'],
[5, 2, 0, 2, 'M', 'Kunde Zwei', '', 'html', '2024-01-11 08:30:00'], // leer, mit Anhang
[6, 2, 0, 2, 'M', 'Kunde Zwei', '<p> </p>', 'html', '2024-01-11 08:40:00'], // leer, ohne Anhang
[7, 3, 0, 3, 'M', 'Kunde Drei', 'Hilfe', 'text', '2024-01-12 08:00:00'],
[8, 4, 0, 1, 'M', 'Kunde Eins', 'Gehört zu gelöschtem Ticket', 'text', '2024-01-13 08:00:00'],
];
$collab = [[1, 1, 1, 3]]; // id, flags, thread, user
$users = [[1, 'Kunde Eins', 1], [2, "O'Brien, Pat (Kunde)", 2], [3, '', 3], [4, 'Ohne Adresse', 0]];
$emails = [[1, 1, 'eins@example.test'], [2, 2, 'pat@example.test'], [3, 3, 'drei@example.test']];
$file1 = "\x89PNG\r\n" . str_repeat("A'B\\C\0D", 40); // enthält Anführungszeichen, Backslash, NUL
$file2 = random_bytes(3000);
$files = [[1, 'T', 'image/png', strlen($file1), 'logo.png'], [2, 'T', 'application/pdf', strlen($file2), 'bericht.pdf'], [3, 'T', 'image/png', 10, 'inline.png']];
$attachments = [[1, 5, 'H', 1, null, 0], [2, 4, 'H', 2, 'Rechnung 4711.pdf', 0], [3, 2, 'H', 3, null, 1], [4, 99, 'T', 3, null, 1]];
/** Dump im gewünschten Format schreiben. */
$makeDump = function (string $format, string $prefix) use ($q, $ticketTable, $subjects, $prioOfTicket, $entries, $collab, $users, $emails, $files, $attachments, $file1, $file2): string {
$path = tempnam(sys_get_temp_dir(), 'osdump') . '.sql';
$out = "-- Dump ($format)\nSET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\nSTART TRANSACTION;\n\n";
$table = function (string $name, array $cols, array $rows) use (&$out, $format, $prefix, $q): void {
$t = $prefix . $name;
$out .= "CREATE TABLE `{$t}` (\n `id` int(11) NOT NULL\n) ENGINE=InnoDB;\n\n";
if (!$rows) return;
$tuples = array_map(fn($r) => '(' . implode(', ', array_map(fn($v) => is_int($v) ? (string) $v : (is_string($v) && str_starts_with($v, '0x') ? $v : $q($v)), $r)) . ')', $rows);
if ($format === 'phpmyadmin') {
$out .= "INSERT INTO `{$t}` (`" . implode('`, `', $cols) . "`) VALUES\n" . implode(",\n", $tuples) . ";\n\n";
} else {
$out .= "INSERT INTO `{$t}` VALUES " . implode(',', $tuples) . ";\n\n";
}
};
$T = osticket_tables();
// Unrelated noise (Sitzungen mit Zeilenumbruch-freiem Datenmüll) darf nichts auslösen
$out .= "INSERT INTO `{$prefix}session` (`session_id`, `session_data`) VALUES\n('abc', 'INSERT INTO `{$prefix}user` VALUES (999,1,1,0,\\'Falsch\\',NOW(),NOW())'),\n('def', '(1,2,3)');\n\n";
$table('department', $T['department'], [
[1, 0, 0, 0, 0, 0, 0, 0, 0, 'Support', '', 1, 0, 1, 1, '1', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[5, 0, 0, 0, 0, 0, 0, 0, 0, 'Buchhaltung', '', 1, 0, 1, 1, '5', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
]);
$table('help_topic', $T['help_topic'], [
[1, 0, 1, 1, 1, 0, 0, 0, 4, 1, 0, 0, 0, 0, 0, 0, 'Technisches Problem', '', '', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[2, 0, 1, 1, 1, 0, 0, 0, 2, 5, 0, 0, 0, 0, 0, 0, 'Rechnungsfrage', '', '', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
]);
$table('staff', $T['staff'], [[1, 1, 1, 'admin', 'Support', 'Admin', 'x', 'local', 'admin@example.com'], [2, 1, 1, 'weg', 'Ex', 'Kollege', 'x', 'local', 'weg@example.test']]);
$table('ticket_priority', $T['ticket_priority'], [[1, 'low', 'Niedrig', '#ddd', 4, 1], [2, 'normal', 'Normal', '#ddd', 3, 1], [3, 'high', 'Hoch', '#ddd', 2, 1], [4, 'emergency', 'Notfall', '#ddd', 1, 1]]);
$table('ticket_status', $T['ticket_status'], [[1, 'Offen', 'open', 3, 0, 1, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [2, 'Gelöst', 'closed', 1, 0, 2, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[3, 'Geschlossen', 'closed', 1, 0, 3, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [4, 'Archiviert', 'archived', 3, 0, 4, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'],
[5, 'Gelöscht', 'deleted', 3, 0, 5, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00']]);
$table('user', $T['user'], array_map(fn($u) => [$u[0], 0, $u[2], 0, $u[1], '2024-01-01 00:00:00', '2024-01-01 00:00:00'], $users));
$table('user_email', $T['user_email'], array_map(fn($e) => [$e[0], $e[1], 0, $e[2]], $emails));
$table('ticket', $T['ticket'], array_map(fn($t) => [$t[0], null, $t[1], $t[2], 0, $t[3], $t[4], 1, $t[5], $t[6], 0, 0, 0, 0, 0, '10.0.0.1', $t[7], null, $t[11], 0, null, null, null, $t[9], $t[10], $t[8], $t[10]], $ticketTable));
$table('ticket__cdata', $T['ticket__cdata'], array_map(fn($t) => [$t[0], $subjects[$t[0]], $prioOfTicket[$t[0]]], $ticketTable));
$table('thread', $T['thread'], array_map(fn($t) => [$t[0], $t[0], 'T', null, null, null, $t[8]], $ticketTable));
$table('thread_collaborator', $T['thread_collaborator'], array_map(fn($c) => [$c[0], $c[1], $c[2], $c[3], 'M', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], $collab));
$table('thread_entry', $T['thread_entry'], array_map(fn($e) => [$e[0], 0, $e[1], $e[2], $e[3], $e[4], 0, $e[5], '', '', '', '', $e[6], $e[7], '10.0.0.1', null, null, $e[8], $e[8]], $entries));
$table('attachment', $T['attachment'], array_map(fn($a) => [$a[0], $a[1], $a[2], $a[3], $a[4], $a[5], null], $attachments));
$table('file', $T['file'], array_map(fn($f) => [$f[0], $f[1], 'D', $f[2], $f[3], 'key' . $f[0], 'sig' . $f[0], $f[4], null, '2024-01-01 00:00:00'], $files));
// Blob-Daten: phpMyAdmin als 0x…, mysqldump als maskierter String; Datei 1 in zwei Teilen
$blob = fn(string $d) => $format === 'phpmyadmin' ? '0x' . bin2hex($d) : $q($d);
$parts = [[1, 0, substr($file1, 0, 100)], [1, 1, substr($file1, 100)], [2, 0, $file2], [3, 0, 'x'], [99, 0, 'nicht gebraucht']];
foreach ($parts as $p) {
if ($format === 'phpmyadmin') $out .= "INSERT INTO `{$prefix}file_chunk` (`file_id`, `chunk_id`, `filedata`) VALUES\n({$p[0]}, {$p[1]}, " . $blob($p[2]) . ");\n\n";
else $out .= "INSERT INTO `{$prefix}file_chunk` VALUES ({$p[0]},{$p[1]}," . $blob($p[2]) . ");\n";
}
$out .= "\nCREATE TABLE `{$prefix}form` (\n `id` int\n);\nCOMMIT;\n";
file_put_contents($path, $out);
return $path;
};
// ── Import-Lauf ──────────────────────────────────────────────────────────────
$runImport = function (string $dump, int $blockBytes, array $passes) use ($check): array {
$pdo = get_db();
$dir = sys_get_temp_dir() . '/dh-os-state-' . bin2hex(random_bytes(4));
mkdir($dir, 0700, true);
$offset = 0;
$steps = 0;
do {
$a = osticket_analyze_step($dump, $dir, $offset, $blockBytes);
$offset = $a['offset'];
$steps++;
} while (!$a['done'] && $steps < 100000);
$results = ['analysis' => $a['counts'], 'steps' => $steps, 'passes' => [], 'notes' => []];
foreach ($passes as $pass) {
$off = 0; $ins = 0; $skip = 0; $guard = 0;
do {
$r = osticket_import_step($pdo, $dir, $pass, $off, $dump);
$ins += $r['inserted']; $skip += $r['skipped']; $off = $r['next_offset'];
foreach ($r['notes'] as $n) $results['notes'][] = $n;
} while (!$r['done'] && ++$guard < 10000);
$results['passes'][$pass] = [$ins, $skip];
}
osticket_state_clear($dir);
return $results;
};
$pdo = get_db();
$origUploads = config()['uploads']['path'];
$allPasses = ['users', 'structure', 'tickets', 'threads', 'attachments'];
foreach (['phpmyadmin' => 'ost_', 'mysqldump' => 'wf_'] as $format => $prefix) {
// Frische Datenbank für jedes Format
foreach (['ticket_attachments', 'ticket_collaborators', 'ticket_threads', 'tickets', 'help_topics', 'users'] as $t) $pdo->exec("DELETE FROM {$t}");
$pdo->exec("DELETE FROM departments WHERE name IN ('Support','Buchhaltung')");
$pdo->exec("DELETE FROM help_topics WHERE name IN ('Technisches Problem','Rechnungsfrage')");
$label = "[$format] ";
$dump = $makeDump($format, $prefix);
$res = $runImport($dump, 700, $allPasses); // winzige Blöcke: Zeilen- und Anweisungsgrenzen werden mehrfach überschritten
$check($res['steps'] > 5, $label . 'Analyse läuft in vielen kleinen Schritten');
$an = $res['analysis'];
$check($an['users'] === 3 && $an['departments'] === 2 && $an['topics'] === 2 && $an['tickets'] === 4 && $an['threads'] === 8 && $an['attachments'] === 2, $label . 'Analyse zählt richtig: ' . json_encode($an));
$check($res['passes']['users'] === [3, 1], $label . 'Kunden: 3 importiert, 1 ohne Adresse übersprungen: ' . json_encode($res['passes']['users']));
$check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE name = 'Falsch'")->fetchColumn() === 0, $label . 'Text in anderen Tabellen wird nicht als Kunde gelesen');
$names = $pdo->query('SELECT email, name FROM users ORDER BY email')->fetchAll(PDO::FETCH_KEY_PAIR);
$check(($names['pat@example.test'] ?? '') === "O'Brien, Pat (Kunde)" && ($names['eins@example.test'] ?? '') === 'Kunde Eins', $label . 'Kundennamen bleiben erhalten (auch mit Komma, Klammern, Apostroph)');
$check(($names['drei@example.test'] ?? '') === 'drei', $label . 'Kunde ohne Namen bekommt den Anfang der E-Mail-Adresse');
$check($res['passes']['structure'][0] === 2 + 2, $label . 'Abteilungen und Hilfethemen werden angelegt: ' . json_encode($res['passes']['structure']));
$dept = $pdo->query("SELECT id FROM departments WHERE name = 'Buchhaltung'")->fetchColumn();
$topic = $pdo->query("SELECT department_id, priority_id FROM help_topics WHERE name = 'Rechnungsfrage'")->fetch();
$check($dept && $topic && (int) $topic['department_id'] === (int) $dept, $label . 'Hilfethema gehört zur richtigen (importierten) Abteilung');
$check($res['passes']['tickets'] === [4, 1], $label . 'Tickets: 4 importiert (inkl. archiviertem), das gelöschte übersprungen: ' . json_encode($res['passes']['tickets']));
$tk = $pdo->query('SELECT t.*, d.name AS dept, h.name AS topic, p.name AS prio, s.email AS staff FROM tickets t JOIN departments d ON d.id = t.department_id LEFT JOIN help_topics h ON h.id = t.help_topic_id JOIN priorities p ON p.id = t.priority_id LEFT JOIN staff s ON s.id = t.assigned_staff_id')->fetchAll();
$by = array_column($tk, null, 'ticket_number');
$val = fn(string $n, string $k) => array_key_exists($n, $by) ? $by[$n][$k] : '<<fehlt>>';
$check(isset($by['100001'], $by['100002'], $by['100003']) && !isset($by['100004']), $label . 'gelöschtes Ticket fehlt, übrige sind da');
$check(($by['100001']['subject'] ?? '') === "Drucker 'kaputt', (dringend)" && ($by['100003']['subject'] ?? '') === 'Ünïcödé Ticket ✓' && ($by['100002']['subject'] ?? '') === 'Rechnung \\ fehlt', $label . 'Betreffs mit Sonderzeichen stimmen');
$check(($by['100001']['dept'] ?? '') === 'Support' && ($by['100002']['dept'] ?? '') === 'Buchhaltung', $label . 'Abteilung wird übernommen');
$check(($by['100001']['topic'] ?? '') === 'Technisches Problem' && ($by['100002']['topic'] ?? '') === 'Rechnungsfrage' && $val('100003', 'topic') === null, $label . 'Hilfethema wird übernommen');
$check(($by['100001']['prio'] ?? '') === 'Dringend' && ($by['100002']['prio'] ?? '') === 'Niedrig' && ($by['100003']['prio'] ?? '') === 'Normal', $label . 'Priorität wird über die Rangfolge zugeordnet');
$check(($by['100001']['status'] ?? '') === 'open' && ($by['100002']['status'] ?? '') === 'resolved' && ($by['100003']['status'] ?? '') === 'closed', $label . 'Status wird zugeordnet (offen, gelöst, geschlossen)');
$check(($by['100001']['staff'] ?? '') === 'admin@example.com' && $val('100002', 'staff') === null, $label . 'Zuweisung über gleiche E-Mail-Adresse');
$check(($by['100001']['source'] ?? '') === 'web' && ($by['100002']['source'] ?? '') === 'email' && ($by['100003']['source'] ?? '') === 'phone', $label . 'Quelle wird übernommen');
$check(($by['100002']['resolved_at'] ?? '') === '2024-01-12 10:00:00' && $val('100001', 'resolved_at') === null && ($by['100001']['updated_at'] ?? '') === '2024-01-10 09:00:00', $label . 'Zeitstempel (gelöst, zuletzt geändert)');
$check(($by['100001']['first_responded_at'] ?? '') === '2024-01-10 09:00:00' && $val('100001', 'first_response_due_at') === null, $label . 'erste Antwort wird aus der ersten Mitarbeiter-Antwort abgeleitet');
$check((int) ($by['100001']['is_overdue'] ?? 0) === 1 && ($by['100001']['requester_name'] ?? '') === 'Kunde Eins' && ($by['100001']['requester_email'] ?? '') === 'eins@example.test', $label . 'Überfällig-Flag und Anfragender');
$check(in_array('Gelöschte osTicket-Tickets wurden nicht importiert.', $res['notes'], true), $label . 'Hinweis zu gelöschten Tickets');
$check(isset($by['100005']) === true && ($by['100005']['status'] ?? '') === 'closed', $label . 'archiviertes Ticket wird als geschlossen übernommen');
$th = $pdo->query('SELECT t.ticket_number, th.author_type, th.is_internal_note, th.message, th.created_at, th.staff_id FROM ticket_threads th JOIN tickets t ON t.id = th.ticket_id ORDER BY th.created_at, th.id')->fetchAll();
$check(count($th) === 6, $label . 'Nachrichten: 6 erwartet (1 leer ohne Anhang, 1 zu gelöschtem Ticket übersprungen), gefunden ' . count($th));
$first = $th[0] ?? [];
$check(str_contains((string) ($first['message'] ?? ''), "'Pro'") && str_contains((string) $first['message'], '\\\\server\\share') && str_contains((string) $first['message'], "X-1") && str_contains((string) $first['message'], "Zeile 2),(1,'x',") && !str_contains((string) $first['message'], 'zitierter'), $label . 'Kundentext mit Anführungszeichen, Backslashes und "),(" bleibt heil, Zitat wird entfernt');
$check(($first['author_type'] ?? '') === 'customer', $label . 'erste Nachricht stammt vom Kunden');
$types = array_count_values(array_map(fn($t) => $t['author_type'] . ($t['is_internal_note'] ? '-note' : ''), $th));
$check(($types['staff'] ?? 0) === 1 && ($types['staff-note'] ?? 0) === 1 && ($types['customer'] ?? 0) === 4, $label . 'Antwort, interne Notiz und Kundennachrichten richtig getrennt: ' . json_encode($types));
$reply = array_values(array_filter($th, fn($t) => $t['author_type'] === 'staff' && !$t['is_internal_note']))[0] ?? [];
$check(str_contains((string) ($reply['message'] ?? ''), 'Wir kümmern uns') && (int) ($reply['staff_id'] ?? 0) > 0, $label . 'Mitarbeiter-Antwort ist dem Mitarbeiter zugeordnet');
$noText = array_values(array_filter($th, fn($t) => str_contains((string) $t['message'], 'siehe Anhang')));
$check(count($noText) === 1, $label . 'Nachricht ohne Text, aber mit Anhang, bleibt als Platzhalter erhalten');
$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_collaborators')->fetchColumn() === 1, $label . 'Mitlesende werden übernommen');
// Anhänge
$att = $pdo->query('SELECT a.original_name, a.stored_name, a.filesize FROM ticket_attachments a ORDER BY a.original_name')->fetchAll();
$check(count($att) === 2, $label . 'zwei Anhänge (Inline-Bilder und Vorlagen-Logos zählen nicht): ' . count($att));
$upl = rtrim((string) config()['uploads']['path'], '/') . '/';
$byName = array_column($att, null, 'original_name');
$check(isset($byName['Rechnung 4711.pdf']) && is_file($upl . $byName['Rechnung 4711.pdf']['stored_name']) && file_get_contents($upl . $byName['Rechnung 4711.pdf']['stored_name']) === $file2, $label . 'Binärer Anhang kommt byte-genau an');
$check(isset($byName['logo.png']) && file_get_contents($upl . $byName['logo.png']['stored_name']) === $file1, $label . 'Anhang aus zwei Teilen mit Anführungszeichen, Backslash und NUL kommt byte-genau an');
$check(isset($byName['Rechnung 4711.pdf']) && str_ends_with($byName['Rechnung 4711.pdf']['stored_name'], '.pdf') && preg_match('/^[a-f0-9]{32}\.(pdf|png)$/', $byName['logo.png']['stored_name'] ?? ''), $label . 'gespeicherte Dateinamen sind zufällig, Endung bleibt');
$check(!glob(sys_get_temp_dir() . '/dh-os-state-*/files/*.part') , $label . 'keine Reste von Zwischendateien');
foreach ($att as $a) @unlink($upl . $a['stored_name']);
// Zweiter Lauf: nichts wird doppelt angelegt
$before = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM ticket_threads')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn()];
$res2 = $runImport($dump, 5000, ['users', 'structure', 'tickets', 'threads']);
$after = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM ticket_threads')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn()];
$check($before === $after, $label . 'wiederholter Import legt nichts doppelt an: ' . json_encode([$before, $after]));
@unlink($dump);
}
// ── Parser im Detail ─────────────────────────────────────────────────────────
$rows = iterator_to_array(osticket_rows("(1, 'a,b', NULL, 0xAB), (2, 'it\\'s (x),(y)', '''q''', 3.5, 'a\\\\b', 'zeile\\nzwei')"), false);
$check($rows === [['1', 'a,b', null, '0xAB'], ['2', "it's (x),(y)", "'q'", '3.5', 'a\\b', "zeile\nzwei"]], 'Zeilenparser: Kommas, Klammern, Apostrophe, Backslashes, NULL, hex');
$check(osticket_table_key('myprefix_thread_entry') === 'thread_entry' && osticket_table_key('ost_thread_entry_email') === null && osticket_table_key('ost_user__cdata') === null && osticket_table_key('ost_ticket__cdata') === 'ticket__cdata', 'Tabellennamen mit beliebigem Präfix werden erkannt, ähnliche Tabellen nicht verwechselt');
$h = osticket_parse_header("INSERT INTO `ost_x_ticket` (`a`, `b`) VALUES (1, 'x'),(2,'y');");
$check($h[0] === 'ost_x_ticket' && $h[1] === ['a', 'b'] && str_starts_with($h[2], '(1,'), 'Kopfzeile mit Spaltenliste und Daten in derselben Zeile');
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "osTicket import checks passed ({$total} Prüfungen).\n";
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/** Prüft das mitgelieferte Plugin "Tagesbericht" (docs/plugins/tagesbericht): Installation, Zahlen pro Abteilung, Zeitsteuerung. */
$pluginDir = sys_get_temp_dir() . '/deskhub-tb-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
require_once __DIR__ . '/fixtures/paths.php';
$src = plugin_source_dir_or_skip('tagesbericht', 'plugin_tagesbericht');
$zipPath = sys_get_temp_dir() . '/tb-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
}
$zip->close();
$r = plugin_install_zip($pdo, $zipPath);
$check($r['ok'] && $r['slug'] === 'tagesbericht', 'Plugin lässt sich installieren: ' . json_encode($r));
$check(!plugin_set_active($pdo, 'tagesbericht', true)['ok'], 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt');
$check(plugin_set_active($pdo, 'tagesbericht', true, ['hooks.cron', 'ui.menu'])['ok'], 'Plugin lässt sich mit bestätigten Berechtigungen aktivieren');
$state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(isset(plugin_state()['loaded']['tagesbericht']), 'Plugin wird geladen');
$check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt für die Einstellungen ist registriert');
// ── Daten: zwei Abteilungen, ein Agent sieht nur seine ────────────────────────
$now = '2026-10-15 10:00:00';
$d1 = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO departments (name, email, is_active, created_at) VALUES ('Vertrieb', '', 1, ?)")->execute([$now]);
$d2 = (int) $pdo->lastInsertId();
$prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Eva Agent','eva@example.test','x','agent',?,1,?)")->execute([$d1, $now]);
$eva = (int) $pdo->lastInsertId();
$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES ('Inaktiv','weg@example.test','x','agent',?,0,?)")->execute([$d1, $now]);
$ticket = function (string $num, int $dept, string $status, ?int $assigned, string $created, ?string $due, ?string $resolved = null, int $spam = 0) use ($pdo, $prio): void {
$pdo->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, assigned_staff_id, created_at, updated_at, resolution_due_at, resolved_at, is_spam) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
->execute([$num, 'Betreff ' . $num, 'K', 'k@example.test', $dept, $prio, $status, $assigned, $created, $created, $due, $resolved, $spam]);
};
$ticket('A-1', $d1, 'open', null, '2026-10-10 08:00:00', '2026-10-12 08:00:00'); // offen, unzugewiesen, überfällig, alt
$ticket('A-2', $d1, 'open', $eva, '2026-10-15 07:00:00', '2026-10-20 08:00:00'); // offen, Eva, neu in 24 h
$ticket('A-3', $d1, 'resolved', $eva, '2026-10-14 12:00:00', null, '2026-10-15 08:00:00'); // heute erledigt
$ticket('B-1', $d2, 'open', null, '2026-10-14 11:00:00', '2026-10-13 08:00:00'); // andere Abteilung, überfällig
$ticket('S-1', $d1, 'open', null, '2026-10-14 11:00:00', '2026-10-13 08:00:00', null, 1); // Spam zählt nicht
$admin = $pdo->query("SELECT * FROM staff WHERE role = 'admin' ORDER BY id LIMIT 1")->fetch();
$evaRow = $pdo->query("SELECT * FROM staff WHERE id = {$eva}")->fetch();
$da = tagesbericht_data($pdo, $admin, $now);
$check($da['open'] === 3 && $da['unassigned'] === 2 && $da['overdue'] === 2, 'Admin sieht alle Abteilungen: ' . json_encode([$da['open'], $da['unassigned'], $da['overdue']]));
$check($da['created'] === 3 && $da['resolved'] === 1, 'Zugänge/Erledigungen der letzten 24 h: ' . json_encode([$da['created'], $da['resolved']]));
$check(count($da['oldest']) === 3 && $da['oldest'][0]['ticket_number'] === 'A-1', 'älteste offene Tickets sind sortiert, Spam ist ausgeschlossen');
$de = tagesbericht_data($pdo, $evaRow, $now);
$check($de['open'] === 2 && $de['overdue'] === 1 && $de['mine'] === 1, 'Agent sieht nur seine Abteilung: ' . json_encode([$de['open'], $de['overdue'], $de['mine']]));
$html = tagesbericht_html($evaRow, $de);
$check(str_contains($html, 'Eva') && str_contains($html, 'A-1') && !str_contains($html, 'B-1'), 'Bericht-HTML enthält nur eigene Tickets');
$check(!str_contains(tagesbericht_html(['name' => '<script>x</script>'], $de), '<script>'), 'Namen werden maskiert');
// ── Empfänger & Zeitsteuerung ────────────────────────────────────────────────
$check(count(tagesbericht_recipients($pdo, [])) === 2, 'ohne Auswahl: alle aktiven Mitarbeiter (inaktive nicht)');
$check(count(tagesbericht_recipients($pdo, [$eva])) === 1, 'Auswahl begrenzt die Empfänger');
$monday = strtotime('2026-10-12 08:30:00');
$check(tagesbericht_tick($pdo, $monday) === 0 && plugin_setting('tagesbericht', 'last_sent', '') === '', 'deaktiviert: nichts passiert');
plugin_set_setting('tagesbericht', 'enabled', '1');
plugin_set_setting('tagesbericht', 'hour', '9');
tagesbericht_tick($pdo, $monday);
$check(plugin_setting('tagesbericht', 'last_sent', '') === '', 'vor der eingestellten Uhrzeit wird nicht gesendet');
tagesbericht_tick($pdo, strtotime('2026-10-17 10:00:00')); // Samstag
$check(plugin_setting('tagesbericht', 'last_sent', '') === '', 'am Wochenende wird bei "nur Werktage" nicht gesendet');
tagesbericht_tick($pdo, strtotime('2026-10-12 09:05:00'));
$check(plugin_setting('tagesbericht', 'last_sent', '') === '2026-10-12', 'ab der Uhrzeit wird einmal gesendet und der Tag gemerkt');
$marker = plugin_setting('tagesbericht', 'last_sent');
plugin_set_setting('tagesbericht', 'last_sent', '2026-10-12');
$check(tagesbericht_tick($pdo, strtotime('2026-10-12 15:00:00')) === 0, 'am selben Tag nicht noch einmal');
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
}
@rmdir($pluginDir);
@unlink($zipPath);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Tagesbericht plugin checks passed.\n";
+246
View File
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
/**
* Prüft die Plugin- und Theme-Webseite (plugin-webseite/) gegen DeskHub selbst:
* Veröffentlichen, Prüfungen, Signatur, Katalog-Vertrag (DeskHub liest und installiert, was die Webseite liefert) und
* eine echte Webserver-Sitzung mit Anmeldung, Upload und Download.
*/
if (!class_exists('ZipArchive')) { echo "Webseiten-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); }
$tmp = sys_get_temp_dir() . '/deskhub-site-' . bin2hex(random_bytes(4));
mkdir($tmp . '/storage', 0755, true);
mkdir($tmp . '/plugins', 0755, true);
mkdir($tmp . '/themes', 0755, true);
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
$k = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k, $privPem, null, ['config' => $cnf]);
$pubPem = openssl_pkey_get_details($k)['key'];
file_put_contents($tmp . '/privat.pem', $privPem);
// Umgebung der Webseite (gilt auch für den späteren Webserver-Prozess)
putenv('PLUGIN_SITE_CONFIG=' . $tmp . '/gibtesnicht.php');
putenv('PLUGIN_SITE_STORAGE=' . $tmp . '/storage');
putenv('PLUGIN_SITE_BASE_URL=https://plugins.example.test');
putenv('PLUGIN_SITE_SIGNING_KEY=' . $tmp . '/privat.pem');
putenv('PLUGIN_SITE_ADMIN_HASH=' . password_hash('Sehr-Sicheres-Passwort-1', PASSWORD_DEFAULT));
$accessKey = bin2hex(random_bytes(24));
putenv('PLUGIN_SITE_ACCESS_KEY=' . $accessKey);
// Umgebung von DeskHub
putenv('DESKHUB_PLUGINS_DIR=' . $tmp . '/plugins');
putenv('DESKHUB_THEMES_DIR=' . $tmp . '/themes');
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once __DIR__ . '/fixtures/paths.php';
$siteDir = website_dir_or_skip('plugin_website');
require_once $siteDir . '/inc/lib.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$makeZip = function (array $files): string {
$p = tempnam(sys_get_temp_dir(), 'pw') . '.zip';
$z = new ZipArchive();
$z->open($p, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($files as $n => $c) $z->addFromString($n, $c);
$z->close();
return $p;
};
$dirZip = function (string $dir) use ($makeZip): string {
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $f) {
$files[str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = file_get_contents($f->getPathname());
}
return $makeZip($files);
};
$themeZip = fn(string $version = '1.0.0') => $makeZip(['theme.css' => "/*\n * Author: Studio\n * Version: {$version}\n */\n:root { --primary: #0a5; }", 'img/bg.png' => 'x']);
// ── Veröffentlichen ──────────────────────────────────────────────────────────
$check(site_public_key() === $pubPem, 'öffentlicher Schlüssel wird aus dem privaten abgeleitet');
$plugin = $dirZip(plugin_source_dir_or_skip('tagesbericht', 'plugin_website'));
$r = site_publish('plugin', $plugin);
$check($r['ok'] && $r['slug'] === 'tagesbericht' && $r['signed'] === true, 'Plugin wird veröffentlicht und signiert: ' . json_encode($r));
$check(!site_publish('plugin', $plugin)['ok'], 'dieselbe Version kann nicht erneut veröffentlicht werden');
$r = site_publish('theme', $themeZip(), ['slug' => 'nordlicht', 'name' => 'Nordlicht', 'description' => 'Hell und ruhig']);
$check($r['ok'] && $r['version'] === '1.0.0', 'Theme wird veröffentlicht, Version stammt aus dem CSS-Kopf: ' . json_encode($r));
$check(site_publish('theme', $themeZip('1.1.0'), ['slug' => 'nordlicht', 'name' => 'Nordlicht'])['ok'], 'zweite Theme-Version wird angenommen');
$bad = site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"x1","name":"X","version":"1.0.0","permissions":["alles"]}', 'plugin.php' => '<?php']));
$check(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"core","name":"X","version":"1.0.0"}', 'plugin.php' => '<?php']))['ok'], 'reservierter Name wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"evil","name":"X","version":"1.0.0"}', '../evil.php' => '<?php']))['ok'], 'Zip-Slip wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['plugin.json' => '{"slug":"exe1","name":"X","version":"1.0.0"}', 'a.exe' => 'MZ']))['ok'], 'unzulässiger Dateityp im Plugin wird abgelehnt');
$check(!site_publish('plugin', $makeZip(['a.php' => '<?php']))['ok'], 'Plugin ohne plugin.json wird abgelehnt');
$check(!site_publish('theme', $makeZip(['theme.css' => 'a{}', 'x.php' => '<?php echo 1;']), ['slug' => 'mitphp', 'name' => 'X', 'version' => '1.0.0'])['ok'], 'PHP-Datei in einem Theme wird abgelehnt');
$check(!site_publish('theme', $themeZip(), ['slug' => 'Ungültig!', 'name' => 'X'])['ok'], 'ungültige Theme-Kennung wird abgelehnt');
$check(!site_publish('theme', $makeZip(['theme.css' => 'a{}']), ['slug' => 'ohnever', 'name' => 'X'])['ok'], 'Theme ohne Versionsangabe wird abgelehnt');
$check(!site_publish('sonstiges', $plugin)['ok'], 'unbekannter Typ wird abgelehnt');
// ── Katalog-Vertrag: DeskHub liest und installiert, was die Webseite liefert ─────
$pluginCatalog = site_catalog('plugin');
$themeCatalog = site_catalog('theme');
$check(count($pluginCatalog['plugins']) === 1 && str_starts_with($pluginCatalog['plugins'][0]['downloadUrl'], 'https://plugins.example.test/download.php'), 'Plugin-Katalog enthält https-Download-Adresse');
$check(count($themeCatalog['themes']) === 1 && $themeCatalog['themes'][0]['version'] === '1.1.0', 'Theme-Katalog enthält nur die neueste Version');
$files = [];
foreach (['plugin' => $pluginCatalog['plugins'], 'theme' => $themeCatalog['themes']] as $t => $list) {
foreach ($list as $e) {
$v = site_item($t, $e['slug'])['versions'][0];
$files[$e['downloadUrl']] = file_get_contents(site_storage() . '/packages/' . $v['file']);
}
}
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use ($pluginCatalog, $themeCatalog, $files): array {
if (str_contains($url, 'type=plugins')) return [200, json_encode($pluginCatalog), null];
if (str_contains($url, 'type=themes')) return [200, json_encode($themeCatalog), null];
return isset($files[$url]) ? [200, $files[$url], null] : [404, null, 'HTTP 404'];
};
$pdo = get_db();
set_setting('plugin_catalog_url', 'https://plugins.example.test/catalog.php?type=plugins');
set_setting('theme_catalog_url', 'https://plugins.example.test/catalog.php?type=themes');
set_setting('plugin_publisher_key', $pubPem);
set_setting('plugin_require_signature', '1');
$cat = plugin_catalog_fetch();
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['permissions'] === ['hooks.cron', 'ui.menu'], 'DeskHub liest den Plugin-Katalog der Webseite');
$r = plugin_catalog_install($pdo, 'tagesbericht');
$check($r['ok'] && $r['signed'] === true, 'DeskHub installiert das Plugin mit geprüfter Signatur („Signatur erforderlich“ an): ' . json_encode($r));
$check(is_file($tmp . '/plugins/tagesbericht/plugin.php'), 'Plugin-Dateien liegen im Plugin-Ordner');
$r = plugin_set_active($pdo, 'tagesbericht', true, ['hooks.cron', 'ui.menu']);
$check($r['ok'], 'Plugin aus dem Katalog lässt sich mit bestätigten Berechtigungen aktivieren');
$tc = theme_catalog_fetch();
$check($tc['ok'] && count($tc['themes']) === 1, 'DeskHub liest den Theme-Katalog der Webseite');
$r = theme_catalog_install('nordlicht');
$check($r['ok'] && $r['signed'] === true && count(list_custom_themes()) === 1, 'DeskHub installiert das Theme mit geprüfter Signatur: ' . json_encode($r));
unset($GLOBALS['deskhub_http_get']);
// ── Webserver: öffentliche Seiten, Anmeldung, Upload, Download ───────────────
$port = random_int(20000, 20999);
$root = $siteDir;
$logFile = tempnam(sys_get_temp_dir(), 'pwlog');
$proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $root], [['pipe', 'r'], ['file', $logFile, 'w'], ['file', $logFile, 'a']], $pipes, $root);
$http = function (string $method, string $path, array $opts = []) use ($port): array {
$ch = curl_init("http://127.0.0.1:$port$path");
// DeskHub schickt den Zugangsschlüssel immer mit; $opts['key'] = false lässt ihn weg, eine Zeichenkette setzt einen anderen
$keyHeader = array_key_exists('key', $opts) ? $opts['key'] : $GLOBALS['accessKey'];
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $keyHeader === false ? [] : ['X-DeskHub-Key: ' . $keyHeader],
CURLOPT_COOKIEJAR => $opts['jar'] ?? '', CURLOPT_COOKIEFILE => $opts['jar'] ?? '']);
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $opts['data'] ?? []); }
$raw = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return [$status, substr($raw, $hs), substr($raw, 0, $hs)];
};
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $port)) break; usleep(100000); }
// Keine öffentlichen Seiten: Startseite führt zur Verwaltung, Übersichts-, Detail- und Hilfeseite gibt es nicht
[$s, , $h] = $http('GET', '/index.php?type=plugin');
$check($s === 302 && stripos($h, 'Location: admin.php') !== false, 'die Startseite leitet zur Verwaltung weiter');
[$s] = $http('GET', '/item.php?type=plugin&slug=tagesbericht');
$check($s === 404, 'es gibt keine öffentliche Detailseite mehr');
[$s] = $http('GET', '/hilfe.php');
$check($s === 404, 'die Hilfeseite „So geht’s“ gibt es nicht mehr');
// Zugangsschutz: ohne (oder mit falschem) Schlüssel liefert die Webseite nichts aus
foreach (['/catalog.php?type=plugins', '/catalog.php?type=themes', '/publisher-key.php', '/preview.php?file=x.png', '/download.php?type=plugin&slug=tagesbericht'] as $guarded) {
[$s1, $b1] = $http('GET', $guarded, ['key' => false]);
[$s2] = $http('GET', $guarded, ['key' => 'falscher-schluessel']);
$check($s1 === 401 && $s2 === 401 && !str_contains($b1, 'tagesbericht') && !str_contains($b1, 'BEGIN PUBLIC KEY'), "ohne gültigen Zugangsschlüssel gibt {$guarded} nichts heraus (401)");
}
[$s, $body] = $http('GET', '/health.php', ['key' => false]);
$check($s === 200 && $body === 'ok', 'der Healthcheck antwortet ohne Schlüssel und gibt nichts preis');
[$s] = $http('GET', '/admin.php', ['key' => false]);
$check($s === 200, 'die Verwaltung bleibt über ihre Anmeldung erreichbar (Passwortschutz statt Zugangsschlüssel)');
[$s, $body] = $http('GET', '/publisher-key.php');
$check($s === 200 && trim($body) === trim($pubPem), 'öffentlicher Schlüssel ist abrufbar');
[$s, $body, $h] = $http('GET', '/catalog.php?type=plugins');
$json = json_decode($body, true);
$check($s === 200 && stripos($h, 'application/json') !== false && ($json['plugins'][0]['slug'] ?? '') === 'tagesbericht', 'catalog.php liefert JSON');
$entry = $json['plugins'][0];
$path = substr($entry['downloadUrl'], strlen('https://plugins.example.test'));
[$s, $body] = $http('GET', $path);
$check($s === 200 && hash('sha256', $body) === $entry['sha256'], 'Download liefert exakt das Paket mit der angegebenen Prüfsumme');
[$s] = $http('GET', '/download.php?type=plugin&slug=tagesbericht&version=9.9.9');
$check($s === 404, 'unbekannte Version liefert 404');
[$s] = $http('GET', '/download.php?type=plugin&slug=../../etc/passwd');
$check($s === 404, 'Pfadtricks im Download werden abgewiesen');
[$s] = $http('GET', '/preview.php?file=../katalog.sqlite');
$check($s === 404, 'Vorschau liefert nur Bilder');
$dl = (int) site_db()->query("SELECT downloads FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'tagesbericht'")->fetchColumn();
$check($dl >= 1, 'Downloads werden gezählt');
// Verwaltung
$jar = tempnam(sys_get_temp_dir(), 'jar');
[$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]);
$check($s === 200 && str_contains($body, 'name="password"') && !str_contains($body, 'Neues Paket veröffentlichen'), 'Verwaltung zeigt ohne Anmeldung nur das Login');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'login', 'user' => 'admin', 'password' => 'falsch']]);
$check(str_contains($body, 'falsch') && !str_contains($body, 'Neues Paket veröffentlichen'), 'falsches Passwort wird abgewiesen');
[$s] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'plugin']]);
$check($s === 403, 'Veröffentlichen ohne Anmeldung ist gesperrt');
[$s] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'login', 'user' => 'admin', 'password' => 'Sehr-Sicheres-Passwort-1']]);
$check($s === 302, 'richtiges Passwort meldet an');
[$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]);
$check($s === 200 && str_contains($body, 'Neues Paket veröffentlichen') && str_contains($body, 'Signierschlüssel eingerichtet'), 'Verwaltung nach der Anmeldung');
preg_match('/name="csrf" value="([a-f0-9]+)"/', $body, $m);
$csrf = $m[1] ?? '';
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'theme', 'csrf' => 'falsch', 'package' => new CURLFile($themeZip('2.0.0'), 'application/zip', 'x.zip')]]);
$check($s === 403, 'falsches CSRF-Token wird abgewiesen');
$up = $themeZip('2.0.0');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'theme', 'csrf' => $csrf, 'slug' => 'abendrot', 'name' => 'Abendrot', 'description' => 'Warm', 'package' => new CURLFile($up, 'application/zip', 'abendrot.zip')]]);
$check($s === 200 && str_contains($body, 'Veröffentlicht: abendrot 2.0.0'), 'Upload über die Verwaltung veröffentlicht ein Theme');
[$s, $body] = $http('GET', '/catalog.php?type=themes');
$slugs = array_column(json_decode($body, true)['themes'] ?? [], 'slug');
$check(in_array('abendrot', $slugs, true) && in_array('nordlicht', $slugs, true), 'neues Theme erscheint im Katalog');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'publish', 'type' => 'plugin', 'csrf' => $csrf, 'package' => new CURLFile($makeZip(['nur.txt' => 'x']), 'application/zip', 'k.zip')]]);
$check(str_contains($body, 'plugin.json wurde im Paket nicht gefunden'), 'ungültiges Paket zeigt eine verständliche Meldung');
// Löschen über die Verwaltung: Version entfernen, dann verschwindet auch der Eintrag samt Paketdatei und Katalogzeile
[$s, $body] = $http('GET', '/admin.php', ['jar' => $jar]);
$check(preg_match_all('/<form method="post" data-confirm="[^"]*"><input type="hidden" name="action" value="delete"><input type="hidden" name="csrf" value="[a-f0-9]+"><input type="hidden" name="id" value="(\d+)">/', $body, $dm) >= 1, 'die Verwaltung zeigt Entfernen-Schaltflächen');
$abId = (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn();
$abFile = (string) site_db()->query("SELECT v.file FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn();
$check($abId > 0 && is_file($tmp . '/storage/packages/' . $abFile), 'Paketdatei liegt vor dem Löschen im Speicher');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => 'falsch', 'id' => $abId]]);
$check($s === 403 && $abId === (int) site_db()->query("SELECT v.id FROM versions v JOIN items i ON i.id = v.item_id WHERE i.slug = 'abendrot'")->fetchColumn(), 'Löschen mit falschem CSRF-Token ändert nichts');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => $abId]]);
$check($s === 200 && str_contains($body, 'Version entfernt.'), 'Löschen meldet Erfolg: HTTP ' . $s . ' ' . substr(strip_tags($body), 0, 200));
$check((int) site_db()->query("SELECT COUNT(*) FROM items WHERE slug = 'abendrot'")->fetchColumn() === 0, 'der Eintrag ist nach dem Löschen aus der Datenbank entfernt');
$deletedPackage = $tmp . '/storage/packages/' . $abFile; // die Datei selbst wird erst nach dem Beenden des Testservers geprüft (Windows hält sie bis dahin offen)
[$s, $body] = $http('GET', '/catalog.php?type=themes');
$check(!in_array('abendrot', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true) && in_array('nordlicht', array_column(json_decode($body, true)['themes'] ?? [], 'slug'), true), 'gelöschtes Theme ist aus dem Katalog, andere bleiben');
[$s, $body] = $http('POST', '/admin.php', ['jar' => $jar, 'data' => ['action' => 'delete', 'csrf' => $csrf, 'id' => 999999]]);
$check(str_contains($body, 'Version nicht gefunden'), 'unbekannte Version liefert eine verständliche Meldung');
@unlink($jar);
} finally {
proc_terminate($proc);
proc_close($proc);
}
usleep(500000);
clearstatcache();
$check(isset($deletedPackage) && !is_file($deletedPackage), 'die Paketdatei ist nach dem Löschen entfernt');
// Aufräumen
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($tmp);
@unlink($cnf);
@unlink($logFile);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Plugin website checks passed ({$total} Prüfungen).
";
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* Prüft die fest eingebauten Zugangsdaten der Docker-Variante der Plugin-Webseite, soweit ohne laufenden Docker-Daemon möglich:
* Das Bake-Skript (läuft im Docker-Build) schreibt eine gesperrte config.php, die Webseite ignoriert dann jede Umgebungsvariable
* und andere Konfigurationsdatei, und die Docker-Dateien enthalten die zugesagten Schutzmaßnahmen.
*/
require_once __DIR__ . '/fixtures/paths.php';
$site = website_dir_or_skip('plugin_website_docker');
$tmp = sys_get_temp_dir() . '/dh-bake-' . bin2hex(random_bytes(4));
mkdir($tmp . '/secrets', 0755, true);
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
$k = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k, $privPem, null, ['config' => $cnf]);
$pubPem = openssl_pkey_get_details($k)['key'];
$hash = password_hash('Ein-Sehr-Langes-Passwort-1', PASSWORD_DEFAULT);
$bake = function (array $env) use ($site, $tmp): array {
$cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($site . '/docker-bake.php') . ' 2>&1';
$full = $env + ['BAKE_SECRETS_DIR' => $tmp . '/secrets', 'BAKE_KEY_DIR' => $tmp . '/secure', 'BAKE_CONFIG_PATH' => $tmp . '/config.php', 'BAKE_STORAGE_DIR' => $tmp . '/data'];
$ph = proc_open($cmd, [1 => ['pipe', 'w']], $pipes, null, $full + getenv());
$out = stream_get_contents($pipes[1]);
return [proc_close($ph), $out];
};
// Fehlerhafte Eingaben werden abgelehnt
file_put_contents($tmp . '/secrets/admin_hash', 'kein-hash');
file_put_contents($tmp . '/secrets/catalog_key', 'abcdef0123456789abcdef0123456789abcdef0123456789');
file_put_contents($tmp . '/secrets/signing_key', $privPem);
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'Passwort-Hash'), 'Klartext statt Passwort-Hash wird abgelehnt');
file_put_contents($tmp . '/secrets/admin_hash', $hash);
[$code, $out] = $bake(['BASE_URL' => 'http://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'https://'), 'http-Adresse wird abgelehnt');
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'a b']);
$check($code !== 0 && str_contains($out, 'ADMIN_USER'), 'ungültiger Benutzername wird abgelehnt');
file_put_contents($tmp . '/secrets/signing_key', 'kein schlüssel');
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'Schlüssel'), 'ungültiger Signierschlüssel wird abgelehnt');
$check(!file_exists($tmp . '/config.php'), 'bei Fehlern wird keine config.php geschrieben');
file_put_contents($tmp . '/secrets/signing_key', $privPem);
foreach (['' => 'fehlender', 'kurz' => 'zu kurzer', 'mit leerzeichen und sonderzeichen!!mit leerzeichen und sonderzeichen' => 'ungültiger'] as $badKey => $label) {
file_put_contents($tmp . '/secrets/catalog_key', $badKey);
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']);
$check($code !== 0 && str_contains($out, 'catalog_key'), "{$label} Zugangsschlüssel für den Katalog wird abgelehnt");
}
file_put_contents($tmp . '/secrets/catalog_key', 'abcdef0123456789abcdef0123456789abcdef0123456789');
// Erfolgsfall
file_put_contents($tmp . '/secrets/signing_key', $privPem);
[$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de/', 'ADMIN_USER' => 'betreiber', 'SITE_NAME' => 'Mein Katalog']);
$check($code === 0 && str_contains($out, 'BEGIN PUBLIC KEY') && !str_contains($out, 'BEGIN PRIVATE KEY') && !str_contains($out, $hash), 'Build gelingt und gibt nur den öffentlichen Schlüssel aus');
$cfg = is_file($tmp . '/config.php') ? (array) require $tmp . '/config.php' : [];
$check(($cfg['locked'] ?? false) === true && $cfg['admin_user'] === 'betreiber' && $cfg['admin_password_hash'] === $hash && $cfg['base_url'] === 'https://plugins.example.de' && $cfg['site_name'] === 'Mein Katalog', 'config.php enthält die fest eingebauten Werte und ist gesperrt');
$check(($cfg['access_key'] ?? '') === 'abcdef0123456789abcdef0123456789abcdef0123456789' && !str_contains($out, 'abcdef0123456789abcdef'), 'Zugangsschlüssel des Katalogs wird fest eingebaut und nicht ausgegeben');
$check(is_file($tmp . '/secure/herausgeber-privat.pem') && trim(file_get_contents($tmp . '/secure/herausgeber-privat.pem')) === trim($privPem) && $cfg['signing_key_file'] === $tmp . '/secure/herausgeber-privat.pem', 'privater Schlüssel liegt im geschützten Ordner');
// Gesperrte Konfiguration: Umgebungsvariablen und fremde Konfigurationsdateien haben keine Wirkung
$siteCopy = $tmp . '/site';
mkdir($siteCopy . '/inc', 0755, true);
copy($site . '/inc/lib.php', $siteCopy . '/inc/lib.php');
copy($tmp . '/config.php', $siteCopy . '/config.php');
$evil = $tmp . '/evil.php';
file_put_contents($evil, "<?php return ['admin_user' => 'angreifer', 'admin_password_hash' => " . var_export(password_hash('x', PASSWORD_DEFAULT), true) . ", 'locked' => false, 'storage_dir' => '/tmp/boese'];");
$probe = $tmp . '/probe.php';
file_put_contents($probe, "<?php require " . var_export($siteCopy . '/inc/lib.php', true) . "; echo json_encode(site_config());");
$run = function (array $env) use ($probe): array {
$ph = proc_open([PHP_BINARY, $probe], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env + getenv());
$out = stream_get_contents($pipes[1]);
proc_close($ph);
return json_decode($out, true) ?: [];
};
$c = $run(['PLUGIN_SITE_ADMIN_HASH' => password_hash('angriff', PASSWORD_DEFAULT), 'PLUGIN_SITE_STORAGE' => '/tmp/boese', 'PLUGIN_SITE_SIGNING_KEY' => '/tmp/fremd.pem', 'PLUGIN_SITE_BASE_URL' => 'https://boese.example', 'PLUGIN_SITE_CONFIG' => $evil]);
$check($c['admin_password_hash'] === $hash && $c['admin_user'] === 'betreiber' && $c['storage_dir'] === $tmp . '/data' && $c['base_url'] === 'https://plugins.example.de' && $c['signing_key_file'] === $tmp . '/secure/herausgeber-privat.pem', 'im gesperrten Zustand ändern Umgebungsvariablen und fremde Konfigurationsdatei nichts');
// Gegenprobe: ohne Sperre würden die Umgebungsvariablen wirken (die lokale Entwicklungs-/Testvariante)
$cfgOpen = $cfg; $cfgOpen['locked'] = false;
file_put_contents($siteCopy . '/config.php', "<?php return " . var_export($cfgOpen, true) . ';');
$c = $run(['PLUGIN_SITE_ADMIN_HASH' => 'ueberschrieben']);
$check($c['admin_password_hash'] === 'ueberschrieben', 'Gegenprobe: ohne Sperre wirken Umgebungsvariablen weiterhin (Entwicklung)');
// Docker-Dateien enthalten die zugesagten Schutzmaßnahmen
$df = (string) file_get_contents($site . '/Dockerfile');
$check(str_contains($df, '--mount=type=secret,id=admin_hash') && str_contains($df, '--mount=type=secret,id=signing_key') && str_contains($df, '--mount=type=secret,id=catalog_key'), 'Dockerfile übergibt Hash, Signierschlüssel und Zugangsschlüssel als BuildKit-Secrets');
$check(str_contains($df, 'health.php'), 'der Healthcheck braucht keinen Zugangsschlüssel');
$check(!preg_match('/ARG\s+(ADMIN_PASSWORD|PASSWORD|SIGNING_KEY|ADMIN_HASH)/i', $df), 'Dockerfile nimmt keine Passwörter oder Schlüssel als normale Build-Argumente');
$check(str_contains($df, 'chmod 0440 /var/www/html/config.php') && str_contains($df, 'chown -R root:root /var/www/html'), 'config.php ist im Image schreibgeschützt');
$check(str_contains($df, 'VOLUME ["/data"]'), 'nur /data ist als Volume vorgesehen');
$dc = (string) file_get_contents($site . '/docker-compose.yml');
$check(str_contains($dc, 'read_only: true') && str_contains($dc, 'no-new-privileges') && str_contains($dc, 'cap_drop'), 'Compose-Datei härtet den Container');
$check(!preg_match('/PLUGIN_SITE_|ADMIN|PASSWORD/i', preg_replace('/#.*$/m', '', $dc)), 'Compose-Datei enthält keine Zugangsdaten oder Überschreibungsmöglichkeiten');
$ps = (string) file_get_contents($site . '/docker-build.ps1');
$check(str_contains($ps, '--secret') || str_contains($ps, '`--secret') || str_contains($ps, '--secret "id=admin_hash'), 'Build-Skript nutzt Secrets');
$ignore = (string) file_get_contents($site . '/.dockerignore');
$check(str_contains($ignore, '*.pem') && str_contains($ignore, 'config.php') && str_contains($ignore, 'storage'), '.dockerignore hält lokale Schlüssel und Daten aus dem Image');
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($tmp);
@unlink($cnf);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Docker website checks passed ({$total} Prüfungen).\n";
+373
View File
@@ -0,0 +1,373 @@
<?php
declare(strict_types=1);
/**
* Prüft das Plugin-System (includes/plugins.php): Installation, Sicherheitsprüfungen, Berechtigungen (Erweiterungspunkte),
* Signaturen, Katalog, Aktivierung mit Migration, Hooks/Filter/Menü/API, Fehler-Isolation, Entfernen und Notausschalter.
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-plugins-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$reboot = function () {
$state = &plugin_state();
$state['booted'] = false; $state['hooks'] = []; $state['filters'] = []; $state['menus'] = []; $state['loaded'] = []; $state['api'] = []; $state['perms'] = [];
plugins_boot();
};
$makeZip = function (array $files): string {
$path = tempnam(sys_get_temp_dir(), 'dhp') . '.zip';
$zip = new ZipArchive();
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($files as $name => $content) $zip->addFromString($name, $content);
$zip->close();
return $path;
};
$dirZip = function (string $dir) use ($makeZip): string {
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)) as $f) {
$files['beispiel-paket/' . str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1))] = file_get_contents($f->getPathname());
}
return $makeZip($files);
};
$manifest = fn(array $o = []) => json_encode($o + ['slug' => 'kaputt', 'name' => 'Kaputt', 'version' => '1.0.0']);
$activate = fn(string $slug) => plugin_set_active($pdo, $slug, true, plugin_read_manifest(plugins_dir() . '/' . $slug)[0]['permissions'] ?? []);
// RSA-Schlüssel für die Signaturtests (Windows-PHP braucht eine minimale openssl.cnf)
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
$keyOpts = ['config' => $cnf];
$newKey = function () use ($keyOpts) {
$k = openssl_pkey_new($keyOpts + ['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k, $priv, null, $keyOpts);
return [$priv, openssl_pkey_get_details($k)['key']];
};
[$privKey, $pubKey] = $newKey();
[$otherPriv, $otherPub] = $newKey();
$sign = function (string $zipPath, string $slug, string $version, string $priv): string {
openssl_sign(plugin_signature_message($slug, $version, hash_file('sha256', $zipPath)), $sig, $priv, OPENSSL_ALGO_SHA256);
return base64_encode($sig);
};
// ── Installation des Beispiel-Plugins ────────────────────────────────────────
require_once __DIR__ . '/fixtures/paths.php';
$zip = $dirZip(plugin_source_dir_or_skip('plugin-beispiel', 'plugins'));
$hash = hash_file('sha256', $zip);
$r = plugin_install_zip($pdo, $zip, str_repeat('0', 64));
$check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'falsche Prüfsumme wird abgelehnt');
$r = plugin_install_zip($pdo, $zip, $hash);
$check($r['ok'] && $r['slug'] === 'beispiel' && $r['version'] === '1.0.0' && $r['signed'] === false, 'Beispiel-Plugin wird installiert: ' . json_encode($r));
$check(is_file($pluginDir . '/beispiel/plugin.php'), 'Dateien liegen im Plugin-Ordner');
$check((int) $pdo->query("SELECT is_active FROM plugins WHERE slug='beispiel'")->fetchColumn() === 0, 'Plugin ist nach der Installation inaktiv');
$reboot();
$check(!isset(plugin_state()['loaded']['beispiel']), 'inaktives Plugin wird nicht geladen');
// ── Aktivierung: Berechtigungen bestätigen, Migration, Hooks, Filter, Menü, API ──
$r = plugin_set_active($pdo, 'beispiel', true);
$check(!$r['ok'] && !empty($r['needs_confirmation']), 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt');
$r = plugin_set_active($pdo, 'beispiel', true, ['hooks.tickets']);
$check(!$r['ok'], 'unvollständige Bestätigung genügt nicht');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE 'plugin_beispiel%'")->fetchColumn() === 0, 'ohne Bestätigung laufen keine Migrationen');
$r = $activate('beispiel');
$check($r['ok'], 'Aktivierung mit Bestätigung gelingt: ' . json_encode($r));
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'plugin_beispiel_001_log.sqlite.sql'")->fetchColumn() === 1, 'Plugin-Migration wird protokolliert');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration LIKE '%mysql%'")->fetchColumn() === 0, 'MySQL-Migration läuft nicht auf SQLite');
$check(plugin_run_migrations($pdo, 'beispiel')['applied'] === 0, 'Migrationen laufen nur einmal');
$check(str_contains((string) $pdo->query("SELECT approved_permissions FROM plugins WHERE slug='beispiel'")->fetchColumn(), 'hooks.tickets'), 'bestätigte Berechtigungen werden gespeichert');
$reboot();
$check(isset(plugin_state()['loaded']['beispiel']), 'aktives Plugin wird geladen');
webhook_dispatch($pdo, 'ticket.created', 42);
webhook_dispatch($pdo, 'ticket.status_changed', 42, ['previousStatus' => 'open']);
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log WHERE ticket_id = 42')->fetchColumn() === 2, 'Ticket-Ereignisse erreichen das Plugin');
$html = plugin_filter('ticket.view.sidebar', '', ['ticketId' => 42]);
$check(str_contains($html, '2 Ereignis'), 'Filter liefert Seitenleisten-Baustein');
$menu = plugin_menu_items(['id' => 1, 'role' => 'admin']);
$check(count($menu) === 1 && $menu[0]['label'] === 'Ticket-Protokoll' && str_contains($menu[0]['url'], 'p=beispiel'), 'Menüpunkt wird registriert');
$api = plugin_state()['api']['beispiel']['count']['fn'] ?? null;
$check($api !== null && $api([], [])['count'] === 2, 'API-Endpunkt liefert Daten');
plugin_hook('cron.run');
$check(plugin_setting('beispiel', 'last_cron') !== null, 'cron.run-Hook läuft');
// ── Berechtigungen werden bei der Registrierung durchgesetzt ─────────────────
$limited = $makeZip([
'plugin.json' => $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'permissions' => ['ui.menu']]),
'plugin.php' => "<?php plugin_add_hook('ticket.created', function () { get_db()->exec(\"UPDATE settings SET setting_value = 'x' WHERE setting_key = 'nie'\"); });\n"
. "plugin_register_menu('Erlaubt', 'seite');\nplugin_register_api('verboten', fn() => ['x' => 1]);\nplugin_add_filter('ticket.view.sidebar', fn(\$h) => \$h . 'NICHT');\nplugin_add_hook('cron.run', fn() => null);",
]);
$check(plugin_install_zip($pdo, $limited)['ok'], 'Plugin mit begrenzten Rechten installiert');
$activate('begrenzt');
$reboot();
$check(in_array('begrenzt', array_column(plugin_state()['menus'], 'plugin'), true), 'erlaubter Erweiterungspunkt (Menü) wird registriert');
$check(!isset(plugin_state()['api']['begrenzt']), 'nicht erlaubte API-Registrierung wird abgelehnt');
$check(!str_contains(plugin_filter('ticket.view.sidebar', '', ['ticketId' => 1]), 'NICHT'), 'nicht erlaubter Seitenleisten-Filter wird abgelehnt');
$check(!isset(plugin_state()['hooks']['ticket.created']) || !array_filter(plugin_state()['hooks']['ticket.created'][10] ?? [], fn($h) => $h['plugin'] === 'begrenzt'), 'nicht erlaubter Ticket-Hook wird abgelehnt');
$note = (string) $pdo->query("SELECT last_error FROM plugins WHERE slug='begrenzt'")->fetchColumn();
$check(str_contains($note, 'Nicht deklarierte Berechtigung'), 'abgelehnte Registrierung ist im Plugin vermerkt');
$check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0 && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 1, 'abgelehnte Registrierung deaktiviert das Plugin nicht');
$bad = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'unbekannt', 'permissions' => ['alles.lesen']]), 'plugin.php' => '<?php']));
$check(!$bad['ok'] && str_contains($bad['error'], 'unbekannte Berechtigung'), 'unbekannte Berechtigung im Manifest wird abgelehnt');
$nomig = $makeZip(['plugin.json' => $manifest(['slug' => 'ohnedb', 'permissions' => []]), 'plugin.php' => '<?php', 'migrations/001.sql' => 'CREATE TABLE x (id INT);']);
plugin_install_zip($pdo, $nomig);
$r = plugin_set_active($pdo, 'ohnedb', true, []);
$check(!$r['ok'] && str_contains($r['error'], 'db.migrations'), 'Migrationen ohne Berechtigung db.migrations werden abgelehnt');
// Update mit zusätzlichen Berechtigungen: aktives Plugin wird angehalten, bis der Admin bestätigt
$upd = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'version' => '1.1.0', 'permissions' => ['ui.menu', 'hooks.tickets']]), 'plugin.php' => "<?php plugin_register_menu('Erlaubt', 'seite');"]));
$check($upd['ok'] && $upd['needs_reapproval'] === ['hooks.tickets'], 'Update mit neuen Berechtigungen wird erkannt');
$check((int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0, 'Plugin mit neuen Berechtigungen wird bis zur Bestätigung angehalten');
$r = plugin_set_active($pdo, 'begrenzt', true, ['ui.menu']);
$check(!$r['ok'], 'erneute Aktivierung verlangt die neuen Berechtigungen');
$check($activate('begrenzt')['ok'], 'nach Bestätigung läuft das Plugin wieder');
// Dateien von Hand ausgetauscht (FTP): Boot verweigert das Laden, solange die Rechte nicht bestätigt sind
file_put_contents($pluginDir . '/begrenzt/plugin.json', $manifest(['slug' => 'begrenzt', 'name' => 'Begrenzt', 'version' => '1.2.0', 'permissions' => ['ui.menu', 'hooks.tickets', 'hooks.cron']]));
$reboot();
$check(!isset(plugin_state()['loaded']['begrenzt']) && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 0, 'per FTP ausgetauschte Dateien mit neuen Rechten werden nicht geladen');
// ── Fehler-Isolation ─────────────────────────────────────────────────────────
$bad = $makeZip(['plugin.json' => $manifest(['permissions' => ['hooks.tickets']]), 'plugin.php' => "<?php plugin_add_hook('ticket.created', function () { throw new RuntimeException('Absichtlicher Testfehler'); });"]);
$r = plugin_install_zip($pdo, $bad);
$check($r['ok'], 'fehlerhaftes Test-Plugin installiert');
$activate('kaputt');
$reboot();
$logBefore = (int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn();
for ($i = 0; $i < 3; $i++) webhook_dispatch($pdo, 'ticket.created', 7);
$check((int) $pdo->query('SELECT COUNT(*) FROM plugin_beispiel_log')->fetchColumn() === $logBefore + 3, 'ein fehlerhaftes Plugin stört andere Plugins nicht');
$row = $pdo->query("SELECT is_active, fail_count, last_error FROM plugins WHERE slug='kaputt'")->fetch();
$check((int) $row['is_active'] === 0 && (int) $row['fail_count'] >= PLUGIN_FAIL_LIMIT, 'fehlerhaftes Plugin wird nach ' . PLUGIN_FAIL_LIMIT . ' Fehlern deaktiviert');
$check(str_contains((string) $row['last_error'], 'Absichtlicher Testfehler'), 'Fehlertext wird gespeichert');
plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'syntax']), 'plugin.php' => '<?php this is not php {{{']));
$activate('syntax');
$reboot();
$check((int) $pdo->query("SELECT fail_count FROM plugins WHERE slug='syntax'")->fetchColumn() >= 1, 'Syntaxfehler beim Laden wird abgefangen');
// ── Sicherheitsprüfungen beim Installieren ───────────────────────────────────
$evil = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'evil']), 'plugin.php' => '<?php', '../evil.php' => '<?php']));
$check(!$evil['ok'], 'Zip-Slip (../) wird abgelehnt');
$check(!file_exists(dirname($pluginDir) . '/evil.php'), 'außerhalb des Plugin-Ordners wird nichts geschrieben');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'exe']), 'plugin.php' => '<?php', 'tool.exe' => 'MZ']));
$check(!$r['ok'] && str_contains($r['error'], 'Unzulässige Datei'), 'unzulässige Dateitypen werden abgelehnt');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'hid']), 'plugin.php' => '<?php', '.htaccess' => 'x']));
$check(!$r['ok'], 'versteckte Dateien (.htaccess) werden abgelehnt');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'core']), 'plugin.php' => '<?php']));
$check(!$r['ok'] && str_contains($r['error'], 'reserviert'), 'reservierte Namen werden abgelehnt');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => '../x']), 'plugin.php' => '<?php']));
$check(!$r['ok'], 'ungültiger Slug wird abgelehnt');
$r = plugin_install_zip($pdo, $makeZip(['plugin.php' => '<?php']));
$check(!$r['ok'], 'Paket ohne plugin.json wird abgelehnt');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'neu', 'requires' => '99.0.0']), 'plugin.php' => '<?php']));
$check(!$r['ok'] && str_contains($r['error'], 'Benötigt DeskHub'), 'zu hohe Mindestversion wird abgelehnt');
$check(!is_dir($pluginDir . '/neu') && !glob($pluginDir . '/.tmp_*'), 'abgelehnte Pakete hinterlassen keine Reste');
// ── Signaturen ───────────────────────────────────────────────────────────────
$pkg = $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '2.0.0']), 'plugin.php' => '<?php']);
$sig = $sign($pkg, 'signiert', '2.0.0', $privKey);
$check(plugin_verify_signature(plugin_signature_message('signiert', '2.0.0', hash_file('sha256', $pkg)), $sig, $pubKey), 'gültige Signatur wird erkannt');
$check(!plugin_verify_signature('anderes', $sig, $pubKey) && !plugin_verify_signature('x', 'kein-base64!!', $pubKey) && !plugin_verify_signature('x', $sig, 'kein schlüssel'), 'ungültige Eingaben werden nicht akzeptiert');
$check(plugin_public_key_valid($pubKey) && !plugin_public_key_valid('quatsch'), 'öffentlicher Schlüssel wird geprüft');
$r = plugin_install_zip($pdo, $pkg, null, $sig);
$check(!$r['ok'] && str_contains($r['error'], 'kein Herausgeber-Schlüssel'), 'Signatur ohne hinterlegten Schlüssel kann nicht geprüft werden');
set_setting('plugin_publisher_key', $pubKey);
$r = plugin_install_zip($pdo, $pkg, null, $sig);
$check($r['ok'] && $r['signed'] === true && (int) $pdo->query("SELECT signature_ok FROM plugins WHERE slug='signiert'")->fetchColumn() === 1, 'signiertes Plugin wird installiert und als signiert vermerkt');
$r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $otherPriv));
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'Signatur mit fremdem Schlüssel wird abgelehnt');
$tampered = $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '2.0.0']), 'plugin.php' => '<?php // manipuliert']);
$r = plugin_install_zip($pdo, $tampered, null, $sig);
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'verändertes Paket mit alter Signatur wird abgelehnt');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'signiert', 'name' => 'Signiert', 'version' => '9.9.9']), 'plugin.php' => '<?php']), null, $sig);
$check(!$r['ok'], 'Signatur lässt sich nicht auf eine andere Version übertragen');
set_setting('plugin_require_signature', '1');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'unsigniert']), 'plugin.php' => '<?php']));
$check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), '„Signatur erforderlich“ lehnt unsignierte Pakete ab');
$check(plugin_install_zip($pdo, $pkg, null, $sig)['ok'], '„Signatur erforderlich“ lässt signierte Pakete zu');
set_setting('plugin_require_signature', '0');
// ── Katalog ──────────────────────────────────────────────────────────────────
$catalogPkg = $makeZip(['plugin.json' => $manifest(['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.0.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Katalog', 'x');"]);
$catalogSig = $sign($catalogPkg, 'katalogplugin', '1.0.0', $privKey);
$entry = ['slug' => 'katalogplugin', 'name' => 'Aus dem Katalog', 'version' => '1.0.0', 'description' => 'Test', 'author' => 'Tester', 'permissions' => ['ui.menu'],
'downloadUrl' => 'https://plugins.example.de/dl/katalogplugin-1.0.0.zip', 'sha256' => hash_file('sha256', $catalogPkg), 'signature' => $catalogSig];
$served = ['plugins' => [$entry, ['slug' => 'ungueltig!', 'version' => '1', 'sha256' => 'x'], array_merge($entry, ['slug' => 'httpdl', 'downloadUrl' => 'http://x.de/a.zip']), array_merge($entry, ['slug' => 'fremdrecht', 'permissions' => ['alles.lesen']])]];
$requests = [];
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$requests, $catalogPkg): array {
$requests[] = $url;
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
if (str_contains($url, 'katalogplugin-1.0.0.zip')) return [200, file_get_contents($catalogPkg), null];
return [404, null, 'HTTP 404'];
};
$check(plugin_catalog_fetch()['ok'] === false, 'ohne Katalog-Adresse gibt es einen Hinweis');
set_setting('plugin_catalog_url', 'http://plugins.example.de/catalog.php');
$check(!plugin_catalog_fetch()['ok'], 'Katalog muss über https erreichbar sein');
set_setting('plugin_catalog_url', 'https://127.0.0.1/catalog.php');
$check(!plugin_catalog_fetch()['ok'], 'Katalog auf interner Adresse wird abgelehnt');
set_setting('plugin_catalog_url', 'https://plugins.example.de/catalog.php?type=plugins');
$cat = plugin_catalog_fetch();
$check($cat['ok'] && count($cat['plugins']) === 1 && $cat['plugins'][0]['slug'] === 'katalogplugin', 'Katalog wird gelesen, ungültige Einträge (Slug, http-Download, unbekannte Rechte) werden verworfen');
$r = plugin_catalog_install($pdo, 'katalogplugin');
$check($r['ok'] && $r['signed'] && (int) $pdo->query("SELECT COUNT(*) FROM plugins WHERE slug='katalogplugin' AND source='catalog' AND signature_ok=1")->fetchColumn() === 1, 'Installation aus dem Katalog gelingt (Prüfsumme + Signatur)');
$again = plugin_catalog_install($pdo, 'katalogplugin');
$check(!$again['ok'] && str_contains($again['error'], 'bereits'), 'ein bereits installiertes Katalog-Plugin lässt sich nicht noch einmal installieren');
$check(!plugin_catalog_install($pdo, 'gibtesnicht')['ok'], 'Plugin außerhalb des Katalogs kann nicht installiert werden');
plugin_uninstall($pdo, 'katalogplugin'); // für die folgenden Fehlerfall-Tests wieder frei machen
$served['plugins'][0]['sha256'] = str_repeat('a', 64);
$r = plugin_catalog_install($pdo, 'katalogplugin');
$check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'abweichende Prüfsumme (manipulierter Download) wird abgelehnt');
$served['plugins'][0]['sha256'] = hash_file('sha256', $catalogPkg);
$served['plugins'][0]['version'] = '1.0.1'; // Katalog verspricht eine andere Version als im Paket steckt
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, $catalogPkg): array {
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
return [200, file_get_contents($catalogPkg), null];
};
$r = plugin_catalog_install($pdo, 'katalogplugin');
$check(!$r['ok'] && str_contains($r['error'], 'passt nicht zur Katalog-Angabe'), 'Paket, das nicht zur Katalog-Angabe passt, wird abgelehnt');
$served['plugins'][0]['version'] = '1.0.0';
$served['plugins'][0]['downloadUrl'] = 'https://10.0.0.5/x.zip';
$check(!plugin_catalog_install($pdo, 'katalogplugin')['ok'], 'Download von interner Adresse wird abgelehnt');
unset($GLOBALS['deskhub_http_get']);
// ── Offizieller Katalog und geprüfter Modus ──────────────────────────────────
$check(!plugin_official_configured() && !plugin_strict_mode(), 'ohne Vorgaben gilt kein strenger Modus');
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://official.example.de/catalog.php?type=plugins', 'themes_url' => 'https://official.example.de/catalog.php?type=themes', 'publisher_key' => $pubKey];
set_setting('plugin_catalog_url', 'https://eigener.example.de/catalog.php');
set_setting('plugin_publisher_key', $otherPub);
set_setting('plugin_require_signature', '0');
$check(plugin_official_configured() && plugin_strict_mode(), 'mit Vorgaben gilt der geprüfte Modus');
$check(plugin_catalog_url() === 'https://official.example.de/catalog.php?type=plugins', 'im geprüften Modus gilt die offizielle Katalog-Adresse, nicht die eigene');
$check(plugin_publisher_key() === trim($pubKey) && plugin_signatures_required('catalog') && !plugin_signatures_required('upload'), 'bei festem Katalog gilt der offizielle Schlüssel; aus dem Katalog ist Signatur Pflicht, bei eigenen Uploads nicht');
$r = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'handgemacht']), 'plugin.php' => '<?php']));
$check($r['ok'] && $r['signed'] === false && (int) $pdo->query("SELECT signature_ok FROM plugins WHERE slug='handgemacht'")->fetchColumn() === 0 && $pdo->query("SELECT source FROM plugins WHERE slug='handgemacht'")->fetchColumn() === 'upload', 'ein eigenes, unsigniertes Plugin lässt sich auch bei festem Katalog hochladen und ist als „nicht geprüft“ vermerkt');
$r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $privKey));
$check($r['ok'] && $r['signed'] === true, 'ein signierter Upload wird als signiert erkannt');
$r = plugin_install_zip($pdo, $pkg, null, $sign($pkg, 'signiert', '2.0.0', $otherPriv));
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'eine ungültige Signatur beim Upload wird auch bei festem Katalog abgelehnt');
$check(plugin_set_active($pdo, 'handgemacht', true)['ok'], 'ein eigenes Plugin ohne Berechtigungen lässt sich aktivieren');
$offPkg = $makeZip(['plugin.json' => $manifest(['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.0.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Offiziell', 'x');"]);
$offEntry = ['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.0.0', 'description' => '', 'author' => '', 'permissions' => ['ui.menu'], 'downloadUrl' => 'https://official.example.de/dl/offiziell.zip',
'sha256' => hash_file('sha256', $offPkg), 'signature' => $sign($offPkg, 'offiziell', '1.0.0', $privKey)];
$offServed = ['plugins' => [$offEntry, array_merge($offEntry, ['slug' => 'unsigniert', 'signature' => ''])]];
$fetches = 0;
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg, &$fetches): array {
if (str_contains($url, 'catalog.php')) { $fetches++; return [200, json_encode($offServed), null]; }
return [200, file_get_contents($offPkg), null];
};
$c1 = plugin_catalog_cached();
$c2 = plugin_catalog_cached();
$check($c1['ok'] && $c2['ok'] && $fetches === 1, 'Katalog wird eine Stunde zwischengespeichert (ein Abruf für zwei Aufrufe)');
plugin_catalog_cached(true);
$check($fetches === 2, 'Aktualisieren erzwingt einen neuen Abruf');
$r = plugin_catalog_install($pdo, 'offiziell');
$check($r['ok'] && $r['signed'], 'Installation aus dem offiziellen Katalog gelingt: ' . json_encode($r));
plugin_uninstall($pdo, 'offiziell');
$offServed['plugins'][0]['signature'] = '';
$r = plugin_catalog_install($pdo, 'offiziell');
$check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), 'unsigniertes Katalog-Plugin wird im geprüften Modus abgelehnt');
$offServed['plugins'][0]['signature'] = $sign($offPkg, 'offiziell', '1.0.0', $otherPriv); // Signatur eines Fremden
$r = plugin_catalog_install($pdo, 'offiziell');
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'Signatur eines fremden Herausgebers wird abgelehnt');
// Neuere Katalogversion eines installierten Plugins darf installiert werden
$offPkg2 = $makeZip(['plugin.json' => $manifest(['slug' => 'offiziell', 'name' => 'Offiziell', 'version' => '1.1.0', 'permissions' => ['ui.menu']]), 'plugin.php' => "<?php plugin_register_menu('Offiziell', 'x');"]);
plugin_uninstall($pdo, 'offiziell');
plugin_install_zip($pdo, $offPkg, null, $sign($offPkg, 'offiziell', '1.0.0', $privKey), 'catalog');
$offServed['plugins'][0] = ['version' => '1.1.0', 'sha256' => hash_file('sha256', $offPkg2), 'signature' => $sign($offPkg2, 'offiziell', '1.1.0', $privKey)] + $offEntry;
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$offServed, $offPkg2): array {
if (str_contains($url, 'catalog.php')) return [200, json_encode($offServed), null];
return [200, file_get_contents($offPkg2), null];
};
plugin_catalog_cached(true);
$r = plugin_catalog_install($pdo, 'offiziell');
$check($r['ok'] && $r['updated'] && $r['version'] === '1.1.0', 'eine neuere Katalogversion wird als Update installiert: ' . json_encode($r));
$r = plugin_catalog_install($pdo, 'offiziell');
$check(!$r['ok'] && str_contains($r['error'], 'bereits'), 'dieselbe Version danach nicht noch einmal');
unset($GLOBALS['deskhub_http_get']);
// Eigene (nicht signierte) Plugins laufen weiter, auch bei festem Katalog
$pdo->exec("UPDATE plugins SET is_active = 1, approved_permissions = 'ui.menu' WHERE slug = 'offiziell'");
$pdo->exec("UPDATE plugins SET is_active = 1, approved_permissions = 'hooks.cron,hooks.tickets,ui.menu' WHERE slug = 'begrenzt'");
$pdo->exec("UPDATE plugins SET signature_ok = 0 WHERE slug = 'begrenzt'");
$reboot();
$check(isset(plugin_state()['loaded']['begrenzt']) && (int) $pdo->query("SELECT is_active FROM plugins WHERE slug='begrenzt'")->fetchColumn() === 1, 'ein eigenes, nicht signiertes Plugin wird auch bei festem Katalog geladen');
$check(isset(plugin_state()['loaded']['offiziell']), 'signiertes Plugin aus dem Katalog wird geladen');
// Der Katalog ist fest: Einstellungen schalten den strengen Modus nicht ab
set_setting('plugin_allow_custom_sources', '1');
$check(plugin_strict_mode(), 'die frühere Einstellung "Eigene Quellen erlauben" hat keine Wirkung mehr');
$check(plugin_catalog_url() === 'https://official.example.de/catalog.php?type=plugins' && plugin_publisher_key() === trim($pubKey), 'eigene Katalog-Adresse und eigener Schlüssel aus den Einstellungen werden ignoriert');
set_setting('plugin_allow_custom_sources', '0');
unset($GLOBALS['deskhub_catalog_defaults']);
set_setting('plugin_catalog_url', '');
set_setting('plugin_publisher_key', '');
set_setting('plugin_catalog_cache', '');
// ── Aktualisieren, Notausschalter, Entfernen ─────────────────────────────────
$upd = plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'beispiel', 'name' => 'Beispiel', 'version' => '1.1.0']), 'plugin.php' => '<?php // neu']));
$check($upd['ok'] && $upd['updated'] && $pdo->query("SELECT version FROM plugins WHERE slug='beispiel'")->fetchColumn() === '1.1.0', 'Plugin lässt sich aktualisieren');
touch($pluginDir . '/.disabled');
$reboot();
$check(plugin_state()['loaded'] === [], 'Notausschalter (.disabled) lädt keine Plugins');
unlink($pluginDir . '/.disabled');
$un = plugin_uninstall($pdo, 'beispiel', false);
$check($un['ok'] && !is_dir($pluginDir . '/beispiel'), 'Plugin wird entfernt');
$check((int) $pdo->query("SELECT COUNT(*) FROM plugins WHERE slug='beispiel'")->fetchColumn() === 0, 'Datenbankeintrag verschwindet');
$check(plugin_install_zip($pdo, $makeZip(['plugin.json' => $manifest(['slug' => 'beispiel']), 'plugin.php' => '<?php']))['ok'], 'Neuinstallation nach dem Entfernen möglich');
// ── Zugangsschlüssel: wird nur an den offiziellen Katalog-Server geschickt ──────────
$KEY = 'GEHEIMER-ZUGANGSSCHLUESSEL';
$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://official.example.de/catalog.php?type=plugins', 'themes_url' => 'https://official.example.de/catalog.php?type=themes', 'publisher_key' => $pubKey, 'access_key' => $KEY];
$seenHeaders = [];
$GLOBALS['deskhub_http_get'] = function (string $url, int $max, array $headers = []) use (&$seenHeaders): array { $seenHeaders[$url] = $headers; return [200, '{}', null]; };
foreach (['https://official.example.de/catalog.php?type=plugins', 'https://official.example.de/download.php?type=plugin&slug=x', 'https://OFFICIAL.example.de/preview.php?file=a.png',
'https://fremd.example.org/irgendwas', 'https://official.example.de.boese.example.org/x', 'http://official.example.de/catalog.php'] as $u) plugin_http_get($u, 1000);
$expect = ['X-DeskHub-Key: ' . $KEY];
$check($seenHeaders['https://official.example.de/catalog.php?type=plugins'] === $expect && $seenHeaders['https://official.example.de/download.php?type=plugin&slug=x'] === $expect && $seenHeaders['https://OFFICIAL.example.de/preview.php?file=a.png'] === $expect, 'Katalog, Download und Vorschau beim offiziellen Server bekommen den Zugangsschlüssel');
$check($seenHeaders['https://fremd.example.org/irgendwas'] === [] && $seenHeaders['https://official.example.de.boese.example.org/x'] === [] && $seenHeaders['http://official.example.de/catalog.php'] === [], 'an fremde Adressen, ähnlich klingende Domains und unverschlüsselte Verbindungen wird der Schlüssel nie geschickt');
$GLOBALS['deskhub_catalog_defaults']['access_key'] = '';
plugin_http_get('https://official.example.de/catalog.php?type=plugins', 1000);
$check($seenHeaders['https://official.example.de/catalog.php?type=plugins'] === [], 'ohne eingetragenen Zugangsschlüssel wird kein Header gesendet');
unset($GLOBALS['deskhub_http_get'], $GLOBALS['deskhub_catalog_defaults']);
// ── Ohne Testmodus lässt sich der Katalog nicht überschreiben (echter Prozess mit den echten Vorgaben) ──
$probe = tempnam(sys_get_temp_dir(), 'probe') . '.php';
file_put_contents($probe, "<?php putenv('DESKHUB_SQLITE_PATH=:memory:'); \$GLOBALS['deskhub_catalog_defaults'] = ['plugins_url' => 'https://boese.example/c', 'publisher_key' => 'x']; require " . var_export(dirname(__DIR__) . '/includes/bootstrap.php', true) . "; "
. "set_setting('plugin_catalog_url', 'https://boese.example/c'); set_setting('plugin_publisher_key', 'x'); set_setting('plugin_allow_custom_sources', '1'); "
. "echo json_encode(['strict' => plugin_strict_mode(), 'url' => plugin_catalog_url(), 'key' => plugin_publisher_key() === catalog_defaults()['publisher_key'], 'official' => plugin_official_configured()]);");
$envClean = array_diff_key(getenv(), ['DESKHUB_TESTING' => 1, 'DESKHUB_CATALOG_DEFAULTS' => 1]);
$envClean['DESKHUB_CATALOG_DEFAULTS'] = $pluginDir . '/gibtesnicht.php'; // ohne DESKHUB_TESTING muss auch das wirkungslos sein
$ph = proc_open([PHP_BINARY, $probe], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $envClean);
$probeOut = json_decode((string) stream_get_contents($pipes[1]), true) ?: [];
proc_close($ph);
@unlink($probe);
$real = (array) require dirname(__DIR__) . '/includes/catalog_defaults.php';
$check(($probeOut['strict'] ?? null) === true && ($probeOut['official'] ?? null) === true && ($probeOut['url'] ?? '') === $real['plugins_url'] && ($probeOut['key'] ?? false) === true,
'ohne Testmodus ignorieren Überschreibungsversuche (Globals, Umgebungsvariable, Einstellungen) die fest eingebauten Vorgaben: ' . json_encode($probeOut));
$check($real['plugins_url'] !== '' && str_contains($real['publisher_key'], 'BEGIN PUBLIC KEY'), 'die ausgelieferte includes/catalog_defaults.php enthält Katalog und Schlüssel');
// Aufräumen
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($pluginDir);
@unlink($cnf);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Plugin system checks passed.\n";
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* Führt alle PHP-Tests aus tests/ nacheinander aus (je Test ein eigener Prozess) und fasst das Ergebnis zusammen.
* Aufruf: php tests/run_all.php alle Tests
* php tests/run_all.php plugin nur Tests, deren Dateiname "plugin" enthält
* Exit-Code 0 = alles grün, 1 = mindestens ein Test fehlgeschlagen (für CI).
*/
$filter = $argv[1] ?? '';
$timeout = 600;
$files = glob(__DIR__ . '/*.php') ?: [];
sort($files);
$files = array_values(array_filter($files, fn($f) => basename($f) !== 'run_all.php' && ($filter === '' || str_contains(basename($f), $filter))));
if (!$files) { fwrite(STDERR, "Keine Tests gefunden.\n"); exit(1); }
$failed = [];
$started = microtime(true);
foreach ($files as $file) {
$name = basename($file, '.php');
$t0 = microtime(true);
$proc = proc_open([PHP_BINARY, $file], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, dirname(__DIR__));
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$out = '';
$err = '';
$timedOut = false;
while (true) {
$out .= (string) stream_get_contents($pipes[1]);
$err .= (string) stream_get_contents($pipes[2]);
$status = proc_get_status($proc);
if (!$status['running']) break;
if (microtime(true) - $t0 > $timeout) { proc_terminate($proc, 9); $timedOut = true; break; }
usleep(50000);
}
$out .= (string) stream_get_contents($pipes[1]);
$err .= (string) stream_get_contents($pipes[2]);
$code = $timedOut ? -1 : $status['exitcode'];
proc_close($proc);
$lines = array_values(array_filter(array_map('trim', explode("\n", trim($out)))));
$last = $lines ? end($lines) : '';
$ok = $code === 0 && !$timedOut;
printf("%s %-32s %5.1fs %s\n", $ok ? 'OK ' : 'FAIL', $name, microtime(true) - $t0, $ok ? $last : ($timedOut ? 'Zeitüberschreitung' : "Exit-Code {$code}"));
if (!$ok) {
$failed[] = $name;
$detail = trim($err !== '' ? $err : $out);
echo ' ' . str_replace("\n", "\n ", mb_substr($detail, 0, 3000)) . "\n";
}
}
printf("\n%d Tests in %.0f s: %d bestanden, %d fehlgeschlagen%s\n", count($files), microtime(true) - $started, count($files) - count($failed), count($failed), $failed ? ' (' . implode(', ', $failed) . ')' : '');
exit($failed ? 1 : 0);
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/**
* Prüft den HTML-Bereinigungsfilter (sanitize_staff_html) gegen typische XSS-Angriffe.
* Wichtig, weil damit HTML-E-Mails fremder Absender im Mitarbeiterbereich angezeigt werden.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
/** Prüft, ob im Ergebnis ausführbares Markup stecken könnte. Gibt den Grund zurück oder null. */
$dangerous = function (string $out): ?string {
if (preg_match('/<\s*(script|iframe|object|embed|svg|math|form|input|style|link|meta|base)\b/i', $out)) return 'gefährliches Tag';
if (preg_match_all('/<([a-z0-9]+)((?:\s[^<>]*)?)>/i', $out, $m, PREG_SET_ORDER)) {
foreach ($m as $t) {
// Attributwerte entfernen, dann dürfen nur erlaubte Attributnamen übrig sein
$names = preg_replace('/"[^"]*"/', '', $t[2]);
if (preg_match_all('/\s([a-z-]+)=/i', (string) $names, $a)) {
foreach ($a[1] as $name) {
if (!in_array(strtolower($name), ['href','target','rel','src','alt','width','height','title','style','colspan','rowspan','align','valign','border','cellpadding','cellspacing'], true)) return "Attribut {$name}";
}
}
}
}
// Übrig gebliebene "<" oder ">" außerhalb vollständiger, erlaubter Tags wären gefährlich
$stripped = preg_replace('/<\/?[a-z0-9]+(?:\s[^<>]*)?\/?>/i', '', $out);
if (str_contains((string) $stripped, '<') || str_contains((string) $stripped, '>')) return 'loses < oder >';
return null;
};
$payloads = [
'script' => '<script>alert(1)</script>Hallo',
'script verschachtelt' => '<scr<script>ipt>alert(1)</scr</script>ipt>',
'img onerror' => '<img src=x onerror=alert(1)>',
'img onerror quoted' => '<img src="x" onerror="alert(1)">',
'unterminiert' => 'Text <img src=x onerror=alert(1)',
'unterminiert svg' => '<svg onload=alert(1)',
'doppelt <' => '<<img src=x onerror=alert(1)//>',
'a javascript' => '<a href="javascript:alert(1)">x</a>',
'a javascript entity' => '<a href="&#106;avascript:alert(1)">x</a>',
'a javascript tab' => '<a href="java&#9;script:alert(1)">x</a>',
'a data' => '<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>',
'a onclick' => '<a href="https://example.org" onclick="alert(1)">x</a>',
'a attribut ausbruch' => '<a href="https://example.org&quot; onmouseover=&quot;alert(1)">x</a>',
'img svg data' => '<img src="data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+">',
'style expression' => '<div style="width:expression(alert(1))">x</div>',
'style url' => '<div style="background:url(https://evil.example/t.gif)">x</div>',
'style url entity' => '<div style="background:u&#114;l(https://evil.example/t.gif)">x</div>',
'style position' => '<div style="position:fixed;top:0;left:0;width:100%;height:100%">Bitte Passwort eingeben</div>',
'style ausbruch' => '<div style="color:red&quot; onmouseover=&quot;alert(1)">x</div>',
'iframe' => '<iframe src="https://evil.example"></iframe>',
'iframe srcdoc' => '<iframe srcdoc="<script>alert(1)</script>"></iframe>',
'form' => '<form action="https://evil.example"><input name=pw><button>Senden</button></form>',
'meta refresh' => '<meta http-equiv="refresh" content="0;url=https://evil.example">',
'base' => '<base href="https://evil.example/">',
'object' => '<object data="x"></object><embed src="x">',
'math' => '<math><mi xlink:href="javascript:alert(1)">x</mi></math>',
'kommentar' => '<!--><script>alert(1)</script>-->',
'bedingt' => '<!--[if gte IE 4]><script>alert(1)</script><![endif]-->',
'cdata' => '<![CDATA[<script>alert(1)</script>]]>',
'attribut ohne quotes' => '<img src=https://x.example/a.png onerror=alert(1)>',
'grossschreibung' => '<IMG SRC=x ONERROR=alert(1)><SCRIPT>alert(1)</SCRIPT>',
'null byte' => "<scr\0ipt>alert(1)</scr\0ipt>",
'tag mit newline' => "<img\nsrc=x\nonerror=alert(1)>",
'slash statt space' => '<img/src=x/onerror=alert(1)>',
'details ontoggle' => '<details open ontoggle=alert(1)>x</details>',
'td background' => '<table background="javascript:alert(1)"><tr><td>x</td></tr></table>',
];
foreach ($payloads as $name => $input) {
$out = sanitize_staff_html($input);
$why = $dangerous($out);
$check($why === null, "Angriff '{$name}' nicht entschärft ({$why}): {$out}");
}
// Konkrete Erwartungen
$out = sanitize_staff_html('Text <img src=x onerror=alert(1)');
$check(!str_contains($out, '<img') && str_contains($out, '&lt;img'), 'Unterminiertes Tag muss als Text maskiert werden: ' . $out);
$out = sanitize_staff_html('<a href="javascript:alert(1)">x</a>');
$check(!str_contains($out, 'javascript') && str_contains($out, 'rel="noopener noreferrer"'), 'javascript-Link: ' . $out);
$out = sanitize_staff_html('<div style="position:fixed;color:red">x</div>');
$check(!str_contains($out, 'position') && str_contains($out, 'color: red'), 'position muss entfernt, color bleiben: ' . $out);
$out = sanitize_staff_html('<div style="background:url(https://evil.example/t.gif)">x</div>');
$check(!str_contains($out, 'url') && !str_contains($out, 'evil'), 'url() im Stil: ' . $out);
$out = sanitize_staff_html('<img src="http://tracker.example/p.gif">');
$check(!str_contains($out, 'tracker.example'), 'http-Bild muss ersetzt werden: ' . $out);
$out = sanitize_staff_html('<img src="data:image/svg+xml;base64,AAAA">');
$check(!str_contains($out, 'svg'), 'svg-Datenbild muss ersetzt werden: ' . $out);
// Normales Rich-Text-HTML bleibt erhalten
$ok = '<p>Hallo <b>Welt</b>, <i>kursiv</i> und <a href="https://example.org/a?x=1&amp;y=2">ein Link</a>.</p><ul><li>Eins</li><li>Zwei</li></ul>'
. '<table border="1"><tr><td colspan="2" style="color: red">Zelle</td></tr></table><blockquote>Zitat</blockquote><br>';
$out = sanitize_staff_html($ok);
foreach (['<p>', '<b>Welt</b>', '<i>kursiv</i>', 'href="https://example.org/a?x=1&amp;y=2"', '<li>Zwei</li>', 'colspan="2"', 'style="color: red"', '<blockquote>Zitat</blockquote>', '<br>'] as $needle) {
$check(str_contains($out, $needle), "Erlaubtes HTML ging verloren ({$needle}): {$out}");
}
$check(sanitize_staff_html(' ') === '', 'Leerer Text');
$check(sanitize_staff_html('Preis < 5 und > 3') === 'Preis &lt; 5 und &gt; 3', 'Kleiner/Größer im Fließtext: ' . sanitize_staff_html('Preis < 5 und > 3'));
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo 'Sanitize HTML checks passed (' . count($payloads) . " Angriffe).\n";
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/database/sqlite_schema.php';
$schemaPath = dirname(__DIR__) . '/database/schema.sql';
$schema = file_get_contents($schemaPath);
if ($schema === false) {
fwrite(STDERR, "Datenbankschema konnte nicht gelesen werden.\n");
exit(1);
}
if (preg_match('/\bAUTOINCREMENT\b/i', $schema)) {
fail('Das kanonische MySQL/MariaDB-Schema enthält SQLite-only AUTOINCREMENT.');
}
if (!preg_match_all(
'/CREATE TABLE IF NOT EXISTS\s+`?(\w+)`?\s*\((.*?)\)\s*(?:ENGINE\s*=\s*InnoDB[^;]*)?;/is',
$schema,
$tables,
PREG_SET_ORDER
)) {
fwrite(STDERR, "Keine Tabellen im Datenbankschema gefunden.\n");
exit(1);
}
$definitions = [];
foreach ($tables as $table) {
$definitions[$table[1]] = $table[0];
if (!preg_match('/ENGINE\s*=\s*InnoDB\s+DEFAULT\s+CHARSET\s*=\s*utf8mb4\s*;$/i', trim($table[0]))) {
fail("Tabellendefinition {$table[1]} verwendet nicht InnoDB/utf8mb4.");
}
if (!preg_match_all(
'/FOREIGN KEY\s*\(\s*`?(\w+)`?\s*\).*?ON DELETE SET NULL/i',
$table[2],
$foreignKeys
)) {
continue;
}
foreach ($foreignKeys[1] as $column) {
if (!preg_match('/^\s*`?' . preg_quote($column, '/') . '`?\s+([^,\r\n]+)/mi', $table[2], $definition)) {
fail("Spalte {$table[1]}.{$column} wurde nicht gefunden.");
}
if (preg_match('/\bNOT\s+NULL\b/i', $definition[1])) {
fail("{$table[1]}.{$column} ist NOT NULL, verwendet aber ON DELETE SET NULL.");
}
}
}
foreach (['departments', 'organizations', 'staff', 'users', 'customer_notes', 'ticket_ratings'] as $tableName) {
if (!isset($definitions[$tableName])) {
fail("Tabellendefinition {$tableName} fehlt.");
}
}
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
foreach (['departments', 'organizations', 'staff', 'users', 'customer_notes'] as $tableName) {
$pdo->exec(convert_schema_to_sqlite($definitions[$tableName]));
}
$pdo->exec(
"INSERT INTO staff (id, name, email, password_hash, role, is_active, created_at)
VALUES (1, 'Testautor', 'autor@example.test', 'hash', 'agent', 1, CURRENT_TIMESTAMP)"
);
$pdo->exec(
"INSERT INTO users (id, name, email, password_hash, created_at)
VALUES (1, 'Testkunde', 'kunde@example.test', 'hash', CURRENT_TIMESTAMP)"
);
$pdo->exec(
"INSERT INTO customer_notes (user_id, staff_id, note, created_at)
VALUES (1, 1, 'Historische Notiz', CURRENT_TIMESTAMP)"
);
$pdo->exec('DELETE FROM staff WHERE id = 1');
$note = $pdo->query('SELECT staff_id, note FROM customer_notes WHERE user_id = 1')->fetch(PDO::FETCH_ASSOC);
if ($note === false || $note['staff_id'] !== null || $note['note'] !== 'Historische Notiz') {
fail('Kundennotizen bleiben beim Löschen eines Mitarbeiters nicht mit staff_id = NULL erhalten.');
}
$pdo->exec('CREATE TABLE tickets (id INTEGER PRIMARY KEY)');
$pdo->exec(convert_schema_to_sqlite($definitions['ticket_ratings']));
$pdo->exec("INSERT INTO users (id, name, email, password_hash, created_at)
VALUES (2, 'Bewertungskunde', 'rating@example.test', 'hash', CURRENT_TIMESTAMP)");
$pdo->exec('INSERT INTO tickets (id) VALUES (1)');
$pdo->exec("INSERT INTO ticket_ratings (ticket_id, user_id, rating, comment, created_at)
VALUES (1, 2, 5, 'Sehr gut', CURRENT_TIMESTAMP)");
$rating = $pdo->query('SELECT id, user_id, rating FROM ticket_ratings WHERE ticket_id = 1')->fetch(PDO::FETCH_ASSOC);
if ($rating === false || (int) $rating['id'] !== 1 || (int) $rating['user_id'] !== 2 || (int) $rating['rating'] !== 5) {
fail('Die SQLite-Konvertierung von ticket_ratings erzeugt keine funktionsfähige Tabelle.');
}
$pdo->exec('DELETE FROM users WHERE id = 2');
$ratingUserId = $pdo->query('SELECT user_id FROM ticket_ratings WHERE ticket_id = 1')->fetchColumn();
if ($ratingUserId !== null) {
fail('ticket_ratings.user_id bleibt beim Löschen eines Kunden nicht als NULL erhalten.');
}
$pdo->exec('DELETE FROM tickets WHERE id = 1');
if ((int) $pdo->query('SELECT COUNT(*) FROM ticket_ratings')->fetchColumn() !== 0) {
fail('Ticket-Bewertungen werden beim Löschen des Tickets nicht mit entfernt.');
}
$fullSchemaPdo = new PDO('sqlite::memory:');
$fullSchemaPdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$fullSchemaPdo->exec('PRAGMA foreign_keys = ON');
$fullSchemaPdo->exec(convert_schema_to_sqlite($schema));
$organizationColumns = $fullSchemaPdo->query('PRAGMA table_info(organizations)')->fetchAll(PDO::FETCH_ASSOC);
$createdAt = array_values(array_filter($organizationColumns, fn(array $column): bool => $column['name'] === 'created_at'))[0] ?? null;
if ($createdAt === null || (int) $createdAt['notnull'] !== 1 || stripos((string) $createdAt['dflt_value'], 'CURRENT_TIMESTAMP') === false) {
fail('Die SQLite-Konvertierung bewahrt NOT NULL/DEFAULT CURRENT_TIMESTAMP nicht.');
}
echo "Schema-Fremdschlüssel sind SET-NULL-kompatibel.\n";
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* Prüft die Sicherheitsprüfung (includes/security_check.php): erkennt sie erreichbare interne Dateien und ausführbare
* Uploads, und meldet sie nichts, wenn alles gesperrt ist? Der Webserver wird durch eine Abruf-Funktion simuliert.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$levels = fn(array $rows) => array_column($rows, 'level');
// Gut konfigurierter Server: alles Interne 403, Unbekanntes 404
$secure = fn(string $url) => str_contains($url, 'deskhub-pruefung') ? [404, ''] : [403, ''];
$r = security_check_exposure('https://support.example.org', $secure);
$check($levels($r) === ['ok'], 'Gesperrter Server: nur "ok" erwartet, erhalten ' . implode(',', $levels($r)));
// Server ohne Sperrregeln (z. B. nginx ohne Konfiguration): Konfiguration und Datenbank liefern 200
$open = fn(string $url) => str_contains($url, 'deskhub-pruefung') ? [404, ''] : [200, '<?php return [];'];
$r = security_check_exposure('https://support.example.org', $open);
$check($levels($r) === ['error'], 'Offener Server muss einen Fehler melden');
$check(str_contains($r[0]['detail'], '/config/config.php') && str_contains($r[0]['detail'], '/database/deskhub.sqlite') && str_contains($r[0]['detail'], '/backups/'), 'Fehlertext nennt die erreichbaren Adressen: ' . $r[0]['detail']);
// Nur eine Adresse offen
$partial = fn(string $url) => str_contains($url, '/backups/') ? [200, 'Index of'] : (str_contains($url, 'deskhub-pruefung') ? [404, ''] : [403, '']);
$r = security_check_exposure('https://support.example.org/', $partial);
$check($levels($r) === ['error'] && str_contains($r[0]['detail'], '/backups/') && !str_contains($r[0]['detail'], 'config.php'), 'Genau die offene Adresse wird gemeldet');
// Server antwortet auf alles mit 200 (Catch-all): nicht aussagekräftig statt falschem Alarm
$catchAll = fn(string $url) => [200, '<html>Startseite</html>'];
$r = security_check_exposure('https://support.example.org', $catchAll);
$check($levels($r) === ['info'], 'Catch-all-Server: nur Hinweis "nicht prüfbar"');
// Nicht erreichbar
$down = fn(string $url) => [0, ''];
$r = security_check_exposure('https://support.example.org', $down);
$check($levels($r) === ['info'], 'Nicht erreichbar: nur Hinweis');
// Upload-Ausführung: simulierter Server führt die Datei aus (liefert den Marker) bzw. nicht
$dir = sys_get_temp_dir() . '/deskhub_sc_' . bin2hex(random_bytes(4));
mkdir($dir);
$executing = function (string $url) use ($dir): array {
$f = $dir . '/' . basename($url);
if (!is_file($f)) return [404, ''];
preg_match('/echo "([a-f0-9]+)"/', (string) file_get_contents($f), $m);
return [200, $m[1] ?? ''];
};
$notExecuting = function (string $url) use ($dir): array {
$f = $dir . '/' . basename($url);
return is_file($f) ? [200, (string) file_get_contents($f)] : [404, '']; // liefert den Quelltext aus, führt ihn nicht aus
};
$denied = fn(string $url) => [403, ''];
$r = security_check_upload_execution('https://support.example.org', $dir, $executing);
$check($r['level'] === 'error', 'Ausführbare Uploads müssen als Fehler gemeldet werden');
$r = security_check_upload_execution('https://support.example.org', $dir, $notExecuting);
$check($r['level'] === 'ok', 'Nicht ausgeführte Uploads: ok (Quelltext ist kein Marker)');
$r = security_check_upload_execution('https://support.example.org', $dir, $denied);
$check($r['level'] === 'ok', 'Gesperrte Uploads: ok');
$check(glob($dir . '/*') === [], 'Die Testdatei wird wieder gelöscht');
$r = security_check_upload_execution('https://support.example.org', $dir . '/gibt-es-nicht', $denied);
$check($r['level'] === 'info', 'Nicht beschreibbarer Ordner: Hinweis');
rmdir($dir);
// Einstellungsprüfungen laufen ohne Fehler und liefern Einträge mit gültigen Stufen
$pdo = get_db();
$rows = security_check_settings($pdo);
$check(count($rows) >= 3, 'Einstellungsprüfung liefert Ergebnisse');
foreach ($rows as $row) $check(in_array($row['level'], ['ok', 'warn', 'error', 'info'], true) && $row['title'] !== '', 'Gültiger Eintrag: ' . json_encode($row));
$all = security_check_all($pdo, false);
$check($all !== [] && $all[0]['level'] !== 'ok' || count(array_unique(array_column($all, 'level'))) === 1, 'Fehler stehen vor "ok" (Sortierung)');
$sorted = array_column($all, 'level');
$rank = ['error' => 0, 'warn' => 1, 'info' => 2, 'ok' => 3];
$check($sorted === array_values(array_map(fn($l) => $l, $sorted)) && array_map(fn($l) => $rank[$l], $sorted) === (function ($a) { sort($a); return $a; })(array_map(fn($l) => $rank[$l], $sorted)), 'Ergebnisse sind nach Dringlichkeit sortiert');
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo "Security check checks passed.\n";
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* Prüft sicherheitsrelevante Hilfsfunktionen: Client-IP hinter Reverse-Proxys (kein Umgehen der Login-Sperre per
* gefälschtem X-Forwarded-For), HTTPS-Erkennung und das Zurücksetzen der Login-Zähler.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['site' => ['trusted_proxies' => ['10.0.0.0/8', '192.168.1.5', 'fd00::/8']]]));
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
// CIDR-Vergleich
$check(ip_in_list('10.1.2.3', ['10.0.0.0/8']), '10.1.2.3 in 10.0.0.0/8');
$check(!ip_in_list('11.1.2.3', ['10.0.0.0/8']), '11.1.2.3 nicht in 10.0.0.0/8');
$check(ip_in_list('192.168.1.5', ['192.168.1.5']), 'Einzeladresse');
$check(!ip_in_list('192.168.1.6', ['192.168.1.5']), 'andere Einzeladresse');
$check(ip_in_list('172.17.0.3', ['172.16.0.0/12']), '/12-Bereich');
$check(!ip_in_list('172.32.0.1', ['172.16.0.0/12']), 'außerhalb /12');
$check(ip_in_list('fd00::1234', ['fd00::/8']), 'IPv6 im Bereich');
$check(!ip_in_list('2001:db8::1', ['fd00::/8']), 'IPv6 außerhalb');
$check(!ip_in_list('10.0.0.1', ['fd00::/8']), 'IPv4 gegen IPv6-Eintrag');
$check(!ip_in_list('kaputt', ['10.0.0.0/8']), 'ungültige IP');
// Direkter Zugriff: X-Forwarded-For wird ignoriert (sonst könnte jeder die Sperre umgehen)
$_SERVER['REMOTE_ADDR'] = '203.0.113.9';
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4';
$_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https';
$check(client_ip() === '203.0.113.9', 'Ohne vertrauenswürdigen Proxy zählt REMOTE_ADDR: ' . client_ip());
$check(!request_is_https(), 'Ohne vertrauenswürdigen Proxy wird X-Forwarded-Proto ignoriert');
// Über vertrauenswürdigen Proxy
$_SERVER['REMOTE_ADDR'] = '10.0.0.7';
$_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.20';
$check(client_ip() === '198.51.100.20', 'Proxy: Client-IP aus X-Forwarded-For: ' . client_ip());
$check(request_is_https(), 'Proxy: X-Forwarded-Proto https wird erkannt');
// Der Angreifer hängt eine eigene Adresse vorne an: maßgeblich ist die letzte nicht vertrauenswürdige von rechts
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.1.1.1, 198.51.100.20, 10.0.0.3';
$check(client_ip() === '198.51.100.20', 'Gefälschte Adresse vorne wird ignoriert: ' . client_ip());
$_SERVER['HTTP_X_FORWARDED_FOR'] = 'kein-ip-wert';
$check(client_ip() === '10.0.0.7', 'Ungültiger Header fällt auf REMOTE_ADDR zurück');
// Login-Zähler: nach Erfolg zurücksetzbar
$_SERVER['REMOTE_ADDR'] = '203.0.113.9';
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
for ($i = 0; $i < 5; $i++) $check(rate_limit_check('login_email', 'a@example.org', 5, 15), "Versuch {$i} erlaubt");
$check(!rate_limit_check('login_email', 'a@example.org', 5, 15), 'sechster Versuch gesperrt');
rate_limit_reset('login_email', 'a@example.org');
$check(rate_limit_check('login_email', 'a@example.org', 5, 15), 'nach dem Zurücksetzen wieder erlaubt');
$check(!rate_limit_check('login_email', 'b@example.org', 0, 15), 'anderer Schlüssel unabhängig');
// Abteilungsprüfung für Mitarbeiter (verhindert Zugriff auf Tickets/Anhänge fremder Abteilungen)
$pdo = get_db();
$pdo->exec("INSERT INTO departments (name, email, is_default) VALUES ('Zweite Abteilung', 'zweite@example.org', 0)");
$deptA = (int) $pdo->query("SELECT id FROM departments ORDER BY id LIMIT 1")->fetchColumn();
$deptB = (int) $pdo->query("SELECT id FROM departments ORDER BY id DESC LIMIT 1")->fetchColumn();
$check($deptA !== $deptB, "zwei Abteilungen angelegt");
$admin = ["id" => 1, "role" => "admin", "department_id" => null];
$agentA = ["id" => 2, "role" => "agent", "department_id" => $deptA];
$agentNone = ["id" => 3, "role" => "agent", "department_id" => null];
$check(staff_can_access_department($admin, $deptB), "Admin darf jede Abteilung");
$check(staff_can_access_department($agentA, $deptA), "Agent darf eigene Abteilung");
$check(!staff_can_access_department($agentA, $deptB), "Agent darf fremde Abteilung nicht");
$check(!staff_can_access_department($agentNone, $deptA), "Agent ohne Abteilung darf nichts");
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo "Security helpers checks passed.\n";
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/** Prüft die Theme-Installation aus dem Katalog (includes/theme_catalog.php): Prüfsumme, Signatur, Format, Sicherheit. */
$themesDir = sys_get_temp_dir() . '/deskhub-tc-' . bin2hex(random_bytes(4));
mkdir($themesDir, 0755, true);
putenv('DESKHUB_THEMES_DIR=' . $themesDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
if (!class_exists('ZipArchive')) { echo "Theme-Katalog-Tests übersprungen: PHP-ZIP fehlt.\n"; exit(0); }
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
$k = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k, $priv, null, ['config' => $cnf]);
$pub = openssl_pkey_get_details($k)['key'];
$zipPath = tempnam(sys_get_temp_dir(), 'th') . '.zip';
$z = new ZipArchive();
$z->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$z->addFromString('theme.css', "/*\n * Author: Studio\n * Version: 1.2.0\n */\n:root { --primary: #0a5; }");
$z->close();
$sha = hash_file('sha256', $zipPath);
openssl_sign(plugin_signature_message('theme-nordlicht', '1.2.0', $sha), $sigRaw, $priv, OPENSSL_ALGO_SHA256);
$sig = base64_encode($sigRaw);
$entry = ['slug' => 'nordlicht', 'name' => 'Nordlicht', 'version' => '1.2.0', 'description' => 'Hell und ruhig', 'author' => 'Studio',
'downloadUrl' => 'https://plugins.example.de/dl/nordlicht-1.2.0.zip', 'sha256' => $sha, 'signature' => $sig];
$served = ['themes' => [$entry, ['slug' => 'kaputt', 'version' => 'x', 'sha256' => 'y'], array_merge($entry, ['slug' => 'httpdl', 'downloadUrl' => 'http://x.de/a.zip'])]];
$body = file_get_contents($zipPath);
$GLOBALS['deskhub_http_get'] = function (string $url, int $max) use (&$served, &$body): array {
if (str_contains($url, 'catalog.php')) return [200, json_encode($served), null];
return [200, $body, null];
};
$check(!theme_catalog_fetch()['ok'], 'ohne Katalog-Adresse gibt es einen Hinweis');
set_setting('theme_catalog_url', 'https://plugins.example.de/catalog.php?type=themes');
$cat = theme_catalog_fetch();
$check($cat['ok'] && count($cat['themes']) === 1 && $cat['themes'][0]['slug'] === 'nordlicht', 'Katalog wird gelesen, ungültige Einträge werden verworfen');
$r = theme_catalog_install('nordlicht');
$check(!$r['ok'] && str_contains($r['error'], 'kein Herausgeber-Schlüssel'), 'Signatur ohne hinterlegten Schlüssel kann nicht geprüft werden');
$check(!theme_catalog_install('gibtesnicht')['ok'], 'Theme außerhalb des Katalogs kann nicht installiert werden');
set_setting('plugin_publisher_key', $pub);
$r = theme_catalog_install('nordlicht');
$check($r['ok'] && $r['signed'] === true, 'mit Herausgeber-Schlüssel wird die Signatur geprüft');
$again = theme_catalog_install('nordlicht');
$check(!$again['ok'] && str_contains($again['error'], 'bereits'), 'ein bereits installiertes Theme lässt sich nicht noch einmal installieren');
$state = theme_catalog_state($served['themes'][0]);
$check($state['installed'] && !$state['update'] && $state['version'] === '1.2.0', 'Katalog erkennt das Theme als installiert (gleicher Name, gleiche Version)');
$check(!theme_catalog_state(['name' => 'Anderes Theme', 'version' => '1.0.0'])['installed'], 'ein nicht installiertes Theme wird nicht als installiert gezeigt');
$check(theme_catalog_state(['name' => 'Nordlicht', 'version' => '1.3.0'])['update'] === true, 'eine neuere Katalogversion wird als Update erkannt');
$themes = list_custom_themes();
$check(count($themes) === 1 && $themes[0]['name'] === 'Nordlicht' && is_file($themesDir . '/' . $themes[0]['id'] . '/theme.css'), 'Theme liegt im Theme-Ordner und wird aufgelistet');
$check($themes && $themes[0]['theme_version'] === '1.2.0' && $themes[0]['author'] === 'Studio', 'Version und Autor stammen aus theme.css');
$check(get_setting('active_custom_theme', '') === '', 'das Theme wird nicht automatisch aktiviert');
foreach (list_custom_themes() as $__t) delete_custom_theme($__t['id']);
$served['themes'][0]['signature'] = base64_encode(random_bytes(256));
$r = theme_catalog_install('nordlicht');
$check(!$r['ok'] && str_contains($r['error'], 'Signatur ist ungültig'), 'ungültige Signatur wird abgelehnt');
// Plugin-Signatur darf nicht als Theme-Signatur durchgehen
openssl_sign(plugin_signature_message('nordlicht', '1.2.0', $sha), $pluginSig, $priv, OPENSSL_ALGO_SHA256);
$served['themes'][0]['signature'] = base64_encode($pluginSig);
$check(!theme_catalog_install('nordlicht')['ok'], 'eine Plugin-Signatur gilt nicht für ein Theme');
$served['themes'][0]['signature'] = $sig;
$body = file_get_contents($zipPath) . 'x'; // Download wurde verändert
$r = theme_catalog_install('nordlicht');
$check(!$r['ok'] && str_contains($r['error'], 'Prüfsumme'), 'veränderter Download wird abgelehnt');
$body = file_get_contents($zipPath);
$served['themes'][0]['signature'] = '';
set_setting('plugin_require_signature', '1');
$r = theme_catalog_install('nordlicht');
$check(!$r['ok'] && str_contains($r['error'], 'müssen signiert sein'), '„Signatur erforderlich“ lehnt unsignierte Themes ab');
set_setting('plugin_require_signature', '0');
$served['themes'][0]['downloadUrl'] = 'https://192.168.1.5/x.zip';
$check(!theme_catalog_install('nordlicht')['ok'], 'Download von interner Adresse wird abgelehnt');
unset($GLOBALS['deskhub_http_get']);
$rm = function (string $d) use (&$rm) {
foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); }
@rmdir($d);
};
$rm($themesDir);
@unlink($zipPath);
@unlink($cnf);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Theme catalog checks passed.\n";
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
$previousDbPath = getenv('DESKHUB_SQLITE_PATH');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/db.php';
require_once dirname(__DIR__) . '/includes/functions.php';
try {
$pdo = get_db();
$now = db_now();
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
->execute(['Ticket Owner', 'ticket-owner@example.test', 'test-hash', $now]);
$ownerId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
->execute(['Other User', 'other@example.test', 'test-hash', $now]);
$otherId = (int) $pdo->lastInsertId();
$departmentId = (int) $pdo->query('SELECT id FROM departments LIMIT 1')->fetchColumn();
$priorityId = (int) $pdo->query('SELECT id FROM priorities LIMIT 1')->fetchColumn();
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id)
VALUES (?,?,?,?,?,?)'
)->execute(['TEST-ACCESS-1', 'Ticket access test', 'Ticket Owner', 'ticket-owner@example.test', $departmentId, $priorityId]);
$ticketId = (int) $pdo->lastInsertId();
$url = ticket_access_url(['id' => $ticketId, 'ticket_number' => 'TEST-ACCESS-1']);
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
$token = $query['token'] ?? '';
if (!preg_match('/^[a-f0-9]{96}$/', $token)) {
throw new RuntimeException('Der E-Mail-Link enthält kein korrektes Zugriffstoken.');
}
if (!verify_ticket_access_token($ticketId, $token)) {
throw new RuntimeException('Das generierte Zugriffstoken wird nicht akzeptiert.');
}
if (verify_ticket_access_token($ticketId, str_repeat('0', 96))) {
throw new RuntimeException('Ein falsches Zugriffstoken wurde akzeptiert.');
}
if (ticket_access_token($ticketId) !== $token) {
throw new RuntimeException('Das Ticket-Zugriffstoken ist nicht stabil.');
}
if (claim_ticket_for_customer($pdo, $ticketId, ['id' => $otherId, 'email' => 'other@example.test'])) {
throw new RuntimeException('Ein fremdes Kundenkonto konnte das Ticket beanspruchen.');
}
if (!claim_ticket_for_customer($pdo, $ticketId, ['id' => $ownerId, 'email' => 'TICKET-OWNER@example.test'])) {
throw new RuntimeException('Der berechtigte Kunde konnte das Ticket nicht beanspruchen.');
}
$ownerCheck = $pdo->prepare('SELECT user_id FROM tickets WHERE id = ?');
$ownerCheck->execute([$ticketId]);
if ((int) $ownerCheck->fetchColumn() !== $ownerId) {
throw new RuntimeException('Das Ticket wurde nicht dem richtigen Kundenkonto zugeordnet.');
}
echo "Ticket access integration checks passed.\n";
} finally {
putenv($previousDbPath === false ? 'DESKHUB_SQLITE_PATH' : 'DESKHUB_SQLITE_PATH=' . $previousDbPath);
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/**
* Prüft die Absicherung des System-Updates: Signatur mit tools/release-sign.php erzeugen und mit DeskHub prüfen,
* manipulierte oder falsch zugeordnete Pakete ablehnen, Release-Anhänge finden.
*/
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_TESTING=1');
$tmp = sys_get_temp_dir() . '/deskhub-upd-' . bin2hex(random_bytes(4));
mkdir($tmp);
// Schlüsselpaar erzeugen (Windows braucht eine minimale openssl-Konfiguration)
$cnf = $tmp . '/cnf';
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
$k = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k, $priv, null, ['config' => $cnf]);
$pub = openssl_pkey_get_details($k)['key'];
file_put_contents($tmp . '/privat.pem', $priv);
// Fremder Schlüssel (Angreifer)
$k2 = openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($k2, $priv2, null, ['config' => $cnf]);
file_put_contents($tmp . '/fremd.pem', $priv2);
// Katalog-Vorgaben mit Herausgeber-Schlüssel für diesen Test
file_put_contents($tmp . '/defaults.php', "<?php return ['plugins_url' => 'https://plugins.example.test/catalog.php?type=plugins', 'themes_url' => 'https://plugins.example.test/catalog.php?type=themes', 'access_key' => '', 'publisher_key' => " . var_export($pub, true) . "];");
putenv('DESKHUB_CATALOG_DEFAULTS=' . $tmp . '/defaults.php');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$sign = function (string $zip, string $key, string $version): array {
exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(dirname(__DIR__) . '/tools/release-sign.php') . ' ' . escapeshellarg($zip) . ' ' . escapeshellarg($key) . ' ' . escapeshellarg($version) . ' 2>&1', $out, $code);
return [$code, implode("\n", $out)];
};
$check(update_signature_required(), 'Mit eingebautem Herausgeber-Schlüssel wird eine Signatur verlangt');
$zip = $tmp . '/deskhub-1.0.7.zip';
$z = new ZipArchive(); $z->open($zip, ZipArchive::CREATE); $z->addFromString('version.php', '<?php // neu'); $z->close();
[$code, $out] = $sign($zip, $tmp . '/privat.pem', '1.0.7');
$check($code === 0 && is_file($zip . '.sig'), 'Signier-Werkzeug erzeugt die .sig-Datei: ' . $out);
$sig = (string) @file_get_contents($zip . '.sig');
$check(update_verify_package('1.0.7', $zip, $sig) === null, 'Gültige Signatur wird akzeptiert');
$check(update_verify_package('v1.0.7', $zip, $sig) === null, 'Version mit führendem "v" passt ebenfalls');
$check(update_verify_package('1.0.8', $zip, $sig) !== null, 'Signatur einer anderen Version wird abgelehnt (kein Herunterstufen/Umetikettieren)');
$check(update_verify_package('1.0.7', $zip, '') !== null, 'Fehlende Signatur wird abgelehnt');
$check(update_verify_package('1.0.7', $zip, 'keinebase64signatur') !== null, 'Kaputte Signatur wird abgelehnt');
// Paket verändert
$evil = $tmp . '/evil.zip';
$z = new ZipArchive(); $z->open($evil, ZipArchive::CREATE); $z->addFromString('version.php', '<?php system($_GET["x"]);'); $z->close();
$check(update_verify_package('1.0.7', $evil, $sig) !== null, 'Verändertes Paket mit alter Signatur wird abgelehnt');
// Signatur mit fremdem Schlüssel
copy($zip, $tmp . '/zweit.zip');
[$code] = $sign($tmp . '/zweit.zip', $tmp . '/fremd.pem', '1.0.7');
$check($code === 0 && update_verify_package('1.0.7', $tmp . '/zweit.zip', (string) file_get_contents($tmp . '/zweit.zip.sig')) !== null, 'Signatur eines fremden Schlüssels wird abgelehnt');
// Plugin-Signatur darf nicht als Update-Signatur durchgehen (andere Nachricht)
openssl_sign(plugin_signature_message('deskhub', '1.0.7', hash_file('sha256', $zip)), $pluginSig, $k, OPENSSL_ALGO_SHA256);
$check(update_verify_package('1.0.7', $zip, base64_encode($pluginSig)) !== null, 'Eine Plugin-Signatur gilt nicht als Update-Signatur');
// Werkzeug: Eingabefehler
[$code] = $sign($zip, $tmp . '/privat.pem', 'kaputt');
$check($code !== 0, 'Ungültige Versionsnummer wird vom Werkzeug abgelehnt');
[$code] = $sign($tmp . '/gibt-es-nicht.zip', $tmp . '/privat.pem', '1.0.7');
$check($code !== 0, 'Fehlende ZIP wird vom Werkzeug abgelehnt');
// Release-Anhänge finden (Gitea-Format)
$release = ['assets' => [
['name' => 'deskhub-1.0.7.zip', 'browser_download_url' => 'https://git.example.test/a/deskhub-1.0.7.zip'],
['name' => 'deskhub-1.0.7.zip.sig', 'browser_download_url' => 'https://git.example.test/a/deskhub-1.0.7.zip.sig'],
['name' => 'deskhub-1.0.6.zip', 'browser_download_url' => 'https://git.example.test/a/deskhub-1.0.6.zip'],
]];
$a = update_find_assets($release, 'v1.0.7');
$check($a['zip'] === 'https://git.example.test/a/deskhub-1.0.7.zip' && $a['sig'] === 'https://git.example.test/a/deskhub-1.0.7.zip.sig', 'Anhänge der richtigen Version werden gefunden');
$a = update_find_assets(['assets' => [['name' => 'deskhub-1.0.7.zip', 'browser_download_url' => 'http://unsicher.example/x.zip']]], '1.0.7');
$check($a['zip'] === null, 'Nur https-Adressen werden akzeptiert');
$a = update_find_assets(['assets' => []], '1.0.7');
$check($a['zip'] === null && $a['sig'] === null, 'Ohne Anhänge: nichts gefunden');
$a = update_find_assets($release, '1.0.9');
$check($a['zip'] === null, 'Anhänge anderer Versionen zählen nicht');
// Zugangstoken: nur aus der Konfiguration, nie eingebaut
$check(DESKHUB_GITEA_TOKEN === '', 'Im Programmcode steckt kein Zugangstoken mehr');
$check(update_http_headers() === [], 'Ohne konfiguriertes Token wird kein Authorization-Header gesendet');
$check(!str_contains((string) file_get_contents(dirname(__DIR__) . '/version.php'), 'openssl_decrypt'), 'version.php enthält keinen verschlüsselten Token mehr');
// Ohne Herausgeber-Schlüssel (reiner Eigenbetrieb) wird keine Signatur verlangt
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
$check(function_exists('plugin_publisher_key'), 'Hilfsfunktion vorhanden');
// Aufräumen
foreach (glob($tmp . '/*') ?: [] as $f) @unlink($f);
@rmdir($tmp);
if ($failures) {
fwrite(STDERR, "FEHLER:\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo "Update security checks passed.\n";
@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/includes/db.php';
require_once dirname(__DIR__) . '/database/migrate.php';
$root = dirname(__DIR__);
$tempDb = tempnam(sys_get_temp_dir(), 'deskhub-web-');
if ($tempDb === false) {
throw new RuntimeException('Temporäre Web-Testdatenbank konnte nicht erstellt werden.');
}
$schema = file_get_contents($root . '/database/schema.sql');
if ($schema === false) {
throw new RuntimeException('Datenbankschema konnte nicht gelesen werden.');
}
$pdo = new PDO('sqlite:' . $tempDb);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(convert_schema_to_sqlite($schema));
$migrations = run_migrations($pdo, true);
if ($migrations['errors']) {
throw new RuntimeException('Frische SQLite-Migration fehlgeschlagen: ' . implode('; ', $migrations['errors']));
}
$password = 'Web-Smoke-Password-42';
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = new DateTimeImmutable();
$createdAt = $now->modify('-2 hours')->format('Y-m-d H:i:s');
$respondedAt = $now->modify('-30 minutes')->format('Y-m-d H:i:s');
$resolvedAt = $now->modify('-10 minutes')->format('Y-m-d H:i:s');
$nowSql = $now->format('Y-m-d H:i:s');
// Hintergrundjobs sind nicht Teil dieses Seitentests; im Dev-Server würden sie die Antwort blockieren.
$pdo->exec("INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES ('pseudo_cron_enabled', '0')");
$pdo->prepare('UPDATE staff SET password_hash = ?, is_active = 1 WHERE id = 1')->execute([$hash]);
$pdo->prepare(
"INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at)
VALUES ('Web Agent', 'web-agent@example.test', ?, 'agent', 1, 1, ?)"
)->execute([$hash, $nowSql]);
$agentId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO users (name, email, password_hash, created_at)
VALUES ('Web Kunde', 'web-customer@example.test', ?, ?)"
)->execute([$hash, $nowSql]);
$userId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO tickets
(ticket_number, subject, user_id, requester_name, requester_email, department_id,
priority_id, assigned_staff_id, status, source, first_responded_at, resolution_due_at,
resolved_at, created_at, updated_at)
VALUES ('WEB-1001', 'Web-Smoke Ticket', ?, 'Web Kunde', 'web-customer@example.test',
1, 2, ?, 'resolved', 'web', ?, ?, ?, ?, ?)"
)->execute([$userId, $agentId, $respondedAt, $resolvedAt, $resolvedAt, $createdAt, $nowSql]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO ticket_threads
(ticket_id, author_type, user_id, message, is_internal_note, created_at)
VALUES (?, 'customer', ?, 'Realistische Smoke-Test-Anfrage', 0, ?)"
)->execute([$ticketId, $userId, $createdAt]);
$pdo->prepare(
"INSERT INTO activity_log (ticket_id, staff_id, action, created_at)
VALUES (?, ?, 'Mitarbeiter zugewiesen: Web Agent', ?)"
)->execute([$ticketId, $agentId, $createdAt]);
$pdo = null;
$port = random_int(19000, 19999);
$command = [PHP_BINARY, '-d', 'max_execution_time=15', '-S', "127.0.0.1:$port", '-t', $root];
$previousDbPath = getenv('DESKHUB_SQLITE_PATH');
// Temporäres Plugin für den Seitentest (Plugin-Ordner des Projekts bleibt unberührt)
$smokePluginDir = sys_get_temp_dir() . '/deskhub-smoke-plugins-' . bin2hex(random_bytes(4));
mkdir($smokePluginDir . '/smoke/pages', 0755, true);
file_put_contents($smokePluginDir . '/smoke/plugin.json', json_encode(['slug' => 'smoke', 'name' => 'Smoke', 'version' => '1.0.0', 'permissions' => ['ui.menu', 'api.endpoints']]));
file_put_contents($smokePluginDir . '/smoke/plugin.php', "<?php plugin_register_menu('Smoke-Seite', 'hello'); plugin_register_api('ping', fn(\$s, \$r) => ['pong' => true]);");
file_put_contents($smokePluginDir . '/smoke/pages/hello.php', '<p>Hallo aus dem Plugin</p>');
file_put_contents($smokePluginDir . '/smoke/pages/geheim.php', '<p>nicht registriert</p>');
$smokePdo = new PDO('sqlite:' . $tempDb);
$smokePdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at, approved_permissions) VALUES ('smoke', 'Smoke', '1.0.0', 1, datetime('now'), 'api.endpoints,ui.menu')");
// Mitgeliefertes Plugin "Tagesbericht" mitladen, damit seine Einstellungsseite im echten Webserver geprüft wird
require_once __DIR__ . '/fixtures/paths.php';
$__tagSrc = plugin_source_dir('tagesbericht');
foreach ($__tagSrc === null ? [] : new RecursiveIteratorIterator(new RecursiveDirectoryIterator($__tagSrc, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $__f) {
$__dest = $smokePluginDir . '/tagesbericht/' . str_replace(chr(92), '/', substr(str_replace(chr(92), '/', $__f->getPathname()), strlen($__tagSrc) + 1));
$__f->isDir() ? @mkdir($__dest, 0755, true) : (@mkdir(dirname($__dest), 0755, true) + copy($__f->getPathname(), $__dest));
}
if ($__tagSrc !== null) $smokePdo->exec("INSERT INTO plugins (slug, name, version, is_active, installed_at, approved_permissions) VALUES ('tagesbericht', 'Tagesbericht', '1.0.0', 1, datetime('now'), 'hooks.cron,ui.menu')");
$smokePdo = null;
putenv('DESKHUB_PLUGINS_DIR=' . $smokePluginDir);
// Offizieller Katalog (geprüfter Modus) für den Seitentest
$smokeCnf = tempnam(sys_get_temp_dir(), 'cnf');
file_put_contents($smokeCnf, "[req]
distinguished_name=req_dn
[req_dn]
");
$smokeKey = openssl_pkey_new(['config' => $smokeCnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$smokeDefaults = tempnam(sys_get_temp_dir(), 'cd');
file_put_contents($smokeDefaults, '<?php return [];'); // zunächst ohne offiziellen Katalog
putenv('DESKHUB_CATALOG_DEFAULTS=' . $smokeDefaults);
putenv('DESKHUB_TESTING=1');
// App-Download: Android-Datei vorbereiten (Windows bleibt bewusst leer)
$smokeAppsDir = sys_get_temp_dir() . '/deskhub-smoke-apps-' . bin2hex(random_bytes(4));
mkdir($smokeAppsDir, 0755, true);
$smokeApk = 'PK' . random_bytes(3000);
file_put_contents($smokeAppsDir . '/DeskHub.apk', $smokeApk);
putenv('DESKHUB_APPS_DIR=' . $smokeAppsDir);
putenv('DESKHUB_SQLITE_PATH=' . $tempDb);
// Serverlog in Datei statt Pipe: Ein nicht geleerter Pipe-Puffer würde den Server nach vielen Requests blockieren.
$serverLogFile = tempnam(sys_get_temp_dir(), 'deskhub-web-log-');
$process = proc_open(
$command,
[['pipe', 'r'], ['file', $serverLogFile, 'w'], ['file', $serverLogFile, 'a']],
$pipes,
$root,
null,
);
putenv($previousDbPath === false ? 'DESKHUB_SQLITE_PATH' : 'DESKHUB_SQLITE_PATH=' . $previousDbPath);
if (!is_resource($process)) {
@unlink($tempDb);
throw new RuntimeException('PHP-Web-Testserver konnte nicht gestartet werden.');
}
try {
wait_for_server($port);
$publicCookies = [];
assert_page(browser_request($port, 'GET', '/home.php', $publicCookies), 'Supportcenter-Startseite');
foreach ([
['admin@example.com', 'Web Agent'],
['web-agent@example.test', 'Statistiken'],
] as [$email, $reportsMarker]) {
$cookies = [];
login($port, '/staff/login.php', $email, $password, $cookies, '/staff/reports.php');
assert_page(browser_request($port, 'GET', '/staff/reports.php', $cookies), $reportsMarker);
// Die Standardliste zeigt nur offene Tickets; das Smoke-Ticket ist gelöst und steht unter "Alle".
assert_page(browser_request($port, 'GET', '/staff/tickets.php?tab=all', $cookies), 'WEB-1001');
if ($email === 'admin@example.com') {
// Verwaltungsseiten im osTicket-Stil: Liste, Neu-Formular und Bearbeiten-Formular müssen ohne PHP-Fehler laden.
foreach ([
'/staff/email_addresses.php' => 'E-Mail-Adressen', '/staff/email_addresses.php?new=1' => 'Abgehend',
'/staff/help_topics.php' => 'Hilfethemen', '/staff/help_topics.php?new=1' => 'Optionen bei neuem Ticket',
'/staff/departments.php' => 'Abteilungen', '/staff/departments.php?new=1' => 'Mitglieder', '/staff/departments.php?edit=1' => 'Abteilung aktualisieren',
'/staff/staff_manage.php' => 'Mitarbeiter', '/staff/staff_manage.php?new=1' => 'Berechtigungen', '/staff/staff_manage.php?edit=1' => 'Erweiterter Zugriff',
'/staff/teams.php' => 'Teams', '/staff/teams.php?new=1' => 'Teaminformationen',
'/staff/roles.php' => 'Rollen', '/staff/roles.php?new=1' => 'Rolleninformationen',
'/staff/priorities.php' => 'SLA', '/staff/priorities.php?new=1' => 'SLA-Zeiten',
'/staff/custom_lists.php' => 'Listen', '/staff/custom_lists.php?new=1' => 'Einträge',
'/staff/site_pages.php' => 'Seiten', '/staff/site_pages.php?new=1' => 'Seiteninformationen',
'/staff/business_hours.php' => 'Feiertage', '/staff/business_hours.php?tab=holidays' => 'Feiertage',
'/staff/update.php' => 'Datenbank aktualisieren', '/staff/plugins.php' => 'Plugin-Katalog', '/staff/backup.php' => 'Vorhandene Backups', '/staff/security_check.php' => 'Sicherheitsprüfung', '/staff/import_osticket.php' => 'osTicket Import', '/staff/ticket_rules.php' => 'Ticket-Regeln', '/staff/ticket_rules.php?new=1' => 'Regelinformationen',
] as $path => $marker) {
$response = browser_request($port, 'GET', $path, $cookies);
assert_page($response, $marker);
foreach (['Fatal error', 'Warning:', 'Notice:', 'Deprecated:', 'Uncaught'] as $bad) {
if (stripos($response[1], $bad) !== false) {
throw new RuntimeException("Seite {$path} enthält eine PHP-Meldung ({$bad}).");
}
}
}
}
// Abteilung löschen, die noch gebraucht wird: Meldung statt HTTP 500
if ($email === 'admin@example.com') {
$dp = browser_request($port, 'GET', '/staff/departments.php', $cookies);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $dp[1], $dm);
$del = browser_request($port, 'POST', '/staff/departments.php', $cookies, ['action' => 'delete', 'id' => '1', 'csrf_token' => $dm[1] ?? '']);
if ($del[0] !== 302) throw new RuntimeException('Löschen einer benutzten Abteilung liefert HTTP ' . $del[0] . ' statt einer Weiterleitung mit Meldung.');
$after = browser_request($port, 'GET', '/staff/departments.php', $cookies);
if (stripos($after[1], 'kann nicht gelöscht werden') === false) throw new RuntimeException('Nach dem Löschversuch fehlt die verständliche Meldung.');
}
// App-Download: Seite für alle Mitarbeiter, Datei nur mit Anmeldung
assert_page(browser_request($port, 'GET', '/staff/apps.php', $cookies), 'Android-App herunterladen');
$dl = browser_request($port, 'GET', '/staff/app_download.php?platform=android', $cookies);
if ($dl[0] !== 200 || $dl[1] !== $smokeApk) throw new RuntimeException('App-Download liefert nicht die hinterlegte Datei.');
if (stripos(implode("
", $dl[2]), 'application/vnd.android.package-archive') === false) throw new RuntimeException('App-Download hat den falschen Dateityp.');
if (browser_request($port, 'GET', '/staff/app_download.php?platform=windows', $cookies)[0] !== 404) throw new RuntimeException('Nicht hinterlegte App sollte 404 liefern.');
if (browser_request($port, 'GET', '/staff/app_download.php?platform=../../config/config', $cookies)[0] !== 404) throw new RuntimeException('Unbekannte Plattform sollte 404 liefern.');
$noCookies = [];
$anon = browser_request($port, 'GET', '/staff/app_download.php?platform=android', $noCookies);
if ($anon[0] !== 302 || $anon[1] === $smokeApk) throw new RuntimeException('Ohne Anmeldung darf die App nicht ladbar sein (Status ' . $anon[0] . ').');
if (browser_request($port, 'GET', '/app_downloads/DeskHub.apk', $noCookies)[0] === 200) throw new RuntimeException('Der App-Ordner ist direkt per URL erreichbar.');
if ($email === 'admin@example.com') {
assert_page(browser_request($port, 'GET', '/staff/plugin.php?p=smoke&page=hello', $cookies), 'Hallo aus dem Plugin');
// Ohne Vorgaben: manueller Upload sichtbar, kein strenger Modus
$pl = browser_request($port, 'GET', '/staff/plugins.php', $cookies);
if (stripos($pl[1], 'Eigenes Plugin hochladen') === false || stripos($pl[1], 'Geprüfte Plugins') !== false) throw new RuntimeException('Plugin-Seite ohne offiziellen Katalog zeigt falschen Modus.');
if ($__tagSrc !== null) assert_page(browser_request($port, 'GET', '/staff/plugin.php?p=tagesbericht&page=einstellungen', $cookies), 'Testbericht an mich senden');
assert_page(browser_request($port, 'GET', '/staff/tickets.php?tab=all', $cookies), 'Smoke-Seite'); // Menüpunkt in der Navigation
foreach (['/staff/plugin.php?p=smoke&page=geheim', '/staff/plugin.php?p=smoke&page=../plugin', '/staff/plugin.php?p=unbekannt&page=hello'] as $blocked) {
if (browser_request($port, 'GET', $blocked, $cookies)[0] !== 404) throw new RuntimeException("Plugin-Seite {$blocked} sollte 404 liefern.");
}
// Mit offiziellem Katalog: geprüfter Modus, kein manueller Upload
file_put_contents($smokeDefaults, '<?php return ' . var_export(['plugins_url' => 'https://katalog.invalid/catalog.php?type=plugins', 'themes_url' => 'https://katalog.invalid/catalog.php?type=themes', 'publisher_key' => openssl_pkey_get_details($smokeKey)['key']], true) . ';');
$pl = browser_request($port, 'GET', '/staff/plugins.php', $cookies);
if (stripos($pl[1], 'Geprüfte Plugins') === false || stripos($pl[1], 'Eigenes Plugin hochladen') === false || stripos($pl[1], 'Eigene Quellen erlauben') !== false) throw new RuntimeException('Plugin-Seite zeigt im geprüften Modus die falschen Bereiche.');
file_put_contents($smokeDefaults, '<?php return [];');
}
$lockPdo = new PDO('sqlite:' . $tempDb);
$lockPdo->exec("UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE ticket_number = 'WEB-1001'");
$lockPdo = null;
assert_page(
browser_request($port, 'GET', '/staff/ticket_view.php?n=WEB-1001', $cookies),
'Web-Smoke Ticket'
);
}
$customerCookies = [];
login($port, '/login.php', 'web-customer@example.test', $password, $customerCookies, '/home.php');
assert_page(browser_request($port, 'GET', '/home.php', $customerCookies), 'Meine Tickets');
assert_page(browser_request($port, 'GET', '/dashboard.php', $customerCookies), 'WEB-1001');
assert_page(
browser_request($port, 'GET', '/ticket_view.php?n=WEB-1001', $customerCookies),
'Web-Smoke Ticket'
);
echo "Web smoke integration test passed for admin, agent, and customer.\n";
} catch (Throwable $e) {
$serverLog = trim((string) @file_get_contents($serverLogFile));
throw new RuntimeException(
$e->getMessage() . ($serverLog !== '' ? "\nPHP-Testserver:\n" . $serverLog : ''),
0,
$e
);
} finally {
proc_terminate($process);
foreach ($pipes as $pipe) {
if (is_resource($pipe)) {
fclose($pipe);
}
}
proc_close($process);
@unlink($tempDb);
@unlink($serverLogFile);
@unlink($smokeAppsDir . '/DeskHub.apk'); @rmdir($smokeAppsDir);
@unlink($smokeDefaults); @unlink($smokeCnf);
foreach (['/smoke/pages/hello.php', '/smoke/pages/geheim.php', '/smoke/pages', '/smoke/plugin.json', '/smoke/plugin.php', '/smoke'] as $__p) { $__f = $smokePluginDir . $__p; is_dir($__f) ? @rmdir($__f) : @unlink($__f); }
if (is_dir($smokePluginDir)) {
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($smokePluginDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $__f) {
$__f->isDir() ? @rmdir($__f->getPathname()) : @unlink($__f->getPathname());
}
@rmdir($smokePluginDir);
}
}
function wait_for_server(int $port): void
{
for ($attempt = 0; $attempt < 40; $attempt++) {
$socket = @fsockopen('127.0.0.1', $port, $errno, $error, 0.1);
if ($socket) {
fclose($socket);
return;
}
usleep(100000);
}
throw new RuntimeException('PHP-Web-Testserver ist nicht erreichbar.');
}
function login(
int $port,
string $path,
string $email,
string $password,
array &$cookies,
string $expectedLocation
): void {
[$status, $body] = browser_request($port, 'GET', $path, $cookies);
if ($status !== 200 || !preg_match('/name="csrf_token" value="([^"]+)"/', $body, $match)) {
throw new RuntimeException("Loginformular {$path} konnte nicht geladen werden.");
}
[$status, , $headers] = browser_request($port, 'POST', $path, $cookies, [
'csrf_token' => html_entity_decode($match[1], ENT_QUOTES),
'action' => 'login',
'email' => $email,
'password' => $password,
]);
$location = response_header($headers, 'Location');
if ($status !== 302 || $location !== $expectedLocation) {
throw new RuntimeException(
"Login für {$email} fehlgeschlagen (HTTP {$status}, Location " . ($location ?? 'fehlt') . ').'
);
}
}
function browser_request(
int $port,
string $method,
string $path,
array &$cookies,
?array $form = null
): array {
$headers = ['Accept: text/html'];
if ($cookies) {
$cookiePairs = [];
foreach ($cookies as $name => $value) {
$cookiePairs[] = $name . '=' . $value;
}
$headers[] = 'Cookie: ' . implode('; ', $cookiePairs);
}
$content = null;
if ($form !== null) {
$content = http_build_query($form);
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
}
$context = stream_context_create([
'http' => [
'method' => $method,
'header' => implode("\r\n", $headers),
'content' => $content,
'ignore_errors' => true,
'follow_location' => 0,
'timeout' => 15,
],
]);
$body = file_get_contents("http://127.0.0.1:$port$path", false, $context);
$responseHeaders = $http_response_header ?? [];
preg_match('/\s(\d{3})\s/', $responseHeaders[0] ?? '', $statusMatch);
foreach ($responseHeaders as $header) {
if (stripos($header, 'Set-Cookie:') !== 0) {
continue;
}
$cookie = trim(substr($header, strlen('Set-Cookie:')));
[$pair] = explode(';', $cookie, 2);
[$name, $value] = array_pad(explode('=', $pair, 2), 2, '');
$cookies[$name] = $value;
}
$status = (int) ($statusMatch[1] ?? 0);
echo "{$method} {$path} -> {$status}\n";
return [$status, $body === false ? '' : $body, $responseHeaders];
}
function response_header(array $headers, string $name): ?string
{
foreach ($headers as $header) {
if (stripos($header, $name . ':') === 0) {
return trim(substr($header, strlen($name) + 1));
}
}
return null;
}
function assert_page(array $response, string $marker): void
{
[$status, $body] = $response;
if ($status !== 200) {
throw new RuntimeException("Seite mit Marker {$marker} liefert HTTP {$status}.");
}
if (stripos($body, $marker) === false) {
throw new RuntimeException("Erwarteter Seiteninhalt fehlt: {$marker}.");
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
require_once __DIR__ . '/includes/bootstrap.php';
$errors = [];
$prefillNumber = $_GET['n'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
if (!rate_limit_check('ticket_lookup', client_ip(), 10, 5)) {
$errors[] = 'Zu viele Versuche. Bitte warten Sie einige Minuten.';
}
$number = trim($_POST['ticket_number'] ?? '');
$email = trim($_POST['email'] ?? '');
// Bei überschrittenem Limit wird gar nicht erst geprüft (sonst ließe sich die Begrenzung beim Raten umgehen)
if (!$errors) {
$stmt = get_db()->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
$stmt->execute([$number]);
$ticket = $stmt->fetch();
if ($ticket && hash_equals(strtolower($ticket['requester_email']), strtolower($email))) {
session_regenerate_id(true);
$_SESSION['guest_ticket_access'][$ticket['ticket_number']] = true;
redirect('/ticket_view.php?n=' . urlencode($ticket['ticket_number']));
}
$errors[] = 'Ticketnummer und E-Mail-Adresse passen nicht zusammen. Bitte prüfen Sie Ihre Eingabe.';
}
}
$pageTitle = 'Ticket ansehen';
$activePage = 'lookup';
require __DIR__ . '/includes/header_customer.php';
?>
<?php $__banner = get_setting('banner_path'); ?>
<div class="auth-wrap<?= $__banner ? ' has-image' : '' ?>"<?= $__banner ? ' style="background-image:url(' . e($__banner) . ')"' : '' ?>>
<div class="auth-card">
<?php require __DIR__ . '/includes/brand_block.php'; ?>
<h1>Ticket ansehen</h1>
<div class="sub">Ohne Konto: Geben Sie Ihre Ticketnummer und E-Mail-Adresse ein.</div>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<form method="post">
<?= csrf_field() ?>
<label for="ticket_number">Ticketnummer</label>
<input type="text" id="ticket_number" name="ticket_number" value="<?= e($prefillNumber) ?>" placeholder="z.B. 260620-0001" required>
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required>
<div class="btn-row">
<button type="submit" class="btn">Ticket ansehen</button>
</div>
</form>
</div>
</div>
<?php require __DIR__ . '/includes/footer_customer.php'; ?>