Files
deskhub/DeskHub-Webseite/staff/plugins.php
T
2026-11-05 21:21:52 +00:00

530 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/backup.php';
require_once __DIR__ . '/_ui.php';
$staff = require_admin();
$pdo = get_db();
$errors = [];
$permLabels = plugin_known_permissions();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
$slug = (string) ($_POST['slug'] ?? '');
if ($action === 'save_settings' && plugin_strict_mode()) {
$errors[] = 'Im geprüften Modus sind Katalog und Schlüssel fest vorgegeben.';
} elseif ($action === 'save_settings') {
$url = trim((string) ($_POST['catalog_url'] ?? ''));
$key = trim((string) ($_POST['publisher_key'] ?? ''));
if ($url !== '' && ($e = plugin_url_check($url))) $errors[] = 'Katalog-Adresse: ' . $e;
if ($key !== '' && !plugin_public_key_valid($key)) $errors[] = 'Der Herausgeber-Schlüssel ist kein gültiger öffentlicher RSA-Schlüssel (PEM, mindestens 2048 Bit).';
if (!empty($_POST['require_signature']) && $key === '') $errors[] = 'Für „Signatur erforderlich“ muss ein Herausgeber-Schlüssel hinterlegt sein.';
if (!$errors) {
set_setting('plugin_catalog_url', $url);
set_setting('plugin_publisher_key', $key);
set_setting('plugin_require_signature', !empty($_POST['require_signature']) ? '1' : '0');
audit_log($staff['id'], 'Plugin-Einstellungen geändert (Katalog/Herausgeber-Schlüssel)');
flash('success', 'Einstellungen gespeichert.');
redirect('/staff/plugins.php');
}
}
if ($action === 'install') {
$f = $_FILES['plugin_zip'] ?? [];
if (empty($_POST['trust'])) {
$errors[] = 'Bitte bestätigen, dass du der Quelle des Plugins vertraust.';
} elseif (($f['error'] ?? 99) !== UPLOAD_ERR_OK) {
$errors[] = 'Upload-Fehler (Code ' . ($f['error'] ?? 'unbekannt') . ').';
} elseif (strtolower(pathinfo((string) ($f['name'] ?? ''), PATHINFO_EXTENSION)) !== 'zip') {
$errors[] = 'Nur ZIP-Dateien sind erlaubt.';
} else {
$res = plugin_install_zip($pdo, $f['tmp_name'], trim((string) ($_POST['sha256'] ?? '')), trim((string) ($_POST['signature'] ?? '')), 'upload');
if (!$res['ok']) {
$errors[] = $res['error'];
} else {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aktualisiert: ' : 'Plugin installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
}
}
if ($action === 'catalog_update_all') {
$res = plugin_catalog_update_all($pdo);
if ($res['updated']) {
audit_log($staff['id'], 'Plugins aus dem Katalog aktualisiert: ' . implode(', ', $res['updated']));
flash('success', count($res['updated']) . ' Plugin(s) aktualisiert: ' . implode(', ', $res['updated']) . '.');
} elseif (!$res['failed']) {
flash('info', 'Alle Plugins sind aktuell.');
}
foreach ($res['failed'] as $failedSlug => $failedMsg) flash('error', $failedSlug . ': ' . $failedMsg);
redirect('/staff/plugins.php');
}
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
$res = plugin_catalog_install($pdo, $slug);
if ($res['ok']) {
audit_log($staff['id'], ($res['updated'] ? 'Plugin aus dem Katalog aktualisiert: ' : 'Plugin aus dem Katalog installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
flash('success', plugin_install_message($res, plugins_installed($pdo)[$res['slug']] ?? []));
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
if (in_array($action, ['activate', 'deactivate'], true) && plugin_valid_slug($slug)) {
if ($action === 'activate') {
$bk = create_backup(); // vor Datenbank-Änderungen des Plugins
if (!$bk['ok']) $errors[] = 'Backup fehlgeschlagen – das Plugin wurde nicht aktiviert: ' . ($bk['error'] ?? 'unbekannter Fehler');
}
if (!$errors) {
$manifest = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
// Signierte Plugins aus dem Katalog des Herausgebers brauchen keine zusätzliche Bestätigung; manuell hochgeladene schon
$row = plugins_installed($pdo)[$slug] ?? [];
$fromCatalog = ($row['source'] ?? '') === 'catalog' && !empty($row['signature_ok']);
$approve = (!empty($_POST['confirm_permissions']) || $fromCatalog) && $manifest ? $manifest['permissions'] : null;
$res = plugin_set_active($pdo, $slug, $action === 'activate', $approve);
if ($res['ok']) {
audit_log($staff['id'], ($action === 'activate' ? 'Plugin aktiviert: ' : 'Plugin deaktiviert: ') . $slug);
flash('success', $action === 'activate' ? 'Plugin aktiviert.' : 'Plugin deaktiviert.');
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
}
if ($action === 'uninstall' && plugin_valid_slug($slug)) {
$res = plugin_uninstall($pdo, $slug, !empty($_POST['remove_data']));
if ($res['ok']) {
audit_log($staff['id'], 'Plugin entfernt: ' . $slug . (!empty($_POST['remove_data']) ? ' (inkl. Daten)' : ''));
flash('success', 'Plugin entfernt.');
redirect('/staff/plugins.php');
}
$errors[] = $res['error'];
}
}
$installed = plugins_installed($pdo);
$manifests = [];
foreach ($installed as $slug => $row) $manifests[$slug] = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
$disabled = plugins_globally_disabled();
$strict = plugin_strict_mode();
$official = plugin_official_configured();
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh']), 60) : null;
// Katalogeinträge je Plugin (für die Vorschaubilder der installierten Plugins)
$catBySlug = [];
if ($catalog && !empty($catalog['ok'])) foreach ($catalog['plugins'] as $__c) $catBySlug[$__c['slug']] = $__c;
$hasKey = plugin_publisher_key() !== '';
$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
$updates = plugin_updates_available($pdo);
$updBySlug = [];
foreach ($updates as $u) $updBySlug[$u['slug']] = $u;
// Zustand je Plugin: active | inactive | error; dazu Update-Kennzeichen
$stateOf = [];
$counts = ['all' => 0, 'active' => 0, 'inactive' => 0, 'error' => 0, 'update' => 0];
foreach ($installed as $slug => $p) {
$bad = !$manifests[$slug] || (!$p['is_active'] && (int) $p['fail_count'] >= PLUGIN_FAIL_LIMIT);
$st = $bad ? 'error' : ($p['is_active'] ? 'active' : 'inactive');
$stateOf[$slug] = $st;
$counts['all']++;
$counts[$st]++;
if (isset($updBySlug[$slug])) $counts['update']++;
}
// Erste Menüseite eines geladenen Plugins für den Link „Öffnen“
$openUrl = [];
foreach (plugin_state()['menus'] as $__m) {
if (!isset($openUrl[$__m['plugin']])) $openUrl[$__m['plugin']] = '/staff/plugin.php?p=' . rawurlencode($__m['plugin']) . '&page=' . rawurlencode($__m['page']);
}
$tab = in_array($_GET['tab'] ?? '', ['add', 'settings'], true) ? $_GET['tab'] : 'installed';
if ($errors) {
$failedAction = $_POST['action'] ?? '';
if (in_array($failedAction, ['install', 'catalog_install'], true)) $tab = 'add';
if ($failedAction === 'save_settings') $tab = 'settings';
}
$banner = function (array $c, string $name): string {
$inner = !empty($c['preview'])
? '<img src="/staff/catalog_preview.php?kind=plugin&amp;slug=' . e($c['slug']) . '" alt="">'
: '<span>' . e(mb_strtoupper(mb_substr($name, 0, 1))) . '</span>';
return '<div class="pl-hero">' . $inner . '</div>';
};
$pageTitle = 'Plugins';
$activePage = 'plugins';
require __DIR__ . '/_header.php';
?>
<style>
.pl-head { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:6px }
.pl-head h1 { margin:0 }
.pl-tabs { display:flex; gap:4px; border-bottom:1px solid var(--border); margin:14px 0 16px; flex-wrap:wrap }
.pl-tabs button { background:none; border:none; border-bottom:3px solid transparent; padding:9px 14px; font:inherit; font-weight:600; color:var(--muted); cursor:pointer }
.pl-tabs button[aria-selected=true] { color:var(--primary); border-bottom-color:var(--primary) }
.pl-bar { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:12px }
.pl-filters { display:flex; gap:4px 14px; flex-wrap:wrap; font-size:.9rem }
.pl-filters button { background:none; border:none; padding:2px 0; font:inherit; color:var(--primary); cursor:pointer }
.pl-filters button[aria-pressed=true] { color:inherit; font-weight:700; border-bottom:2px solid var(--primary) }
.pl-filters .n { color:var(--muted); font-weight:400 }
.pl-search { max-width:260px; margin:0 }
.pl-card[hidden] { display:none }
.pl-title { display:flex; gap:12px; align-items:flex-start }
.pl-title strong { font-size:1.02rem }
.pl-actions { margin-top:8px; display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; font-size:.86rem }
.pl-actions form { display:inline; margin:0 }
.pl-link { background:none; border:none; padding:0; font:inherit; color:var(--primary); cursor:pointer; text-decoration:none }
.pl-link:hover { text-decoration:underline }
.pl-link.danger { color:#dc2626 }
.pl-meta { font-size:.82rem; color:var(--muted) }
.pl-meta p { margin:0 0 6px; color:inherit }
.pl-tag { display:inline-block; border-radius:20px; padding:1px 9px; font-size:.72rem; font-weight:700; border:1px solid var(--border); margin-right:4px }
.pl-tag.ok { background:#e8f6ee; color:#15803d; border-color:#b7e1c6 }
.pl-tag.warn { background:#fff4d6; color:#92600a; border-color:#f1d38a }
.pl-tag.bad { background:#fdeaea; color:#b91c1c; border-color:#f3b9b9 }
.pl-tag.info { background:#e8f1ff; color:#1d4ed8; border-color:#bcd3f7 }
.pl-err { color:#b45309; font-size:.8rem; margin-top:6px; word-break:break-word }
.pl-empty { padding:26px; text-align:center; color:var(--muted) }
.pl-empty[hidden] { display:none }
.pl-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(420px,1fr)); gap:16px }
.pl-card { background:var(--card,#fff); border:1px solid var(--border); border-radius:6px; display:flex; flex-direction:column; overflow:hidden }
.pl-card-top { display:grid; grid-template-columns:128px minmax(0,1fr) auto; gap:16px; padding:18px 18px 14px; align-items:start }
.pl-card.st-active { border-top:3px solid #15803d }
.pl-card.st-error { border-top:3px solid #dc2626 }
.pl-card-icon img, .pl-card-icon div { width:128px !important; height:128px !important; border-radius:14px !important; object-fit:cover; font-size:2.6rem !important }
.pl-card-main h4 { margin:0 0 8px; font-size:1.05rem; color:var(--primary) }
.pl-card-main p { margin:0 0 8px; font-size:.86rem; line-height:1.45 }
.pl-card-main p.by { font-style:italic; color:var(--muted); font-size:.82rem }
.pl-card-act { display:flex; flex-direction:column; align-items:flex-end; gap:12px }
.pl-card-act .btn { white-space:nowrap }
.pl-installed-badge { display:inline-block; padding:8px 14px; border-radius:8px; background:#e8f6ee; color:#15803d; border:1px solid #b7e1c6; font-weight:700; font-size:.88rem; white-space:nowrap }
.pl-card-act .pl-install { background:var(--primary); color:#fff; font-weight:700 }
.pl-more { font-size:.85rem; color:var(--primary) }
.pl-card-main p:not(.by) { display:-webkit-box; -webkit-line-clamp:4; line-clamp:4; -webkit-box-orient:vertical; overflow:hidden }
.pl-card-detail { display:none }
#pl-modal { margin:auto; border:none; border-radius:8px; padding:0; width:min(900px, 94vw); max-height:90vh; background:var(--card,#fff); color:inherit; overflow:hidden }
#pl-modal::backdrop { background:rgba(0,0,0,.65) }
#pl-modal .pl-modal-inner { display:flex; flex-direction:column; max-height:90vh }
#pl-modal .pl-card-detail { display:flex; flex-direction:column; min-height:0; flex:1; overflow:hidden; padding:0 }
#pl-modal .pl-modal-body { display:grid; grid-template-columns:minmax(0,1fr) 230px; overflow:auto; min-height:0 }
#pl-modal .pl-modal-text { padding:20px 24px; font-size:.92rem; line-height:1.55 }
#pl-modal .pl-modal-text h3 { margin:0 0 10px; font-size:1.25rem }
#pl-modal .pl-modal-text h4 { margin:16px 0 6px }
#pl-modal .pl-modal-side { padding:20px; background:var(--bg-soft,#f6f8fa); border-left:1px solid var(--border-soft); font-size:.84rem }
#pl-modal .pl-modal-side p { margin:0 0 10px }
#pl-modal .pl-modal-foot { padding:12px 20px; border-top:1px solid var(--border-soft); display:flex; flex-direction:row-reverse; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; background:var(--bg-soft,#f6f8fa) }
.pl-hero { min-height:110px; max-height:42vh; flex:none; background:#0b2a30; display:flex; align-items:center; justify-content:center; overflow:hidden }
.pl-hero img { display:block; width:100%; height:auto; max-height:42vh; object-fit:contain }
.pl-hero span { font-size:3.2rem; font-weight:700; color:#fff }
#pl-modal .pl-modal-foot form { margin:0; display:flex; align-items:center; gap:10px; flex-wrap:wrap }
#pl-modal .pl-modal-foot .pl-confirm { margin:0 }
#pl-modal .pl-modal-foot .btn.danger { padding:7px 12px; font-size:.85rem }
.pl-modal-close { position:absolute; top:10px; right:10px; z-index:2; width:34px; height:34px; border-radius:50%; border:none; background:rgba(0,0,0,.55); color:#fff; font-size:1.2rem; cursor:pointer; line-height:1 }
@media (max-width:640px) { #pl-modal .pl-modal-body { grid-template-columns:1fr } #pl-modal .pl-modal-side { border-left:none; border-top:1px solid var(--border-soft) } }
.pl-card .pl-foot { margin-top:auto; display:flex; justify-content:space-between; align-items:flex-end; gap:12px; flex-wrap:wrap; padding:14px 18px; background:var(--bg-soft,#f6f8fa); border-top:1px solid var(--border-soft); font-size:.8rem }
@media (max-width:560px) { .pl-grid { grid-template-columns:1fr } .pl-card-top { grid-template-columns:72px minmax(0,1fr) } .pl-card-icon img, .pl-card-icon div { width:72px !important; height:72px !important } .pl-card-act { grid-column:1 / -1; flex-direction:row; align-items:center } }
.pl-confirm { display:block; font-size:.76rem; margin-bottom:4px }
[data-pl-panel][hidden] { display:none }
@media (max-width:760px) { .pl-search { max-width:none; width:100% } }
</style>
<div class="pl-head">
<h1>Plugins</h1>
<button type="button" class="btn secondary" data-pl-tab="add">Neu hinzufügen</button>
</div>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($updates): ?>
<div class="flash warning"><strong>Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['installed'] . ' → ' . $u['available'], $updates))) ?>. <a href="#" data-pl-tab="installed" data-pl-filter="update">Anzeigen</a>
<form method="post" style="display:inline;margin:0 0 0 10px"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_update_all"><button class="btn" type="submit" style="padding:3px 12px;font-size:.82rem" onclick="this.textContent='⏳ Wird aktualisiert …'"><?= count($updates) === 1 ? 'Jetzt aktualisieren' : 'Alle aktualisieren' ?></button></form></div>
<?php endif; ?>
<?php if ($disabled): ?>
<div class="flash error">Alle Plugins sind abgeschaltet (Datei <code>plugins/.disabled</code> oder Einstellung <code>plugins.enabled = false</code>). Bereits aktive Plugins werden nicht geladen.</div>
<?php endif; ?>
<div class="pl-tabs" role="tablist">
<button type="button" role="tab" data-pl-tab="installed" aria-selected="<?= $tab === 'installed' ? 'true' : 'false' ?>">Installiert (<?= $counts['all'] ?>)</button>
<button type="button" role="tab" data-pl-tab="add" aria-selected="<?= $tab === 'add' ? 'true' : 'false' ?>">Plugin hinzufügen</button>
<?php if (!$strict): ?><button type="button" role="tab" data-pl-tab="settings" aria-selected="<?= $tab === 'settings' ? 'true' : 'false' ?>">Katalog &amp; Schlüssel</button><?php endif; ?>
</div>
<!-- Installiert -->
<section data-pl-panel="installed" <?= $tab === 'installed' ? '' : 'hidden' ?>>
<div class="pl-bar">
<div class="pl-filters" role="group" aria-label="Filter">
<button type="button" data-pl-filter="all" aria-pressed="true">Alle <span class="n">(<?= $counts['all'] ?>)</span></button>
<button type="button" data-pl-filter="active" aria-pressed="false">Aktiv <span class="n">(<?= $counts['active'] ?>)</span></button>
<button type="button" data-pl-filter="inactive" aria-pressed="false">Inaktiv <span class="n">(<?= $counts['inactive'] ?>)</span></button>
<?php if ($counts['error']): ?><button type="button" data-pl-filter="error" aria-pressed="false">Mit Fehler <span class="n">(<?= $counts['error'] ?>)</span></button><?php endif; ?>
<?php if ($counts['update']): ?><button type="button" data-pl-filter="update" aria-pressed="false">Update verfügbar <span class="n">(<?= $counts['update'] ?>)</span></button><?php endif; ?>
</div>
<input type="search" class="pl-search" id="pl-search" placeholder="Installierte Plugins durchsuchen …" aria-label="Installierte Plugins durchsuchen">
</div>
<div class="pl-grid" id="pl-installed">
<?php foreach ($installed as $slug => $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; $st = $stateOf[$slug]; $upd = $updBySlug[$slug] ?? null;
$trusted = ($p['source'] ?? '') === 'catalog' && !empty($p['signature_ok']); ?>
<div class="pl-card st-<?= e($st) ?>" data-state="<?= e($st) ?>" data-update="<?= $upd ? '1' : '0' ?>" data-text="<?= e(mb_strtolower($p['name'] . ' ' . $slug . ' ' . ($m['description'] ?? '') . ' ' . ($m['author'] ?? ''))) ?>">
<div class="pl-card-top">
<div class="pl-card-icon"><?= catalog_thumb('plugin', $catBySlug[$slug] ?? ['slug' => $slug, 'name' => $p['name']]) ?></div>
<div class="pl-card-main">
<h4><?= e($p['name']) ?></h4>
<p><?= $m && $m['description'] !== '' ? e($m['description']) : (!$m ? '<span style="color:#dc2626">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</span>' : '') ?></p>
<p class="by">Von <span><?= $m && $m['author'] !== '' ? e($m['author']) : 'unbekannt' ?></span></p>
<div>
<?php if ($p['is_active']): ?><span class="pl-tag ok">Aktiv</span>
<?php elseif ($st === 'error'): ?><span class="pl-tag bad"><?= $m ? 'Wegen Fehlern deaktiviert' : 'Ungültig' ?></span>
<?php else: ?><span class="pl-tag">Inaktiv</span><?php endif; ?>
<?php if ($upd): ?><span class="pl-tag warn">Update <span><?= e($upd['available']) ?></span></span><?php endif; ?>
</div>
</div>
<div class="pl-card-act">
<?php if ($p['is_active']): ?>
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="deactivate"><button class="btn secondary" type="submit">Deaktivieren</button></form>
<?php if (isset($openUrl[$slug])): ?><a class="pl-more" href="<?= e($openUrl[$slug]) ?>">Öffnen</a><?php endif; ?>
<?php else: ?>
<form method="post" style="margin:0;text-align:right" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
<?php if ($perms && !$trusted): ?><label class="pl-confirm"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
</form>
<?php endif; ?>
<?php if ($upd): ?><form method="post" style="margin:0;text-align:right"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($slug) ?>"><button class="pl-more" type="submit" style="background:none;border:0;padding:0;cursor:pointer;font:inherit;text-decoration:underline" onclick="this.textContent='⏳ Wird aktualisiert …'">Jetzt aktualisieren</button></form><?php endif; ?>
<a href="#" class="pl-more">Weitere Details</a>
</div>
</div>
<div class="pl-card-detail" hidden>
<?= $banner($catBySlug[$slug] ?? ['slug' => $slug], $p['name']) ?>
<div class="pl-modal-body">
<div class="pl-modal-text">
<h3><?= e($p['name']) ?></h3>
<p><?= $m && $m['description'] !== '' ? nl2br(e($m['description'])) : '' ?></p>
<?php if (!$m): ?><p style="color:#dc2626">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</p><?php endif; ?>
<?php if ($perms): ?>
<h4>Berechtigungen</h4>
<ul><?php foreach ($perms as $perm): ?><li><?= e($permLabels[$perm] ?? $perm) ?> <span style="opacity:.7">(<?= e($perm) ?>)</span></li><?php endforeach; ?></ul>
<?php elseif ($m): ?><p>Dieses Plugin fordert keine Berechtigungen an.</p><?php endif; ?>
<?php if (!empty($p['last_error'])): ?><h4>Letzte Fehlermeldung</h4><div class="pl-err"><?= e(mb_strimwidth((string) $p['last_error'], 0, 300, '…')) ?></div><?php endif; ?>
</div>
<aside class="pl-modal-side">
<p><strong>Version:</strong> <?= e($p['version']) ?></p>
<p><strong>Autor:</strong> <?= $m && $m['author'] !== '' ? e($m['author']) : 'unbekannt' ?></p>
<p><strong>Kennung:</strong> <code><?= e($slug) ?></code></p>
<p><strong>Zustand:</strong> <?= $p['is_active'] ? 'Aktiv' : ($st === 'error' ? 'Fehler' : 'Inaktiv') ?></p>
<p><strong>Herkunft:</strong> <?= ($p['source'] ?? '') === 'catalog' ? 'Aus dem Katalog' : (!empty($p['signature_ok']) ? 'Manuell hochgeladen' : 'Nicht vom Herausgeber geprüft') ?></p>
<p><strong>Signatur:</strong> <?= !empty($p['signature_ok']) ? '✓ vom Herausgeber signiert' : 'nicht signiert (eigenes Plugin)' ?></p>
<?php if ($upd): ?><p><strong>Update:</strong> <span><?= e($upd['available']) ?></span> verfügbar</p><?php endif; ?>
</aside>
</div>
<div class="pl-modal-foot">
<?php if ($p['is_active']): ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="deactivate"><button class="btn secondary" type="submit">Deaktivieren</button></form>
<?php else: ?>
<form method="post" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
<?php if ($perms && !$trusted): ?><label class="pl-confirm"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
</form>
<?php endif; ?>
<form method="post" onsubmit="return confirm('Plugin wirklich entfernen?')">
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="uninstall">
<label class="pl-confirm"><input type="checkbox" name="remove_data" value="1"> Auch die Daten des Plugins löschen</label>
<button type="submit" class="btn danger">Plugin entfernen</button>
</form>
</div>
</div>
<div class="pl-foot">
<div>
<div>Version <strong><?= e($p['version']) ?></strong></div>
<div class="muted"><?= $p['is_active'] ? 'Aktiv' : ($st === 'error' ? 'Fehler' : 'Inaktiv') ?></div>
</div>
<div style="text-align:right">
<div><?= !empty($p['signature_ok']) ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span> vom Herausgeber' : '<span style="color:#b45309;font-weight:600">Eigenes Plugin</span>' ?></div>
<div class="muted"><?= $perms ? count($perms) . ' Berechtigung' . (count($perms) === 1 ? '' : 'en') : 'Keine Berechtigungen' ?></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="pl-empty" id="pl-none" <?= $installed ? 'hidden' : '' ?>><?= $installed ? 'Kein Plugin passt zu Filter oder Suche.' : 'Noch keine Plugins installiert. Unter „Plugin hinzufügen“ findest du den Katalog und den ZIP-Upload.' ?></div>
</section>
<!-- Plugin hinzufügen -->
<section data-pl-panel="add" <?= $tab === 'add' ? '' : 'hidden' ?>>
<div class="card">
<div class="pl-bar" style="margin-bottom:0">
<h3 style="margin:0"><?= $official ? 'Geprüfte Plugins' : 'Plugin-Katalog' ?></h3>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?php if ($catalog && !empty($catalog['ok'])): ?><input type="search" class="pl-search" id="pl-search-cat" placeholder="Katalog durchsuchen …" aria-label="Katalog durchsuchen"><?php endif; ?>
<?php if ($catalog): ?><a class="btn secondary" href="/staff/plugins.php?tab=add&amp;refresh=1">Katalog aktualisieren</a><?php endif; ?>
</div>
</div>
<?php if (!$catalog): ?>
<p class="muted" style="font-size:.85rem">Es ist noch keine Katalog-Adresse hinterlegt. Trage unter „Katalog &amp; Schlüssel“ die Adresse der Plugin-Webseite ein, dann könnt ihr Plugins direkt von dort installieren.</p>
<?php elseif (!$catalog['ok']): ?>
<div class="flash error" style="margin-top:12px"><?= e($catalog['error']) ?></div>
<?php else: ?>
<?php if (!empty($catalog['cached_at'])): ?><div class="muted" style="font-size:.76rem;margin:6px 0 12px">Stand: <span><?= e(date('d.m.Y H:i', (int) $catalog['cached_at'])) ?></span></div><?php endif; ?>
<div class="pl-grid" id="pl-catalog">
<?php foreach ($catalog['plugins'] as $c): $have = $installed[$c['slug']] ?? null; $newer = $have && version_compare($c['version'], $have['version'], '>'); ?>
<div class="pl-card" data-text="<?= e(mb_strtolower($c['name'] . ' ' . $c['slug'] . ' ' . $c['description'] . ' ' . $c['author'])) ?>">
<div class="pl-card-top">
<div class="pl-card-icon"><?= catalog_thumb('plugin', $c) ?></div>
<div class="pl-card-main">
<h4><?= e($c['name']) ?></h4>
<p><?= e($c['description']) ?></p>
<p class="by">Von <span><?= $c['author'] !== '' ? e($c['author']) : 'unbekannt' ?></span></p>
<div>
<?php if (!empty($c['language'])): ?><span class="pl-tag info" title="Sprachpaket: nach der Aktivierung erscheint die Flagge zum Umschalten">🌐 Sprache: <?= e($c['language']['name']) ?></span><?php endif; ?>
<?php if (!empty($c['featured'])): ?><span class="pl-tag warn" title="Vom Herausgeber empfohlen">★ Empfohlen</span><?php endif; ?>
</div>
</div>
<div class="pl-card-act">
<?php if ($have && !$newer): ?><span class="pl-installed-badge">✓ Installiert</span>
<?php elseif ($strict && $c['signature'] === ''): ?><span class="muted" style="font-size:.78rem;text-align:right">nicht signiert,<br>nicht installierbar</span>
<?php else: ?>
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($c['slug']) ?>">
<button class="btn pl-install" type="submit"><?= $newer ? 'Jetzt aktualisieren' : 'Jetzt installieren' ?></button></form>
<?php endif; ?>
<a href="#" class="pl-more">Weitere Details</a>
</div>
</div>
<div class="pl-card-detail" hidden>
<?= $banner($c, $c['name']) ?>
<div class="pl-modal-body">
<div class="pl-modal-text">
<h3><?= e($c['name']) ?></h3>
<p><?= nl2br(e($c['description'])) ?></p>
<?php if ($c['permissions']): ?>
<h4>Berechtigungen</h4>
<ul><?php foreach ($c['permissions'] as $perm): ?><li><?= e($permLabels[$perm] ?? $perm) ?> <span style="opacity:.7">(<?= e($perm) ?>)</span></li><?php endforeach; ?></ul>
<?php else: ?><p>Dieses Plugin fordert keine Berechtigungen an.</p><?php endif; ?>
</div>
<aside class="pl-modal-side">
<p><strong>Version:</strong> <?= e($c['version']) ?></p>
<p><strong>Autor:</strong> <?= $c['author'] !== '' ? e($c['author']) : 'unbekannt' ?></p>
<p><strong>Kennung:</strong> <code><?= e($c['slug']) ?></code></p>
<p><strong>Installiert:</strong> <?= $have ? e($have['version']) : 'nein' ?></p>
<p><strong>Signatur:</strong> <?= $c['signature'] !== '' ? ($hasKey ? '✓ vom Herausgeber signiert' : 'vorhanden, Schlüssel fehlt') : 'nicht signiert' ?></p>
</aside>
</div>
<div class="pl-modal-foot">
<?php if ($have && !$newer): ?><span class="pl-installed-badge">✓ Installiert</span>
<?php elseif ($strict && $c['signature'] === ''): ?><span class="muted">nicht signiert, nicht installierbar</span>
<?php else: ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($c['slug']) ?>">
<button class="btn" type="submit"><?= $newer ? 'Jetzt aktualisieren' : 'Jetzt installieren' ?></button></form>
<?php endif; ?>
</div>
</div>
<div class="pl-foot">
<div>
<div>Version <strong><?= e($c['version']) ?></strong></div>
<div class="muted"><?= $have ? 'Installiert: <span>' . e($have['version']) . '</span>' : 'Nicht installiert' ?></div>
</div>
<div style="text-align:right">
<div><?= $c['signature'] !== '' ? ($hasKey ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span> vom Herausgeber' : '<span style="color:#b45309;font-weight:600">Signatur vorhanden, Schlüssel fehlt</span>') : '<span style="color:#b45309;font-weight:600">Nicht signiert</span>' ?></div>
<div class="muted"><?= $c['permissions'] ? count($c['permissions']) . ' Berechtigung' . (count($c['permissions']) === 1 ? '' : 'en') : 'Keine Berechtigungen' ?></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (!$catalog['plugins']): ?><div class="pl-empty">Der Katalog enthält keine Plugins.</div><?php else: ?><div class="pl-empty" id="pl-cat-none" hidden>Kein Plugin im Katalog passt zur Suche.</div><?php endif; ?>
<?php endif; ?>
</div>
<div class="card" style="margin-top:20px">
<h3 style="margin-top:0">Eigenes Plugin hochladen</h3>
<p class="muted" style="font-size:.85rem">Hier könnt ihr eigene Plugins als ZIP installieren oder aktualisieren, auch ohne Signatur. Diese Plugins sind <strong>nicht vom Herausgeber geprüft</strong> und laufen mit allen Rechten des Systems, auch mit Zugriff auf die gesamte Datenbank. Installiert nur Code, den ihr selbst geschrieben oder geprüft habt.</p>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?><input type="hidden" name="action" value="install">
<label for="plugin_zip">ZIP-Datei</label>
<input type="file" id="plugin_zip" name="plugin_zip" accept=".zip" required>
<details style="margin-top:8px"><summary class="muted" style="cursor:pointer;font-size:.85rem">Signatur und Prüfsumme (optional)</summary>
<label for="signature">Signatur (optional, Base64)</label>
<input type="text" id="signature" name="signature" placeholder="Nur ausfüllen, wenn der Anbieter eine Signatur nennt" autocomplete="off">
<label for="sha256">Erwartete Prüfsumme (SHA-256, optional)</label>
<input type="text" id="sha256" name="sha256" placeholder="Nur ausfüllen, wenn der Anbieter eine Prüfsumme nennt" autocomplete="off">
</details>
<label style="margin-top:10px"><input type="checkbox" name="trust" value="1" required> Ich kenne den Inhalt dieses Plugins und vertraue ihm. Mir ist bewusst, dass es mit allen Rechten des Systems läuft.</label>
<div class="btn-row" style="margin-top:12px"><button type="submit" class="btn">Hochladen &amp; installieren</button></div>
</form>
<p class="muted" style="font-size:.82rem">Das Plugin wird nach der Installation nicht automatisch aktiviert. Vor dem Aktivieren bestätigt ihr die Berechtigungen, die es anfordert.</p>
</div>
</section>
<!-- Katalog und Schlüssel -->
<?php if (!$strict): ?>
<section data-pl-panel="settings" <?= $tab === 'settings' ? '' : 'hidden' ?>>
<div class="card">
<h3 style="margin-top:0">Katalog und Herausgeber-Schlüssel</h3>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="save_settings">
<label for="catalog_url">Katalog-Adresse (https)</label>
<input type="text" id="catalog_url" name="catalog_url" value="<?= e(plugin_catalog_url()) ?>" placeholder="https://plugins.example.de/catalog.php?type=plugins">
<label for="publisher_key">Öffentlicher Herausgeber-Schlüssel (PEM)</label>
<textarea id="publisher_key" name="publisher_key" rows="6" style="font-family:monospace;font-size:.78rem" placeholder="-----BEGIN PUBLIC KEY-----"><?= e(plugin_publisher_key()) ?></textarea>
<label style="margin-top:8px"><input type="checkbox" name="require_signature" value="1" <?= plugin_signatures_required() ? 'checked' : '' ?>> Nur signierte Plugins installieren (empfohlen, sobald ein Schlüssel hinterlegt ist)</label>
<p class="muted" style="font-size:.82rem">Den Schlüssel bekommst du von der Plugin-Webseite. Ist er hinterlegt, prüft DeskHub bei jeder Installation, ob das Paket wirklich vom Herausgeber stammt und unverändert ist.</p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</div>
</section>
<?php endif; ?>
<dialog id="pl-modal" aria-label="Plugin-Details"><button type="button" class="pl-modal-close" aria-label="Schließen">✕</button><div class="pl-modal-inner" id="pl-modal-content"></div></dialog>
<script>
(function () {
var panels = document.querySelectorAll('[data-pl-panel]');
var filter = 'all', q = '';
function show(name) {
panels.forEach(function (p) { p.hidden = p.getAttribute('data-pl-panel') !== name; });
document.querySelectorAll('.pl-tabs [data-pl-tab]').forEach(function (b) { b.setAttribute('aria-selected', b.getAttribute('data-pl-tab') === name ? 'true' : 'false'); });
try { history.replaceState(null, '', location.pathname + (name === 'installed' ? '' : '?tab=' + name)); } catch (e) {}
}
function apply() {
var rows = document.querySelectorAll('#pl-installed .pl-card'), shown = 0;
rows.forEach(function (r) {
var ok = (filter === 'all' || (filter === 'update' ? r.dataset.update === '1' : r.dataset.state === filter)) && (q === '' || r.dataset.text.indexOf(q) !== -1);
r.hidden = !ok; if (ok) shown++;
});
var none = document.getElementById('pl-none');
if (none && rows.length) none.hidden = shown > 0;
document.querySelectorAll('.pl-filters [data-pl-filter]').forEach(function (b) { b.setAttribute('aria-pressed', b.getAttribute('data-pl-filter') === filter ? 'true' : 'false'); });
}
document.querySelectorAll('[data-pl-tab]').forEach(function (b) {
b.addEventListener('click', function (ev) {
ev.preventDefault();
show(b.getAttribute('data-pl-tab'));
if (b.hasAttribute('data-pl-filter')) { filter = b.getAttribute('data-pl-filter'); apply(); }
});
});
document.querySelectorAll('.pl-filters [data-pl-filter]').forEach(function (b) {
b.addEventListener('click', function () { filter = b.getAttribute('data-pl-filter'); apply(); });
});
var s = document.getElementById('pl-search');
if (s) s.addEventListener('input', function () { q = s.value.trim().toLowerCase(); apply(); });
var modal = document.getElementById('pl-modal'), modalBody = document.getElementById('pl-modal-content');
document.querySelectorAll('.pl-more[href="#"]:not([data-pl-tab])').forEach(function (a) {
a.addEventListener('click', function (ev) {
ev.preventDefault();
var d = a.closest('.pl-card').querySelector('.pl-card-detail');
if (!d || !modal || !modal.showModal) return;
modalBody.innerHTML = d.outerHTML;
var inner = modalBody.firstElementChild; inner.removeAttribute('hidden');
modal.showModal();
});
});
if (modal) {
modal.querySelector('.pl-modal-close').addEventListener('click', function () { modal.close(); });
modal.addEventListener('click', function (ev) { if (ev.target === modal) modal.close(); });
}
var sc = document.getElementById('pl-search-cat');
if (sc) sc.addEventListener('input', function () {
var v = sc.value.trim().toLowerCase(), n = 0;
document.querySelectorAll('#pl-catalog .pl-card').forEach(function (c) { var ok = v === '' || c.dataset.text.indexOf(v) !== -1; c.hidden = !ok; if (ok) n++; });
var none = document.getElementById('pl-cat-none'); if (none) none.hidden = n > 0;
});
apply();
})();
</script>
<?php require __DIR__ . '/_footer.php'; ?>