Upload via GUI (32 Dateien)

This commit is contained in:
2026-10-25 17:58:33 +00:00
parent 7f79c59ac4
commit a62971926b
12 changed files with 701 additions and 364 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{$PLUGINS_DOMAIN} { {$PLUGINS_DOMAIN} {
encode gzip encode gzip
request_body { request_body {
max_size 14MB max_size 320MB
} }
header { header {
Strict-Transport-Security "max-age=31536000" Strict-Transport-Security "max-age=31536000"
+2 -2
View File
@@ -12,8 +12,8 @@ RUN apt-get update \
&& a2enmod rewrite headers \ && a2enmod rewrite headers \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# PHP: Uploads bis 12 MB (Pakete sind auf 10 MB begrenzt), keine Fehlerausgabe im Browser, Apache verrät keine Versionen # PHP: Uploads bis 310 MB (Pakete sind auf 10 MB begrenzt, App-Dateien auf 300 MB), keine Fehlerausgabe im Browser, Apache verrät keine Versionen
RUN { echo 'upload_max_filesize=12M'; echo 'post_max_size=14M'; echo 'display_errors=Off'; echo 'log_errors=On'; echo 'expose_php=Off'; } > /usr/local/etc/php/conf.d/plugin-site.ini \ RUN { echo 'upload_max_filesize=310M'; echo 'post_max_size=315M'; echo 'max_execution_time=600'; echo 'max_input_time=600'; echo 'memory_limit=256M'; echo 'display_errors=Off'; echo 'log_errors=On'; echo 'expose_php=Off'; } > /usr/local/etc/php/conf.d/plugin-site.ini \
&& { echo 'ServerTokens Prod'; echo 'ServerSignature Off'; echo '<Directory /var/www/html>'; echo ' AllowOverride All'; echo ' Require all granted'; echo '</Directory>'; } > /etc/apache2/conf-available/plugin-site.conf \ && { echo 'ServerTokens Prod'; echo 'ServerSignature Off'; echo '<Directory /var/www/html>'; echo ' AllowOverride All'; echo ' Require all granted'; echo '</Directory>'; } > /etc/apache2/conf-available/plugin-site.conf \
&& a2enconf plugin-site && a2enconf plugin-site
+2
View File
@@ -77,6 +77,8 @@ docker compose up -d # Webseite auf Port 3040 (http)
Die Webseite ist danach unter `http://SERVER:3040` erreichbar. **DeskHub lädt Plugins nur über https**, deshalb braucht die öffentliche Adresse (`BASE_URL`, die ihr beim Bauen angegeben habt) einen HTTPS-Proxy, der auf Port 3040 weiterleitet. Zwei Wege: Die Webseite ist danach unter `http://SERVER:3040` erreichbar. **DeskHub lädt Plugins nur über https**, deshalb braucht die öffentliche Adresse (`BASE_URL`, die ihr beim Bauen angegeben habt) einen HTTPS-Proxy, der auf Port 3040 weiterleitet. Zwei Wege:
Im Bereich `admin.php` gibt es außerdem „DeskHub-Apps veröffentlichen“ für Android-APK- und Windows-EXE-Releases. Tragt pro Upload eine neue Versionsnummer ein; DeskHub bezieht anschließend die aktuelle Version und die Datei über den geschützten App-Endpunkt. Für Windows-Setups muss PHP Uploads bis mindestens 300 MB erlauben (im Docker-Image und im Caddy schon eingestellt; bei einem eigenen Proxy dort ebenfalls, z. B. nginx `client_max_body_size 320m`) (`upload_max_filesize` und `post_max_size`); die Dateien landen wie andere Pakete im geschützten `storage`-Verzeichnis.
- **Euer eigener Proxy** (nginx, Traefik, Nginx Proxy Manager …): `https://plugins.example.de` → `http://127.0.0.1:3040`. - **Euer eigener Proxy** (nginx, Traefik, Nginx Proxy Manager …): `https://plugins.example.de` → `http://127.0.0.1:3040`.
- **Mitgelieferter Caddy** (besorgt das Zertifikat selbst; Ports 80 und 443 frei, Domain zeigt auf den Server): `PLUGINS_DOMAIN=plugins.example.de docker compose --profile https up -d`. - **Mitgelieferter Caddy** (besorgt das Zertifikat selbst; Ports 80 und 443 frei, Domain zeigt auf den Server): `PLUGINS_DOMAIN=plugins.example.de docker compose --profile https up -d`.
+191 -56
View File
@@ -34,7 +34,13 @@ function admin_throttled(bool $record = false): bool
$loggedIn = !empty($_SESSION['admin']); $loggedIn = !empty($_SESSION['admin']);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { /** 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'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'login') { if ($action === 'login') {
if ($cfg['admin_password_hash'] === '') { if ($cfg['admin_password_hash'] === '') {
@@ -72,6 +78,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($res['ok']) $message = "Veröffentlicht: {$res['slug']} {$res['version']} (" . ($res['signed'] ? 'signiert' : 'NICHT signiert, kein Signierschlüssel eingerichtet') . ').'; if ($res['ok']) $message = "Veröffentlicht: {$res['slug']} {$res['version']} (" . ($res['signed'] ? 'signiert' : 'NICHT signiert, kein Signierschlüssel eingerichtet') . ').';
else $error = $res['error']; 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 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)) { } elseif (in_array($action, ['push_block', 'push_unblock', 'push_delete'], true)) {
$pid = (string) ($_POST['id'] ?? ''); $pid = (string) ($_POST['id'] ?? '');
if (preg_match('/^[a-f0-9]{24}$/', $pid)) { if (preg_match('/^[a-f0-9]{24}$/', $pid)) {
@@ -91,76 +116,186 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
} }
site_header('Verwaltung'); $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);
?> ?>
<h1>Verwaltung</h1>
<?php if ($error): ?><div class="alert err"><?= site_h($error) ?></div><?php endif; ?> <?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 ($message): ?><div class="alert okbox"><?= site_h($message) ?></div><?php endif; ?>
<?php if (!$loggedIn): ?> <?php if (!$loggedIn): ?>
<form method="post" class="form narrow"> <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"> <input type="hidden" name="action" value="login">
<label>Benutzername<input type="text" name="user" autocomplete="username" required></label> <label>Benutzername<input type="text" name="user" autocomplete="username" required autofocus></label>
<label>Passwort<input type="password" name="password" autocomplete="current-password" required></label> <label>Passwort<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">Anmelden</button> <button type="submit">Anmelden</button>
</form> </form>
<?php else: ?> <?php else:
<form method="post" class="logout"><input type="hidden" name="action" value="logout"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><button type="submit" class="link">Abmelden</button></form> $signed = site_public_key() !== '';
<p class="<?= site_public_key() !== '' ? 'ok' : 'warn' ?>"><?= site_public_key() !== '' ? '✓ Signierschlüssel eingerichtet: Neue Pakete werden automatisch signiert.' : 'Kein Signierschlüssel eingerichtet (signing_key_file in config.php): Pakete werden unsigniert veröffentlicht.' ?></p> $appReleases = site_app_releases();
$latestApp = ['android' => null, 'windows' => null, 'minecraft' => 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');
?>
<details class="form" style="max-width:none"><summary><strong>Einstellungen für DeskHub (offizieller Katalog)</strong></summary> <?php if ($tab === 'overview'): ?>
<p class="muted">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 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>
<?php if (site_public_key() === ''): ?><p class="warn">Es ist noch kein Signierschlüssel eingerichtet (php einrichten.php). Ohne Schlüssel ist der geprüfte Modus nicht nutzbar.</p><?php endif; ?> <div class="stats">
<pre class="key"><?= site_h(site_deskhub_defaults_snippet()) ?></pre> <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>
</details> <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=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>
<h2>Neues Paket veröffentlichen</h2> <?php elseif ($tab === 'packages'): ?>
<form method="post" enctype="multipart/form-data" class="form"> <div class="pagehead"><div><h1>Pakete</h1><p class="sub">Plugins und Themes für den DeskHub-Katalog hochladen und verwalten.</p></div></div>
<input type="hidden" name="action" value="publish"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"> <section class="card">
<label>Art<select name="type" id="type"><option value="plugin">Plugin</option><option value="theme">Theme</option></select></label> <header><div><h2>Neues Paket veröffentlichen</h2><p>ZIP-Datei mit <code>plugin.json</code> bzw. Theme-CSS</p></div></header>
<label>ZIP-Paket<input type="file" name="package" accept=".zip" required></label> <form method="post" enctype="multipart/form-data" class="form">
<fieldset class="theme-only"><legend>Nur für Themes (bei Plugins steht alles in der plugin.json)</legend> <input type="hidden" name="action" value="publish"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>">
<label>Kennung (Slug)<input type="text" name="slug" placeholder="nordlicht" pattern="[a-z0-9][a-z0-9_\-]{1,40}"></label> <div class="row2">
<label>Name<input type="text" name="name" placeholder="Nordlicht"></label> <label>Art<select name="type" id="type"><option value="plugin">Plugin</option><option value="theme">Theme</option></select></label>
<label>Version<input type="text" name="version" placeholder="1.0.0 (sonst aus dem CSS-Kopf)"></label> <label>ZIP-Paket<input type="file" name="package" accept=".zip" required></label>
<label>Autor<input type="text" name="author"></label> </div>
<label>Beschreibung<textarea name="description" rows="3"></textarea></label> <fieldset class="theme-only"><legend>Nur für Themes (bei Plugins steht alles in der plugin.json)</legend>
</fieldset> <div class="row2">
<label>Vorschaubild (optional, PNG/JPG/WebP, höchstens 1 MB)<input type="file" name="preview" accept="image/png,image/jpeg,image/webp"></label> <label>Kennung (Slug)<input type="text" name="slug" placeholder="nordlicht" pattern="[a-z0-9][a-z0-9_\-]{1,40}"></label>
<button type="submit">Veröffentlichen</button> <label>Name<input type="text" name="name" placeholder="Nordlicht"></label>
</form> </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 (optional, PNG/JPG/WebP, höchstens 1 MB)<input type="file" name="preview" accept="image/png,image/jpeg,image/webp"></label>
<div><button type="submit">Veröffentlichen</button></div>
</form>
</section>
<h2>Push-Dienst (Android-App)</h2> <div class="cols two">
<?php $pushSa = push_service_account(); $pushRows = push_db()->query('SELECT * FROM push_installs ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 200')->fetchAll(); ?> <?php foreach (['plugin' => ['Plugins', $plugins], 'theme' => ['Themes', $themes]] as $t => [$label, $items]): ?>
<p class="<?= $pushSa ? 'ok' : 'warn' ?>"><?= $pushSa ? '✓ Firebase-Dienstkonto eingebaut (Projekt ' . site_h($pushSa['project_id']) . ').' : 'Kein Firebase-Dienstkonto eingebaut: schluessel/firebase-dienstkonto.json fehlt beim Bauen, Push-Nachrichten sind nicht möglich.' ?></p> <section class="card">
<?php if (!$pushRows): ?><p class="muted">Noch keine DeskHub-Installation angemeldet.</p><?php else: ?> <header><div><h2><?= site_h($label) ?></h2><p><?= count($items) ?> veröffentlicht</p></div></header>
<table class="admin"> <?php if (!$items): ?><div class="empty">Noch keine <?= site_h($label) ?> veröffentlicht.</div><?php endif; ?>
<caption>Angemeldete DeskHub-Installationen (<?= count($pushRows) ?>)</caption>
<?php foreach ($pushRows as $p): ?>
<tr><td><?= site_h($p['name'] !== '' ? $p['name'] : $p['id']) ?></td><td><?= site_h(substr((string) $p['created_at'], 0, 16)) ?></td><td><?= $p['last_used_at'] ? 'zuletzt ' . site_h(substr((string) $p['last_used_at'], 0, 16)) : 'noch nie genutzt' ?></td><td><?= (int) $p['sent_total'] ?> gesendet</td>
<td><?= (int) $p['blocked'] === 1 ? '<span class="warn">gesperrt</span>' : '<span class="ok">aktiv</span>' ?></td>
<td><form method="post"><input type="hidden" name="action" value="<?= (int) $p['blocked'] === 1 ? 'push_unblock' : 'push_block' ?>"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><input type="hidden" name="id" value="<?= site_h($p['id']) ?>"><button type="submit" class="link"><?= (int) $p['blocked'] === 1 ? 'Entsperren' : 'Sperren' ?></button></form></td>
<td><form method="post" data-confirm="Installation wirklich löschen?"><input type="hidden" name="action" value="push_delete"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><input type="hidden" name="id" value="<?= site_h($p['id']) ?>"><button type="submit" class="link">Löschen</button></form></td></tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<h2>Veröffentlichte Pakete</h2>
<?php foreach (['plugin' => 'Plugins', 'theme' => 'Themes'] as $t => $label): $items = site_items($t); ?>
<h3><?= site_h($label) ?></h3>
<?php if (!$items): ?><p class="muted">Noch keine.</p><?php endif; ?>
<?php foreach ($items as $it): ?> <?php foreach ($items as $it): ?>
<table class="admin"> <div class="pkg">
<caption><?= site_h($it['name']) ?> <span class="muted small"><?= site_h($it['slug']) ?></span> <?= !empty($it['featured']) ? '<span class="ok">★ Empfohlen</span>' : '' ?> <?php if (!empty($it['language']) && is_array($__l = json_decode((string) $it['language'], true))): ?><span class="muted small">🌐 Sprachpaket: <?= site_h((string) $__l['name']) ?> (<?= site_h((string) $__l['code']) ?>)</span><?php endif; ?> <div class="head">
<form method="post" style="display:inline"><input type="hidden" name="action" value="<?= !empty($it['featured']) ? 'unfeature' : 'feature' ?>"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><input type="hidden" name="id" value="<?= (int) $it['id'] ?>"><button type="submit" class="link"><?= !empty($it['featured']) ? 'Empfehlung entfernen' : 'Als Empfehlung markieren' ?></button></form></caption> <div class="grow"><span class="name"><?= site_h($it['name']) ?></span> <span class="muted small"><?= site_h($it['slug']) ?></span></div>
<?php foreach (site_versions((int) $it['id']) as $v): ?> <?php if (!empty($it['featured'])): ?><span class="badge brand">★ Empfohlen</span><?php endif; ?>
<tr><td><?= site_h($v['version']) ?></td><td><?= site_h(substr($v['created_at'], 0, 16)) ?></td><td><?= (int) $v['downloads'] ?> Downloads</td> <?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; ?>
<td><?= $v['signature'] !== '' ? '<span class="ok">signiert</span>' : '<span class="warn">nicht signiert</span>' ?></td> <?= admin_btn(!empty($it['featured']) ? 'unfeature' : 'feature', ['id' => (int) $it['id']], !empty($it['featured']) ? 'Empfehlung entfernen' : 'Als Empfehlung markieren', 'ghost sm') ?>
<td><form method="post" data-confirm="Version wirklich entfernen?"><input type="hidden" name="action" value="delete"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>"><input type="hidden" name="id" value="<?= (int) $v['id'] ?>"><button type="submit" class="link danger">Entfernen</button></form></td></tr> </div>
<?php endforeach; ?> <div class="tablewrap flat"><table>
</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; ?> <?php endforeach; ?>
</section>
<?php endforeach; ?> <?php endforeach; ?>
</div>
<?php elseif ($tab === 'apps'): ?>
<div class="pagehead"><div><h1>Apps</h1><p class="sub">Windows-, Android-App und Minecraft-Plugin 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"><option value="windows">Windows (EXE)</option><option value="android">Android (APK)</option><option value="minecraft">Minecraft-Plugin (JAR)</option></select></label>
<label>Versionsnummer<input type="text" 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,application/vnd.android.package-archive,application/vnd.microsoft.portable-executable" 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 three">
<?php foreach (['windows' => 'Windows', 'android' => 'Android', 'minecraft' => 'Minecraft'] 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><?= ['windows' => 'Setup (EXE)', 'android' => 'Installationsdatei (APK)', 'minecraft' => 'Plugin (JAR)'][$pf] ?></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 === '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> <script src="assets/admin.js"></script>
<?php endif; ?> <?php endif; ?>
<?php site_footer(); <?php site_footer();
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/inc/lib.php';
site_require_access();
$platform = (string) ($_GET['platform'] ?? '');
$release = site_app_release($platform);
if (!$release) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
exit(json_encode(['error' => 'Kein App-Release vorhanden.']));
}
$path = site_storage() . '/apps/' . $platform . '/' . basename((string) $release['file']);
if (!is_file($path)) {
http_response_code(404);
exit;
}
if (($_GET['download'] ?? '') !== '1') {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode([
'platform' => $platform,
'version' => $release['version'],
'sha256' => $release['sha256'],
'size' => (int) $release['size'],
'notes' => $release['notes'],
'created_at' => $release['created_at'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$filename = ['android' => 'DeskHub.apk', 'windows' => 'DeskHub-Setup.exe', 'minecraft' => 'DeskHub-Minecraft.jar'][$platform];
while (ob_get_level() > 0) @ob_end_clean();
header('Content-Type: ' . ['android' => 'application/vnd.android.package-archive', 'windows' => 'application/vnd.microsoft.portable-executable', 'minecraft' => 'application/java-archive'][$platform]);
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header('X-DeskHub-SHA256: ' . $release['sha256']);
header('X-DeskHub-Version: ' . $release['version']);
readfile($path);
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#14b8a6"/><stop offset="1" stop-color="#0f6f6a"/></linearGradient></defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<path fill="#fff" fill-rule="evenodd" d="M19 15h13a17 17 0 0 1 0 34H19zM27 23v18h5a9 9 0 0 0 0-18z"/>
<circle cx="49" cy="15" r="4.5" fill="#99f6e4"/>
</svg>

After

Width:  |  Height:  |  Size: 441 B

+132 -51
View File
@@ -1,53 +1,134 @@
:root { --bg: #f4f6f9; --card: #fff; --ink: #1e293b; --muted: #64748b; --line: #e2e8f0; --brand: #0f6f6a; --navy: #14202e; --ok: #15803d; --warn: #b45309; --err: #dc2626; } :root {
@media (prefers-color-scheme: dark) { :root { --bg: #0f1722; --card: #17222f; --ink: #e5eaf0; --muted: #94a3b8; --line: #263445; --brand: #2dd4bf; --navy: #0b121b; } } --bg: #f3f6f8; --card: #ffffff; --card-2: #f8fafc; --ink: #162033; --muted: #5d6b82; --line: #e2e8f0;
--brand: #0f766e; --brand-2: #14b8a6; --brand-ink: #ffffff; --brand-soft: #e0f5f2;
--navy: #0f1b2b; --ok: #15803d; --ok-bg: #dcfce7; --warn: #b45309; --warn-bg: #fef3c7; --err: #b91c1c; --err-bg: #fee2e2;
--shadow: 0 1px 2px rgba(15, 27, 43, .06), 0 4px 14px rgba(15, 27, 43, .05); --radius: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b121b; --card: #131e2c; --card-2: #101a27; --ink: #e6ecf3; --muted: #93a2b8; --line: #243245;
--brand: #2dd4bf; --brand-2: #14b8a6; --brand-ink: #04201d; --brand-soft: #0f2a2b;
--navy: #08101a; --ok: #4ade80; --ok-bg: #12301f; --warn: #fbbf24; --warn-bg: #33270d; --err: #f87171; --err-bg: #3a1618;
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 4px 14px rgba(0, 0, 0, .25);
}
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } html { -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 16px; } body { margin: 0; background: var(--bg); color: var(--ink); font: 15.5px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.top { background: var(--navy); color: #fff; } .wrap { max-width: 1560px; margin: 0 auto; padding: 0 20px; }
.top .wrap { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; min-height: 56px; } a { color: var(--brand); }
.brand { color: #fff; font-weight: 700; text-decoration: none; }
nav a { color: #cbd5e1; text-decoration: none; margin-left: 18px; padding: 4px 0; border-bottom: 2px solid transparent; }
nav a.on, nav a:hover { color: #fff; border-color: var(--brand); }
main { padding-top: 24px; padding-bottom: 32px; }
h1 { margin: 0 0 8px; font-size: 1.7rem; } h2 { margin-top: 28px; } h3 { margin-top: 22px; }
.lead { color: var(--muted); max-width: 70ch; }
.muted { color: var(--muted); } .small { font-size: .8rem; } .meta { font-size: .85rem; color: var(--muted); margin: 4px 0; }
.ok { color: var(--ok); font-weight: 600; } .warn { color: var(--warn); font-weight: 600; } .danger { color: var(--err); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 18px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px; color: inherit; text-decoration: none; display: block; transition: border-color .15s, transform .15s; }
.card:hover { border-color: var(--brand); transform: translateY(-2px); }
.card h2 { margin: 0 0 6px; font-size: 1.1rem; }
.thumb { width: 100%; height: 140px; object-fit: cover; border-radius: 8px; margin-bottom: 10px; background: var(--line); }
.preview { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); margin: 8px 0 12px; }
.search { display: flex; gap: 8px; margin: 14px 0; }
input[type=search], input[type=text], input[type=password], select, textarea { width: 100%; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); font: inherit; }
button, .btn { background: var(--brand); color: #fff; border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
@media (prefers-color-scheme: dark) { button, .btn { color: #06201e; } }
button.link { background: none; color: var(--brand); padding: 0; font-weight: 400; }
button.link.danger { color: var(--err); }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.hash { font: .72rem/1.3 ui-monospace, Consolas, monospace; word-break: break-all; max-width: 260px; }
caption { text-align: left; font-weight: 700; padding: 8px 2px; }
table.admin { margin-bottom: 14px; }
.form { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px; display: grid; gap: 12px; max-width: 640px; }
.form.narrow { max-width: 380px; }
.form label { display: grid; gap: 4px; font-size: .88rem; font-weight: 600; }
fieldset { border: 1px dashed var(--line); border-radius: 8px; display: grid; gap: 10px; }
.alert { padding: 10px 14px; border-radius: 8px; margin: 12px 0; } .alert.err { background: #fee2e2; color: #991b1b; } .alert.okbox { background: #dcfce7; color: #166534; }
pre.key, code { background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; font-size: .85rem; }
pre.key { padding: 12px; overflow: auto; }
.logout { text-align: right; }
.foot { color: var(--muted); font-size: .8rem; padding-bottom: 30px; }
@media (max-width: 600px) { .hash { max-width: 120px; } th:nth-child(5), td:nth-child(5) { display: none; } }
/* Layout-Korrekturen: lange Inhalte (Schlüssel, Hashes) dürfen Karten nicht sprengen */ /* Kopfzeile mit Tabs */
.form, .form > * { min-width: 0; } .top { background: var(--navy); color: #fff; position: sticky; top: 0; z-index: 10; border-bottom: 1px solid rgba(255, 255, 255, .06); }
details.form { display: block; max-width: 760px; } .top .wrap { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; min-height: 62px; }
details.form summary { cursor: pointer; } .brand { display: flex; align-items: center; gap: 10px; color: #fff; font-weight: 700; text-decoration: none; letter-spacing: .01em; }
details.form p { overflow-wrap: anywhere; } .brand img { border-radius: 8px; display: block; }
pre.key { max-width: 100%; margin: 12px 0 0; overflow-x: auto; white-space: pre; box-sizing: border-box; font-size: .8rem; line-height: 1.45; } .tabs { display: flex; gap: 4px; margin-left: auto; overflow-x: auto; scrollbar-width: none; max-width: 100%; }
main { overflow-wrap: break-word; } .tabs::-webkit-scrollbar { display: none; }
table.admin td { white-space: nowrap; } .tabs a { color: #b8c4d4; text-decoration: none; padding: 8px 14px; border-radius: 10px; white-space: nowrap; font-weight: 600; font-size: .92rem; display: inline-flex; align-items: center; gap: 7px; transition: background .15s, color .15s; }
table.admin form { margin: 0; } .tabs a .ico { opacity: .75; font-size: .95em; }
.tabs a:hover { color: #fff; background: rgba(255, 255, 255, .08); }
.tabs a.on { color: #fff; background: rgba(45, 212, 191, .18); box-shadow: inset 0 -2px 0 var(--brand-2); }
@media (max-width: 720px) { .top .wrap { gap: 6px; padding-top: 8px; padding-bottom: 8px; } .tabs { margin-left: 0; width: 100%; } }
main { padding-top: 28px; padding-bottom: 36px; overflow-wrap: break-word; }
.pagehead { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
h1 { margin: 0; font-size: 1.75rem; line-height: 1.2; letter-spacing: -.01em; }
h2 { margin: 0 0 4px; font-size: 1.12rem; } h3 { margin: 0; font-size: 1rem; }
.sub { color: var(--muted); margin: 4px 0 0; max-width: 70ch; }
.muted { color: var(--muted); } .small { font-size: .82rem; }
/* Karten und Raster */
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; margin-bottom: 20px; min-width: 0; }
.card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.card > header p { margin: 2px 0 0; color: var(--muted); font-size: .9rem; }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; align-items: start; }
.cols > .card { margin-bottom: 0; }
@media (max-width: 420px) { .cols { grid-template-columns: 1fr; } }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(165px, 1fr)); gap: 14px; margin-bottom: 20px; }
.stat { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); text-decoration: none; color: inherit; display: block; transition: border-color .15s, transform .15s; }
a.stat:hover { border-color: var(--brand); transform: translateY(-2px); }
.stat .k { color: var(--muted); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.stat .v { font-size: 1.9rem; font-weight: 800; line-height: 1.15; margin-top: 4px; }
.stat .s { color: var(--muted); font-size: .84rem; margin-top: 2px; }
/* Status-Zeilen und Badges */
.status { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 12px; background: var(--card-2); border: 1px solid var(--line); margin-bottom: 10px; }
.status .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--muted); }
.status.ok .dot { background: var(--ok); box-shadow: 0 0 0 4px var(--ok-bg); } .status.warn .dot { background: var(--warn); box-shadow: 0 0 0 4px var(--warn-bg); }
.status.ok, .status.warn { color: var(--ink); }
.status strong { display: block; } .status span { color: var(--muted); font-size: .88rem; }
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 999px; font-size: .75rem; font-weight: 700; background: var(--card-2); border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
.badge.ok { background: var(--ok-bg); border-color: transparent; color: var(--ok); } .badge.warn { background: var(--warn-bg); border-color: transparent; color: var(--warn); }
.badge.brand { background: var(--brand-soft); border-color: transparent; color: var(--brand); } .badge.err { background: var(--err-bg); border-color: transparent; color: var(--err); }
.ok { color: var(--ok); } .warn { color: var(--warn); } .danger { color: var(--err); }
/* Formulare */
.form { display: grid; gap: 14px; }
.form.narrow { max-width: 400px; margin: 40px auto; }
.form label { display: grid; gap: 5px; font-size: .86rem; font-weight: 600; min-width: 0; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: end; } @media (max-width: 520px) { .row2 { grid-template-columns: 1fr; } }
input[type=search], input[type=text], input[type=password], select, textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--card-2); color: var(--ink); font: inherit; font-weight: 400; transition: border-color .15s, box-shadow .15s; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
input[type=file] { width: 100%; padding: 12px; border: 1.5px dashed var(--line); border-radius: 10px; background: var(--card-2); color: var(--muted); font: inherit; font-weight: 400; cursor: pointer; }
input[type=file]::file-selector-button { margin-right: 12px; border: 0; border-radius: 8px; padding: 7px 14px; background: var(--brand); color: var(--brand-ink); font: inherit; font-weight: 600; cursor: pointer; }
input[type=file]:hover { border-color: var(--brand); }
fieldset { border: 1px dashed var(--line); border-radius: 12px; display: grid; gap: 12px; padding: 14px; margin: 0; min-width: 0; }
legend { padding: 0 6px; font-size: .8rem; color: var(--muted); font-weight: 600; }
button, .btn { background: var(--brand); color: var(--brand-ink); border: 0; border-radius: 10px; padding: 10px 18px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: filter .15s, transform .05s; }
button:hover, .btn:hover { filter: brightness(1.08); } button:active { transform: translateY(1px); }
button.ghost, .btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
button.ghost:hover { border-color: var(--brand); filter: none; }
button.sm { padding: 6px 12px; font-size: .85rem; border-radius: 8px; }
button.danger { background: var(--err-bg); color: var(--err); }
button.link { background: none; color: var(--brand); padding: 0; font-weight: 600; }
button.link.danger { background: none; color: var(--err); }
form.inline { display: inline; margin: 0; }
.alert { padding: 12px 16px; border-radius: 12px; margin: 0 0 18px; font-weight: 500; }
.alert.err { background: var(--err-bg); color: var(--err); } .alert.okbox { background: var(--ok-bg); color: var(--ok); }
/* Tabellen */
.tablewrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
table { width: 100%; border-collapse: collapse; background: var(--card); }
th, td { text-align: left; padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tr:last-child td { border-bottom: 0; }
th { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--card-2); white-space: nowrap; }
td.actions { text-align: right; white-space: nowrap; }
.hash { font: .72rem/1.3 ui-monospace, Consolas, monospace; word-break: break-all; }
.empty { text-align: center; color: var(--muted); padding: 28px 12px; border: 1.5px dashed var(--line); border-radius: 12px; }
/* Paketliste */
.pkg { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 12px; overflow: hidden; background: var(--card); }
.pkg > .head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 14px; background: var(--card-2); border-bottom: 1px solid var(--line); }
.pkg .head .name { font-weight: 700; } .pkg .head .grow { flex: 1; min-width: 0; }
.pkg table { border: 0; }
/* Code, Schlüssel, Details */
pre.key, code { background: var(--card-2); border: 1px solid var(--line); border-radius: 8px; padding: 2px 6px; font-size: .85rem; }
pre.key { padding: 14px; overflow-x: auto; white-space: pre; max-width: 100%; margin: 10px 0 0; font-size: .8rem; line-height: 1.5; }
details.card summary { cursor: pointer; font-weight: 700; }
details.card p { overflow-wrap: anywhere; }
.thumb { width: 100%; height: 140px; object-fit: cover; border-radius: 10px; margin-bottom: 10px; background: var(--line); }
.foot { color: var(--muted); font-size: .8rem; padding-bottom: 32px; text-align: center; }
@media (max-width: 560px) { .stats { grid-template-columns: 1fr 1fr; gap: 10px; } .stat { padding: 12px 14px; } .stat .v { font-size: 1.5rem; } main { padding-top: 20px; } }
/* Ersatz für Inline-Styles (die Sicherheitsrichtlinie erlaubt keine) */
.h1-sm { font-size: 1.4rem; }
.mt { margin-top: 20px; }
.actions-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 0; }
.actions-row .btn { flex: 1 1 auto; white-space: nowrap; }
.tablewrap.flat { border: 0; border-radius: 0; }
/* Zwei Spalten: Plugins | Themes, Windows | Android */
.cols.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 900px) { .cols.two { grid-template-columns: 1fr; } }
.cols.two .pkg:last-child { margin-bottom: 0; }
.cols.two td, .cols.two th { padding: 10px 12px; }
.card > header .badge { align-self: center; }
.cols.two td, .cols.two th { white-space: nowrap; }
.cols.two > .card { padding: 16px; }
.cols.two td, .cols.two th { padding: 9px 8px; }
.cols.two td.actions { padding-right: 12px; }
.cols.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 1100px) { .cols.three { grid-template-columns: 1fr; } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+28 -4
View File
@@ -10,17 +10,41 @@ function site_headers(): void
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"); header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
} }
function site_header(string $title, string $active = ''): void /** Navigation der Verwaltung: Kennung => [Beschriftung, Symbol]. */
function site_tabs(): array
{
return [
'overview' => ['Übersicht', '◧'],
'packages' => ['Pakete', '▣'],
'apps' => ['Apps', '⬇'],
'push' => ['Push-Dienst', '🔔'],
'settings' => ['Einstellungen', '⚙'],
];
}
function site_header(string $title, string $active = '', bool $nav = false): void
{ {
site_headers(); site_headers();
$name = site_config()['site_name']; $name = site_config()['site_name'];
?><!doctype html> ?><!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= site_h($title) ?> · <?= site_h($name) ?></title><link rel="stylesheet" href="assets/style.css"></head> <meta name="theme-color" content="#0f6f6a">
<title><?= site_h($title) ?> · <?= site_h($name) ?></title>
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
<link rel="icon" href="assets/favicon-32.png" sizes="32x32" type="image/png">
<link rel="shortcut icon" href="favicon.ico">
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
<link rel="stylesheet" href="assets/style.css"></head>
<body> <body>
<header class="top"><div class="wrap"> <header class="top"><div class="wrap">
<a class="brand" href="index.php"><?= site_h($name) ?></a> <a class="brand" href="index.php"><img src="assets/favicon.svg" alt="" width="30" height="30"><span><?= site_h($name) ?></span></a>
<nav><a href="admin.php" class="on">Verwaltung</a></nav> <?php if ($nav): ?>
<nav class="tabs" aria-label="Bereiche">
<?php foreach (site_tabs() as $key => [$label, $icon]): ?>
<a href="admin.php?tab=<?= site_h($key) ?>"<?= $key === $active ? ' class="on" aria-current="page"' : '' ?>><span class="ico" aria-hidden="true"><?= $icon ?></span><?= site_h($label) ?></a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
</div></header> </div></header>
<main class="wrap"> <main class="wrap">
<?php <?php
+49
View File
@@ -7,6 +7,7 @@ declare(strict_types=1);
*/ */
const SITE_MAX_PACKAGE_BYTES = 10 * 1024 * 1024; const SITE_MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
const SITE_MAX_APP_BYTES = 300 * 1024 * 1024;
const SITE_MAX_PREVIEW_BYTES = 1024 * 1024; const SITE_MAX_PREVIEW_BYTES = 1024 * 1024;
const SITE_PLUGIN_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html']; const SITE_PLUGIN_EXTENSIONS = ['php', 'json', 'sql', 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'md', 'txt', 'html'];
const SITE_THEME_EXTENSIONS = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2']; const SITE_THEME_EXTENSIONS = ['css', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'woff', 'woff2'];
@@ -95,6 +96,10 @@ function site_db(): PDO
signature TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL, requires TEXT NOT NULL DEFAULT '', permissions TEXT NOT NULL DEFAULT '', signature TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL, requires TEXT NOT NULL DEFAULT '', permissions TEXT NOT NULL DEFAULT '',
downloads INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (item_id, version), downloads INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (item_id, version),
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE)"); FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE)");
$pdo->exec("CREATE TABLE IF NOT EXISTS app_releases (
id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, version TEXT NOT NULL, file TEXT NOT NULL,
sha256 TEXT NOT NULL, size INTEGER NOT NULL, notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL,
UNIQUE(platform, version))");
// Empfehlung (nachträglich ergänzt, damit bestehende Datenbanken ohne Handarbeit weiterlaufen) // Empfehlung (nachträglich ergänzt, damit bestehende Datenbanken ohne Handarbeit weiterlaufen)
$cols = array_column($pdo->query('PRAGMA table_info(items)')->fetchAll(), 'name'); $cols = array_column($pdo->query('PRAGMA table_info(items)')->fetchAll(), 'name');
if (!in_array('featured', $cols, true)) $pdo->exec('ALTER TABLE items ADD COLUMN featured INTEGER NOT NULL DEFAULT 0'); if (!in_array('featured', $cols, true)) $pdo->exec('ALTER TABLE items ADD COLUMN featured INTEGER NOT NULL DEFAULT 0');
@@ -102,6 +107,50 @@ function site_db(): PDO
return $pdo; return $pdo;
} }
/** Latest published app release per platform: the HIGHEST version, not the one uploaded last. */
function site_app_release(string $platform): ?array
{
if (!in_array($platform, ['android', 'windows', 'minecraft'], true)) return null;
$stmt = site_db()->prepare('SELECT * FROM app_releases WHERE platform = ?');
$stmt->execute([$platform]);
$rows = $stmt->fetchAll();
if (!$rows) return null;
usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id']));
return $rows[0];
}
function site_app_releases(): array
{
$rows = site_db()->query('SELECT * FROM app_releases')->fetchAll();
usort($rows, fn($a, $b) => strcmp($a['platform'], $b['platform']) ?: (version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id'])));
return $rows;
}
function site_publish_app(string $platform, string $tmp, string $version, string $notes): array
{
$types = ['android' => ['apk', "PK"], 'windows' => ['exe', "MZ"], 'minecraft' => ['jar', "PK"]];
if (!isset($types[$platform])) return ['ok' => false, 'error' => 'Unbekannte Plattform.'];
if (!preg_match('/^\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 3.4.0 eingeben.'];
if (!is_uploaded_file($tmp)) return ['ok' => false, 'error' => 'Die hochgeladene Datei wurde nicht gefunden.'];
$size = (int) filesize($tmp);
if ($size < 1024 || $size > SITE_MAX_APP_BYTES) return ['ok' => false, 'error' => 'Die Datei muss zwischen 1 KB und 300 MB groß sein.'];
$head = (string) file_get_contents($tmp, false, null, 0, 2);
if ($head !== $types[$platform][1]) return ['ok' => false, 'error' => 'Dateityp passt nicht zur gewählten Plattform.'];
$dir = site_storage() . '/apps/' . $platform;
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return ['ok' => false, 'error' => 'App-Speicher konnte nicht angelegt werden.'];
$file = bin2hex(random_bytes(24)) . '.' . $types[$platform][0];
if (!move_uploaded_file($tmp, $dir . '/' . $file)) return ['ok' => false, 'error' => 'App-Datei konnte nicht gespeichert werden.'];
$sha = hash_file('sha256', $dir . '/' . $file);
try {
$stmt = site_db()->prepare('INSERT INTO app_releases(platform,version,file,sha256,size,notes,created_at) VALUES(?,?,?,?,?,?,?)');
$stmt->execute([$platform, $version, $file, $sha, $size, substr(trim($notes), 0, 2000), date('Y-m-d H:i:s')]);
} catch (PDOException $e) {
@unlink($dir . '/' . $file);
return ['ok' => false, 'error' => str_contains(strtolower($e->getMessage()), 'unique') ? 'Diese Version ist für diese Plattform bereits vorhanden.' : 'Release konnte nicht gespeichert werden.'];
}
return ['ok' => true];
}
/** Plugin bzw. Theme als „Empfohlen“ markieren oder die Markierung entfernen. */ /** Plugin bzw. Theme als „Empfohlen“ markieren oder die Markierung entfernen. */
function site_set_featured(int $itemId, bool $featured): bool function site_set_featured(int $itemId, bool $featured): bool
{ {