Files
deskhub/plugin-webseite/admin.php
T
2026-11-05 21:22:26 +00:00

350 lines
26 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
declare(strict_types=1);
/** Verwaltung der Webseite: Anmelden, Pakete veröffentlichen, Versionen entfernen. */
require_once __DIR__ . '/inc/layout.php';
require_once __DIR__ . '/inc/push.php';
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'httponly' => true, 'samesite' => 'Strict']);
session_name('plugin_site_admin');
session_start();
$cfg = site_config();
$error = null;
$message = null;
function admin_csrf(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(24));
}
function admin_check_csrf(): bool
{
return hash_equals($_SESSION['csrf'] ?? '', (string) ($_POST['csrf'] ?? ''));
}
/** Höchstens 5 Fehlversuche pro IP in 15 Minuten. */
function admin_throttled(bool $record = false): bool
{
$file = site_storage() . '/login_attempts.json';
$data = is_file($file) ? (json_decode((string) file_get_contents($file), true) ?: []) : [];
$ip = $_SERVER['REMOTE_ADDR'] ?? '?';
$now = time();
$data[$ip] = array_values(array_filter($data[$ip] ?? [], fn($t) => $t > $now - 900));
if ($record) { $data[$ip][] = $now; file_put_contents($file, json_encode($data), LOCK_EX); }
return count($data[$ip]) >= 5;
}
$loggedIn = !empty($_SESSION['admin']);
/** PHP verwirft bei zu großem Upload das ganze Formular (kein $_POST, kein Token) – das ist keine Abmeldung. */
$tooBig = $_SERVER['REQUEST_METHOD'] === 'POST' && !$_POST && !$_FILES && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0;
if ($tooBig) {
$error = 'Die Datei ist größer als die Servergrenze (post_max_size ' . ini_get('post_max_size') . ', upload_max_filesize ' . ini_get('upload_max_filesize') . '). Bitte die Grenzen erhöhen bzw. das Docker-Image neu bauen. Du bleibst angemeldet.';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$tooBig) {
$action = $_POST['action'] ?? '';
if ($action === 'login') {
if ($cfg['admin_password_hash'] === '') {
$error = 'Die Verwaltung ist nicht eingerichtet: In config.php fehlt admin_password_hash.';
} elseif (admin_throttled()) {
$error = 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.';
} elseif (hash_equals((string) $cfg['admin_user'], (string) ($_POST['user'] ?? '')) && password_verify((string) ($_POST['password'] ?? ''), (string) $cfg['admin_password_hash'])) {
session_regenerate_id(true);
$_SESSION['admin'] = true;
header('Location: admin.php');
exit;
} else {
admin_throttled(true);
usleep(400000);
$error = 'Benutzername oder Passwort ist falsch.';
}
} elseif (!$loggedIn || !admin_check_csrf()) {
http_response_code(403);
$error = 'Ungültige Anfrage. Bitte neu anmelden.';
$loggedIn = false;
} elseif ($action === 'logout') {
session_destroy();
header('Location: admin.php');
exit;
} elseif ($action === 'publish') {
$zip = $_FILES['package'] ?? [];
if (($zip['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$error = 'Upload-Fehler (Code ' . ($zip['error'] ?? '?') . '). Prüft upload_max_filesize und post_max_size.';
} elseif (strtolower(pathinfo((string) $zip['name'], PATHINFO_EXTENSION)) !== 'zip') {
$error = 'Bitte eine ZIP-Datei hochladen.';
} else {
$prev = $_FILES['preview'] ?? [];
$hasPreview = ($prev['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;
$res = site_publish((string) ($_POST['type'] ?? ''), $zip['tmp_name'], $_POST, $hasPreview ? $prev['tmp_name'] : null, $hasPreview ? (string) $prev['name'] : null);
if ($res['ok']) $message = "Veröffentlicht: {$res['slug']} {$res['version']} (" . ($res['signed'] ? 'signiert' : 'NICHT signiert, kein Signierschlüssel eingerichtet') . ').';
else $error = $res['error'];
}
} elseif ($action === 'publish_app') {
$upload = $_FILES['app_file'] ?? [];
if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$error = 'App-Upload fehlgeschlagen (Code ' . ($upload['error'] ?? '?') . '). Prüft upload_max_filesize, post_max_size und die Servergrenze.';
} else {
$res = site_publish_app((string) ($_POST['platform'] ?? ''), (string) $upload['tmp_name'], trim((string) ($_POST['version'] ?? '')), (string) ($_POST['notes'] ?? ''));
if ($res['ok']) $message = 'App-Version ' . $res['version'] . ' veröffentlicht.';
else $error = $res['error'];
}
} elseif ($action === 'delete_app') {
$id = (int) ($_POST['id'] ?? 0);
$stmt = site_db()->prepare('SELECT platform,file FROM app_releases WHERE id = ?');
$stmt->execute([$id]);
$release = $stmt->fetch();
if ($release) {
site_db()->prepare('DELETE FROM app_releases WHERE id = ?')->execute([$id]);
@unlink(site_storage() . '/apps/' . $release['platform'] . '/' . basename($release['file']));
$message = 'App-Version entfernt.';
} else $error = 'App-Version wurde nicht gefunden.';
} elseif (in_array($action, ['push_block', 'push_unblock', 'push_delete'], true)) {
$pid = (string) ($_POST['id'] ?? '');
if (preg_match('/^[a-f0-9]{24}$/', $pid)) {
$pp = push_db();
if ($action === 'push_delete') $pp->prepare('DELETE FROM push_installs WHERE id = ?')->execute([$pid]);
else $pp->prepare('UPDATE push_installs SET blocked = ? WHERE id = ?')->execute([$action === 'push_block' ? 1 : 0, $pid]);
$message = ['push_block' => 'Installation gesperrt.', 'push_unblock' => 'Installation entsperrt.', 'push_delete' => 'Installation gelöscht (sie meldet sich beim nächsten Senden von selbst neu an).'][$action];
} else {
$error = 'Ungültige Installation.';
}
} elseif ($action === 'set_preview' || $action === 'remove_preview') {
$img = $_FILES['preview'] ?? [];
if ($action === 'set_preview' && ($img['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$error = 'Bitte ein Bild auswählen (PNG, JPG oder WebP, höchstens 1 MB).';
} else {
$res = site_set_preview((int) ($_POST['id'] ?? 0), $action === 'set_preview' ? (string) $img['tmp_name'] : null, $action === 'set_preview' ? (string) $img['name'] : null);
if ($res['ok']) $message = $action === 'set_preview' ? 'Vorschaubild geändert. Es bleibt bei weiteren Versionen erhalten.' : 'Vorschaubild entfernt.';
else $error = $res['error'];
}
} elseif ($action === 'feature' || $action === 'unfeature') {
$message = site_set_featured((int) ($_POST['id'] ?? 0), $action === 'feature') ? ($action === 'feature' ? 'Als „Empfohlen“ markiert. DeskHub zeigt es nach der nächsten Katalog-Aktualisierung (spätestens in wenigen Minuten).' : 'Empfehlung entfernt.') : null;
$error = $message ? null : 'Eintrag nicht gefunden.';
} elseif ($action === 'delete') {
$message = site_delete_version((int) ($_POST['id'] ?? 0)) ? 'Version entfernt.' : null;
$error = $message ? null : 'Version nicht gefunden.';
}
}
$tabs = site_tabs();
$tab = (string) ($_GET['tab'] ?? 'overview');
if (!isset($tabs[$tab])) $tab = 'overview';
$csrf = $loggedIn ? admin_csrf() : '';
/** Kleines POST-Formular mit nur einem Knopf. */
function admin_btn(string $action, array $fields, string $label, string $class = 'link', string $confirm = ''): string
{
$h = '<form method="post" class="inline"' . ($confirm !== '' ? ' data-confirm="' . site_h($confirm) . '"' : '') . '><input type="hidden" name="action" value="' . site_h($action) . '"><input type="hidden" name="csrf" value="' . site_h(admin_csrf()) . '">';
foreach ($fields as $k => $v) $h .= '<input type="hidden" name="' . site_h($k) . '" value="' . site_h((string) $v) . '">';
return $h . '<button type="submit" class="' . site_h($class) . '">' . site_h($label) . '</button></form>';
}
site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $loggedIn);
?>
<?php if ($error): ?><div class="alert err"><?= site_h($error) ?></div><?php endif; ?>
<?php if ($message): ?><div class="alert okbox"><?= site_h($message) ?></div><?php endif; ?>
<?php if (!$loggedIn): ?>
<form method="post" class="card form narrow">
<div><h1 class="h1-sm">Anmelden</h1><p class="sub">Verwaltung der DeskHub-Plugins, Themes und Apps.</p></div>
<input type="hidden" name="action" value="login">
<label>Benutzername<input type="text" name="user" autocomplete="username" required autofocus></label>
<label>Passwort<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">Anmelden</button>
</form>
<?php else:
$signed = site_public_key() !== '';
$appReleases = site_app_releases();
$latestApp = array_fill_keys(array_keys(site_app_platforms()), null);
foreach ($appReleases as $r) if (array_key_exists($r['platform'], $latestApp) && $latestApp[$r['platform']] === null) $latestApp[$r['platform']] = $r;
$pushSa = push_service_account();
$pushRows = push_db()->query('SELECT * FROM push_installs ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 200')->fetchAll();
$plugins = site_items('plugin');
$themes = site_items('theme');
?>
<?php if ($tab === 'overview'): ?>
<div class="pagehead"><div><h1>Übersicht</h1><p class="sub">Alles Wichtige auf einen Blick: veröffentlichte Pakete, aktuelle App-Versionen und der Zustand der Dienste.</p></div></div>
<div class="stats">
<a class="stat" href="admin.php?tab=packages"><div class="k">Plugins</div><div class="v"><?= count($plugins) ?></div><div class="s">veröffentlicht</div></a>
<a class="stat" href="admin.php?tab=packages"><div class="k">Themes</div><div class="v"><?= count($themes) ?></div><div class="s">veröffentlicht</div></a>
<a class="stat" href="admin.php?tab=apps"><div class="k">Windows</div><div class="v"><?= $latestApp['windows'] ? site_h($latestApp['windows']['version']) : '–' ?></div><div class="s">aktuelle App-Version</div></a>
<a class="stat" href="admin.php?tab=apps"><div class="k">Android</div><div class="v"><?= $latestApp['android'] ? site_h($latestApp['android']['version']) : '–' ?></div><div class="s">aktuelle App-Version</div></a>
<a class="stat" href="admin.php?tab=server"><div class="k">DeskHub-Server</div><div class="v"><?= $latestApp['server'] ? site_h($latestApp['server']['version']) : '–' ?></div><div class="s">aktuelles System-Update</div></a>
<a class="stat" href="admin.php?tab=push"><div class="k">Push-Geräte</div><div class="v"><?= count($pushRows) ?></div><div class="s">angemeldete Installationen</div></a>
</div>
<div class="cols">
<section class="card">
<header><div><h2>Systemstatus</h2><p>Einrichtung dieser Webseite</p></div></header>
<div class="status <?= $signed ? 'ok' : 'warn' ?>"><i class="dot"></i><div><strong>Signierschlüssel</strong><span><?= $signed ? 'Eingerichtet – neue Pakete werden automatisch signiert.' : 'Fehlt (signing_key_file in config.php) – Pakete werden unsigniert veröffentlicht.' ?></span></div></div>
<div class="status <?= $pushSa ? 'ok' : 'warn' ?>"><i class="dot"></i><div><strong>Push-Dienst</strong><span><?= $pushSa ? 'Firebase-Projekt ' . site_h($pushSa['project_id']) . ' eingebaut.' : 'Kein Firebase-Dienstkonto eingebaut – Push-Nachrichten sind nicht möglich.' ?></span></div></div>
<?php $openAccess = (string) site_config()['access_key'] === ''; ?>
<div class="status <?= $openAccess ? 'warn' : 'ok' ?>"><i class="dot"></i><div><strong>Zugangsschlüssel für Katalog und Downloads</strong><span><?= $openAccess ? 'Nicht gesetzt – Katalog, Pakete und App-Dateien sind für jeden abrufbar. Image mit Zugangsschlüssel bauen (docker-build.ps1 erzeugt ihn).' : 'Gesetzt – nur DeskHub-Server mit dem Schlüssel dürfen Pakete und Apps laden.' ?></span></div></div>
</section>
<section class="card">
<header><div><h2>Schnellzugriff</h2><p>Häufige Aufgaben</p></div></header>
<p class="actions-row">
<a class="btn" href="admin.php?tab=packages">Plugin oder Theme hochladen</a>
<a class="btn ghost" href="admin.php?tab=apps">App-Version veröffentlichen</a>
<a class="btn ghost" href="admin.php?tab=settings">DeskHub-Einstellungen</a>
</p>
</section>
</div>
<?php elseif ($tab === 'packages'): ?>
<div class="pagehead"><div><h1>Pakete</h1><p class="sub">Plugins und Themes für den DeskHub-Katalog hochladen und verwalten.</p></div></div>
<section class="card">
<header><div><h2>Neues Paket veröffentlichen</h2><p>ZIP-Datei mit <code>plugin.json</code> bzw. Theme-CSS</p></div></header>
<form method="post" enctype="multipart/form-data" class="form">
<input type="hidden" name="action" value="publish"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>">
<div class="row2">
<label>Art<select name="type" id="type"><option value="plugin">Plugin</option><option value="theme">Theme</option></select></label>
<label>ZIP-Paket<input type="file" name="package" accept=".zip" required></label>
</div>
<fieldset class="theme-only"><legend>Nur für Themes (bei Plugins steht alles in der plugin.json)</legend>
<div class="row2">
<label>Kennung (Slug)<input type="text" name="slug" placeholder="nordlicht" pattern="[a-z0-9][a-z0-9_\-]{1,40}"></label>
<label>Name<input type="text" name="name" placeholder="Nordlicht"></label>
</div>
<div class="row2">
<label>Version<input type="text" name="version" placeholder="1.0.0 (sonst aus dem CSS-Kopf)"></label>
<label>Autor<input type="text" name="author"></label>
</div>
<label>Beschreibung<textarea name="description" rows="3"></textarea></label>
</fieldset>
<label>Vorschaubild (nur beim ersten Mal nötig, PNG/JPG/WebP, höchstens 1 MB)<input type="file" name="preview" accept="image/png,image/jpeg,image/webp"></label>
<p class="muted small">Bei einem Update von Plugin oder Theme bleibt das vorhandene Bild erhalten: einfach weglassen. Ändern oder entfernen kannst du es unten bei dem Eintrag.</p>
<div><button type="submit">Veröffentlichen</button></div>
</form>
</section>
<div class="cols two">
<?php foreach (['plugin' => ['Plugins', $plugins], 'theme' => ['Themes', $themes]] as $t => [$label, $items]): ?>
<section class="card">
<header><div><h2><?= site_h($label) ?></h2><p><?= count($items) ?> veröffentlicht</p></div></header>
<?php if (!$items): ?><div class="empty">Noch keine <?= site_h($label) ?> veröffentlicht.</div><?php endif; ?>
<?php foreach ($items as $it): ?>
<div class="pkg">
<div class="head">
<div class="grow"><span class="name"><?= site_h($it['name']) ?></span> <span class="muted small"><?= site_h($it['slug']) ?></span></div>
<?php if (!empty($it['featured'])): ?><span class="badge brand">★ Empfohlen</span><?php endif; ?>
<?php if (!empty($it['language']) && is_array($__l = json_decode((string) $it['language'], true))): ?><span class="badge">🌐 <?= site_h((string) $__l['name']) ?> (<?= site_h((string) $__l['code']) ?>)</span><?php endif; ?>
<?= admin_btn(!empty($it['featured']) ? 'unfeature' : 'feature', ['id' => (int) $it['id']], !empty($it['featured']) ? 'Empfehlung entfernen' : 'Als Empfehlung markieren', 'ghost sm') ?>
</div>
<div class="previewrow">
<?php if (!empty($it['preview'])): ?><img class="thumb-sm" src="preview.php?file=<?= site_h(rawurlencode((string) $it['preview'])) ?>" alt="Vorschaubild von <?= site_h($it['name']) ?>"><?php else: ?><span class="thumb-sm none">kein Bild</span><?php endif; ?>
<form method="post" enctype="multipart/form-data" class="inline"><input type="hidden" name="action" value="set_preview"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>"><input type="hidden" name="id" value="<?= (int) $it['id'] ?>">
<input type="file" name="preview" accept="image/png,image/jpeg,image/webp" required> <button type="submit" class="ghost sm"><?= !empty($it['preview']) ? 'Bild ersetzen' : 'Bild hinzufügen' ?></button></form>
<?php if (!empty($it['preview'])) echo admin_btn('remove_preview', ['id' => (int) $it['id']], 'Bild entfernen', 'link danger', 'Vorschaubild wirklich entfernen?'); ?>
</div>
<div class="tablewrap flat"><table>
<?php foreach (site_versions((int) $it['id']) as $v): ?>
<tr><td><strong><?= site_h($v['version']) ?></strong></td><td class="muted"><?= site_h(substr($v['created_at'], 0, 10)) ?></td><td><?= (int) $v['downloads'] ?> Downloads</td>
<td><?= $v['signature'] !== '' ? '<span class="badge ok">signiert</span>' : '<span class="badge warn">nicht signiert</span>' ?></td>
<td class="actions"><?= admin_btn('delete', ['id' => (int) $v['id']], 'Entfernen', 'link danger', 'Version wirklich entfernen?') ?></td></tr>
<?php endforeach; ?>
</table></div>
</div>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
</div>
<?php elseif ($tab === 'apps'): ?>
<div class="pagehead"><div><h1>Apps</h1><p class="sub">Windows-, Android-App, Minecraft-Plugin, WordPress-Plugin und Chrome-Erweiterung veröffentlichen. Die Apps zeigen ihren Nutzern automatisch „Update verfügbar“, sobald hier eine höhere Version liegt.</p></div></div>
<section class="card">
<header><div><h2>Neue App-Version hochladen</h2><p>Windows-Setups dürfen bis 300 MB groß sein (PHP-Grenzen <code>upload_max_filesize</code> / <code>post_max_size</code> beachten).</p></div></header>
<form method="post" enctype="multipart/form-data" class="form">
<input type="hidden" name="action" value="publish_app"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>">
<div class="row2">
<label>Plattform<select name="platform" id="platform"><?php foreach (site_app_platforms() as $pk => $pv): if ($pk === 'server') continue; ?><option value="<?= site_h($pk) ?>" data-zip="<?= $pv['ext'] === 'zip' ? '1' : '0' ?>"><?= site_h($pv['label'] . ' – ' . $pv['what']) ?></option><?php endforeach; ?></select></label>
<label>Versionsnummer <span class="muted small" id="versionhint" hidden>(leer lassen: wird aus dem Paket gelesen)</span><input type="text" id="appversion" name="version" placeholder="3.4.0" pattern="[0-9]+[.][0-9]+[.][0-9]+([+-][A-Za-z0-9.-]+)?" required></label>
</div>
<label>Installationsdatei<input type="file" name="app_file" accept=".apk,.exe,.jar,.zip,application/vnd.android.package-archive,application/vnd.microsoft.portable-executable,application/zip" required></label>
<label>Änderungen (optional, erscheint im Update-Hinweis)<textarea name="notes" rows="2" maxlength="2000"></textarea></label>
<div><button type="submit">App-Version hochladen</button></div>
</form>
</section>
<div class="cols">
<?php foreach (array_map(fn($p) => $p['label'], array_diff_key(site_app_platforms(), ['server' => 1])) as $pf => $pl): $mine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === $pf)); $cur = $latestApp[$pf]; ?>
<section class="card">
<header><div><h2><?= site_h($pl) ?></h2><p><?= site_h(site_app_platforms()[$pf]['what']) ?></p></div>
<?= $cur ? '<span class="badge ok">aktuell ' . site_h($cur['version']) . '</span>' : '<span class="badge">noch nichts veröffentlicht</span>' ?></header>
<?php if (!$mine): ?><div class="empty">Noch keine Version veröffentlicht.</div><?php else: ?>
<div class="tablewrap"><table>
<thead><tr><th>Version</th><th>Datum</th><th>Größe</th><th></th></tr></thead>
<?php foreach ($mine as $release): $isLatest = $cur && (int) $cur['id'] === (int) $release['id']; ?><tr>
<td><strong><?= site_h($release['version']) ?></strong> <?= $isLatest ? '<span class="badge ok">aktuell</span>' : '' ?></td>
<td class="muted"><?= site_h(substr($release['created_at'], 0, 16)) ?></td><td><?= number_format((int) $release['size'] / 1048576, 1, ',', '.') ?> MB</td>
<td class="actions"><?= admin_btn('delete_app', ['id' => (int) $release['id']], 'Entfernen', 'link danger', 'Diese App-Version wirklich entfernen?') ?></td>
</tr><?php endforeach; ?>
</table></div>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
<?php elseif ($tab === 'server'): $serverMine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === 'server')); $serverCur = $latestApp['server']; ?>
<div class="pagehead"><div><h1>DeskHub-Update</h1><p class="sub">Das ZIP des DeskHub-Servers für das System-Update. Alle DeskHub-Installationen holen neue Versionen von hier (Adminportal → System-Update). Die Version wird aus <code>version.php</code> im Paket gelesen, das Paket wird mit dem Schlüssel dieser Seite automatisch signiert.</p></div></div>
<?php if (!$signed): ?><div class="alert err">Kein Signierschlüssel eingerichtet: Die Pakete werden nicht signiert, DeskHub mit eingebautem Herausgeber-Schlüssel lehnt sie ab. Siehe Einstellungen.</div><?php endif; ?>
<section class="card">
<header><div><h2>Neue Version hochladen</h2><p>ZIP des Projekts (ohne <code>config/</code>, Datenbank, Backups, Uploads; diese Ordner werden beim Update ohnehin nie überschrieben).</p></div>
<?= $serverCur ? '<span class="badge ok">aktuell ' . site_h($serverCur['version']) . '</span>' : '<span class="badge">noch nichts veröffentlicht</span>' ?></header>
<form method="post" enctype="multipart/form-data" class="form">
<input type="hidden" name="action" value="publish_app"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>"><input type="hidden" name="platform" value="server">
<label>Update-Paket (ZIP)<input type="file" name="app_file" accept=".zip,application/zip" required></label>
<div class="row2">
<label>Versionsnummer <span class="muted small">(leer lassen: wird aus version.php gelesen)</span><input type="text" name="version" placeholder="1.0.96" pattern="[0-9]+[.][0-9]+[.][0-9]+([+-][A-Za-z0-9.-]+)?"></label>
<label>Änderungen (erscheint im Update-Fenster)<textarea name="notes" rows="2" maxlength="2000"></textarea></label>
</div>
<div><button type="submit">Update veröffentlichen</button></div>
</form>
</section>
<section class="card">
<header><div><h2>Veröffentlichte Versionen</h2><p>DeskHub zeigt die neuesten 20 unter „Bestimmte Version installieren“.</p></div></header>
<?php if (!$serverMine): ?><div class="empty">Noch keine Version veröffentlicht. Bis dahin nutzt DeskHub die Ausweichquelle (Gitea).</div><?php else: ?>
<div class="tablewrap"><table>
<thead><tr><th>Version</th><th>Datum</th><th>Größe</th><th>Änderungen</th><th></th></tr></thead>
<?php foreach ($serverMine as $release): $isLatest = $serverCur && (int) $serverCur['id'] === (int) $release['id']; ?><tr>
<td><strong><?= site_h($release['version']) ?></strong> <?= $isLatest ? '<span class="badge ok">aktuell</span>' : '' ?></td>
<td class="muted"><?= site_h(substr($release['created_at'], 0, 16)) ?></td><td><?= number_format((int) $release['size'] / 1048576, 1, ',', '.') ?> MB</td>
<td class="muted small"><?= site_h(mb_substr((string) $release['notes'], 0, 120)) ?></td>
<td class="actions"><?= admin_btn('delete_app', ['id' => (int) $release['id']], 'Entfernen', 'link danger', 'Diese Version wirklich entfernen?') ?></td>
</tr><?php endforeach; ?>
</table></div>
<?php endif; ?>
</section>
<?php elseif ($tab === 'push'): ?>
<div class="pagehead"><div><h1>Push-Dienst</h1><p class="sub">Zentraler Push-Dienst der Android-App. Hier siehst du die angemeldeten DeskHub-Installationen.</p></div></div>
<div class="status <?= $pushSa ? 'ok' : 'warn' ?>"><i class="dot"></i><div><strong><?= $pushSa ? 'Firebase-Dienstkonto eingebaut' : 'Kein Firebase-Dienstkonto' ?></strong><span><?= $pushSa ? 'Projekt ' . site_h($pushSa['project_id']) : 'schluessel/firebase-dienstkonto.json fehlte beim Bauen – Push-Nachrichten sind nicht möglich.' ?></span></div></div>
<section class="card mt">
<header><div><h2>Angemeldete Installationen</h2><p><?= count($pushRows) ?> Einträge</p></div></header>
<?php if (!$pushRows): ?><div class="empty">Noch keine DeskHub-Installation angemeldet.</div><?php else: ?>
<div class="tablewrap"><table>
<thead><tr><th>Name</th><th>Angemeldet</th><th>Zuletzt genutzt</th><th>Gesendet</th><th>Status</th><th></th></tr></thead>
<?php foreach ($pushRows as $p): $blocked = (int) $p['blocked'] === 1; ?>
<tr><td><?= site_h($p['name'] !== '' ? $p['name'] : $p['id']) ?></td><td class="muted"><?= site_h(substr((string) $p['created_at'], 0, 16)) ?></td>
<td class="muted"><?= $p['last_used_at'] ? site_h(substr((string) $p['last_used_at'], 0, 16)) : 'noch nie' ?></td><td><?= (int) $p['sent_total'] ?></td>
<td><?= $blocked ? '<span class="badge warn">gesperrt</span>' : '<span class="badge ok">aktiv</span>' ?></td>
<td class="actions"><?= admin_btn($blocked ? 'push_unblock' : 'push_block', ['id' => $p['id']], $blocked ? 'Entsperren' : 'Sperren') ?> &nbsp; <?= admin_btn('push_delete', ['id' => $p['id']], 'Löschen', 'link danger', 'Installation wirklich löschen?') ?></td></tr>
<?php endforeach; ?>
</table></div>
<?php endif; ?>
</section>
<?php else: ?>
<div class="pagehead"><div><h1>Einstellungen</h1><p class="sub">Einrichtung für DeskHub und Konto.</p></div></div>
<div class="status <?= $signed ? 'ok' : 'warn' ?>"><i class="dot"></i><div><strong>Signierschlüssel</strong><span><?= $signed ? 'Eingerichtet – neue Pakete werden automatisch signiert.' : 'Nicht eingerichtet (<code>php einrichten.php</code>) – ohne Schlüssel ist der geprüfte Modus nicht nutzbar.' ?></span></div></div>
<section class="card mt">
<header><div><h2>Einstellungen für DeskHub (offizieller Katalog)</h2><p>Diesen Inhalt in <code>includes/catalog_defaults.php</code> von DeskHub einsetzen, bevor ihr ein Release baut. Dann installieren eure Kunden Plugins nur aus diesem Katalog, und nur signierte.</p></div></header>
<pre class="key"><?= site_h(site_deskhub_defaults_snippet()) ?></pre>
</section>
<section class="card">
<header><div><h2>Konto</h2><p>Du bist als Administrator angemeldet.</p></div></header>
<?= admin_btn('logout', [], 'Abmelden', 'ghost') ?>
</section>
<?php endif; ?>
<script src="assets/admin.js"></script>
<?php endif; ?>
<?php site_footer();