Upload via GUI (32 Dateien)
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
+177
-42
@@ -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'): ?>
|
||||||
|
<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">
|
<form method="post" enctype="multipart/form-data" class="form">
|
||||||
<input type="hidden" name="action" value="publish"><input type="hidden" name="csrf" value="<?= site_h(admin_csrf()) ?>">
|
<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>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>
|
<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>
|
<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>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>
|
<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>Version<input type="text" name="version" placeholder="1.0.0 (sonst aus dem CSS-Kopf)"></label>
|
||||||
<label>Autor<input type="text" name="author"></label>
|
<label>Autor<input type="text" name="author"></label>
|
||||||
|
</div>
|
||||||
<label>Beschreibung<textarea name="description" rows="3"></textarea></label>
|
<label>Beschreibung<textarea name="description" rows="3"></textarea></label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<label>Vorschaubild (optional, PNG/JPG/WebP, höchstens 1 MB)<input type="file" name="preview" accept="image/png,image/jpeg,image/webp"></label>
|
<label>Vorschaubild (optional, PNG/JPG/WebP, höchstens 1 MB)<input type="file" name="preview" accept="image/png,image/jpeg,image/webp"></label>
|
||||||
<button type="submit">Veröffentlichen</button>
|
<div><button type="submit">Veröffentlichen</button></div>
|
||||||
</form>
|
</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 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="tablewrap flat"><table>
|
||||||
<?php foreach (site_versions((int) $it['id']) as $v): ?>
|
<?php foreach (site_versions((int) $it['id']) as $v): ?>
|
||||||
<tr><td><?= site_h($v['version']) ?></td><td><?= site_h(substr($v['created_at'], 0, 16)) ?></td><td><?= (int) $v['downloads'] ?> Downloads</td>
|
<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="ok">signiert</span>' : '<span class="warn">nicht signiert</span>' ?></td>
|
<td><?= $v['signature'] !== '' ? '<span class="badge ok">signiert</span>' : '<span class="badge warn">nicht signiert</span>' ?></td>
|
||||||
<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>
|
<td class="actions"><?= admin_btn('delete', ['id' => (int) $v['id']], 'Entfernen', 'link danger', 'Version wirklich entfernen?') ?></td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</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') ?> <?= 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();
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user