Upload via GUI (34 Dateien)

This commit is contained in:
2026-11-05 21:22:26 +00:00
parent 752b42245a
commit c0b4092498
8 changed files with 447 additions and 20 deletions
+1
View File
@@ -7,6 +7,7 @@ Eigenständige Webseite, von der DeskHub-Installationen Plugins und Kundenportal
- Verwaltung mit allen Paketen, Versionen, Signaturstatus und Download-Zähler (keine öffentliche Übersichtsseite: Die Startseite führt zur Verwaltung) - Verwaltung mit allen Paketen, Versionen, Signaturstatus und Download-Zähler (keine öffentliche Übersichtsseite: Die Startseite führt zur Verwaltung)
- Katalog für DeskHub: `catalog.php?type=plugins` und `catalog.php?type=themes` - Katalog für DeskHub: `catalog.php?type=plugins` und `catalog.php?type=themes`
- Verwaltung (`admin.php`): Pakete hochladen, Versionen entfernen, Vorschaubilder - Verwaltung (`admin.php`): Pakete hochladen, Versionen entfernen, Vorschaubilder
- **Sprache:** Die Verwaltung gibt es auf Deutsch und English. Oben rechts wechselst du die Sprache (sie wird in einem Cookie gemerkt, auch `?lang=en` bzw. `?lang=de` geht). Die Übersetzung steht in `lang/en.json` (Schlüssel = deutscher Text, `{}` für wechselnde Teile, `{#}` für Zahlen, `{1}`, `{2}` für die Reihenfolge in der Übersetzung); eine weitere Sprache ist eine weitere Datei `lang/<code>.json` plus ein Eintrag in `SITE_LANGS` in `inc/i18n.php`. Was nicht übersetzt ist, bleibt deutsch. Die Schnittstellen für DeskHub und die Apps (Katalog, Downloads, Push) sind davon nicht betroffen.
- **Vorschaubild nur einmal:** Das Bild eines Plugins oder Themes bleibt bei jeder neuen Version erhalten, auch wenn du die alte Version vorher entfernst. Beim Eintrag in der Verwaltung lässt es sich ersetzen oder entfernen. Liegt im ZIP eine Datei `vorschau.png`, wird sie beim ersten Veröffentlichen automatisch verwendet. - **Vorschaubild nur einmal:** Das Bild eines Plugins oder Themes bleibt bei jeder neuen Version erhalten, auch wenn du die alte Version vorher entfernst. Beim Eintrag in der Verwaltung lässt es sich ersetzen oder entfernen. Liegt im ZIP eine Datei `vorschau.png`, wird sie beim ersten Veröffentlichen automatisch verwendet.
- **WordPress-Plugin und Chrome-Erweiterung** (Tab „Apps“): ZIP hochladen, die Versionsnummer wird aus dem Plugin-Kopf (`Version:`) bzw. der `manifest.json` gelesen (eine eingetippte Nummer hat Vorrang). Abruf der neuesten Version: `app-release.php?platform=wordpress` oder `platform=chrome` (mit `&download=1` als Datei). - **WordPress-Plugin und Chrome-Erweiterung** (Tab „Apps“): ZIP hochladen, die Versionsnummer wird aus dem Plugin-Kopf (`Version:`) bzw. der `manifest.json` gelesen (eine eingetippte Nummer hat Vorrang). Abruf der neuesten Version: `app-release.php?platform=wordpress` oder `platform=chrome` (mit `&download=1` als Datei).
- Prüft jedes Paket beim Hochladen (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und **signiert es automatisch** mit eurem privaten Schlüssel - Prüft jedes Paket beim Hochladen (Dateitypen, Pfade, `plugin.json`, Berechtigungen) und **signiert es automatisch** mit eurem privaten Schlüssel
+34 -2
View File
@@ -168,6 +168,7 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l
<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=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">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=apps"><div class="k">Android</div><div class="v"><?= $latestApp['android'] ? site_h($latestApp['android']['version']) : '–' ?></div><div class="s">aktuelle App-Version</div></a>
<a class="stat" href="admin.php?tab=server"><div class="k">DeskHub-Server</div><div class="v"><?= $latestApp['server'] ? site_h($latestApp['server']['version']) : '–' ?></div><div class="s">aktuelles System-Update</div></a>
<a class="stat" href="admin.php?tab=push"><div class="k">Push-Geräte</div><div class="v"><?= count($pushRows) ?></div><div class="s">angemeldete Installationen</div></a> <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>
<div class="cols"> <div class="cols">
@@ -254,7 +255,7 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l
<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_app"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>"> <input type="hidden" name="action" value="publish_app"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>">
<div class="row2"> <div class="row2">
<label>Plattform<select name="platform" id="platform"><?php foreach (site_app_platforms() as $pk => $pv): ?><option value="<?= site_h($pk) ?>" data-zip="<?= $pv['ext'] === 'zip' ? '1' : '0' ?>"><?= site_h($pv['label'] . ' – ' . $pv['what']) ?></option><?php endforeach; ?></select></label> <label>Plattform<select name="platform" id="platform"><?php foreach (site_app_platforms() as $pk => $pv): if ($pk === 'server') continue; ?><option value="<?= site_h($pk) ?>" data-zip="<?= $pv['ext'] === 'zip' ? '1' : '0' ?>"><?= site_h($pv['label'] . ' – ' . $pv['what']) ?></option><?php endforeach; ?></select></label>
<label>Versionsnummer <span class="muted small" id="versionhint" hidden>(leer lassen: wird aus dem Paket gelesen)</span><input type="text" id="appversion" name="version" placeholder="3.4.0" pattern="[0-9]+[.][0-9]+[.][0-9]+([+-][A-Za-z0-9.-]+)?" required></label> <label>Versionsnummer <span class="muted small" id="versionhint" hidden>(leer lassen: wird aus dem Paket gelesen)</span><input type="text" id="appversion" name="version" placeholder="3.4.0" pattern="[0-9]+[.][0-9]+[.][0-9]+([+-][A-Za-z0-9.-]+)?" required></label>
</div> </div>
<label>Installationsdatei<input type="file" name="app_file" accept=".apk,.exe,.jar,.zip,application/vnd.android.package-archive,application/vnd.microsoft.portable-executable,application/zip" required></label> <label>Installationsdatei<input type="file" name="app_file" accept=".apk,.exe,.jar,.zip,application/vnd.android.package-archive,application/vnd.microsoft.portable-executable,application/zip" required></label>
@@ -263,7 +264,7 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l
</form> </form>
</section> </section>
<div class="cols"> <div class="cols">
<?php foreach (array_map(fn($p) => $p['label'], site_app_platforms()) as $pf => $pl): $mine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === $pf)); $cur = $latestApp[$pf]; ?> <?php foreach (array_map(fn($p) => $p['label'], array_diff_key(site_app_platforms(), ['server' => 1])) as $pf => $pl): $mine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === $pf)); $cur = $latestApp[$pf]; ?>
<section class="card"> <section class="card">
<header><div><h2><?= site_h($pl) ?></h2><p><?= site_h(site_app_platforms()[$pf]['what']) ?></p></div> <header><div><h2><?= site_h($pl) ?></h2><p><?= site_h(site_app_platforms()[$pf]['what']) ?></p></div>
<?= $cur ? '<span class="badge ok">aktuell ' . site_h($cur['version']) . '</span>' : '<span class="badge">noch nichts veröffentlicht</span>' ?></header> <?= $cur ? '<span class="badge ok">aktuell ' . site_h($cur['version']) . '</span>' : '<span class="badge">noch nichts veröffentlicht</span>' ?></header>
@@ -282,6 +283,37 @@ site_header($loggedIn ? $tabs[$tab][0] . ' · Verwaltung' : 'Anmelden', $tab, $l
</div> </div>
<?php elseif ($tab === 'server'): $serverMine = array_values(array_filter($appReleases, fn($r) => $r['platform'] === 'server')); $serverCur = $latestApp['server']; ?>
<div class="pagehead"><div><h1>DeskHub-Update</h1><p class="sub">Das ZIP des DeskHub-Servers für das System-Update. Alle DeskHub-Installationen holen neue Versionen von hier (Adminportal → System-Update). Die Version wird aus <code>version.php</code> im Paket gelesen, das Paket wird mit dem Schlüssel dieser Seite automatisch signiert.</p></div></div>
<?php if (!$signed): ?><div class="alert err">Kein Signierschlüssel eingerichtet: Die Pakete werden nicht signiert, DeskHub mit eingebautem Herausgeber-Schlüssel lehnt sie ab. Siehe Einstellungen.</div><?php endif; ?>
<section class="card">
<header><div><h2>Neue Version hochladen</h2><p>ZIP des Projekts (ohne <code>config/</code>, Datenbank, Backups, Uploads; diese Ordner werden beim Update ohnehin nie überschrieben).</p></div>
<?= $serverCur ? '<span class="badge ok">aktuell ' . site_h($serverCur['version']) . '</span>' : '<span class="badge">noch nichts veröffentlicht</span>' ?></header>
<form method="post" enctype="multipart/form-data" class="form">
<input type="hidden" name="action" value="publish_app"><input type="hidden" name="csrf" value="<?= site_h($csrf) ?>"><input type="hidden" name="platform" value="server">
<label>Update-Paket (ZIP)<input type="file" name="app_file" accept=".zip,application/zip" required></label>
<div class="row2">
<label>Versionsnummer <span class="muted small">(leer lassen: wird aus version.php gelesen)</span><input type="text" name="version" placeholder="1.0.96" pattern="[0-9]+[.][0-9]+[.][0-9]+([+-][A-Za-z0-9.-]+)?"></label>
<label>Änderungen (erscheint im Update-Fenster)<textarea name="notes" rows="2" maxlength="2000"></textarea></label>
</div>
<div><button type="submit">Update veröffentlichen</button></div>
</form>
</section>
<section class="card">
<header><div><h2>Veröffentlichte Versionen</h2><p>DeskHub zeigt die neuesten 20 unter „Bestimmte Version installieren“.</p></div></header>
<?php if (!$serverMine): ?><div class="empty">Noch keine Version veröffentlicht. Bis dahin nutzt DeskHub die Ausweichquelle (Gitea).</div><?php else: ?>
<div class="tablewrap"><table>
<thead><tr><th>Version</th><th>Datum</th><th>Größe</th><th>Änderungen</th><th></th></tr></thead>
<?php foreach ($serverMine as $release): $isLatest = $serverCur && (int) $serverCur['id'] === (int) $release['id']; ?><tr>
<td><strong><?= site_h($release['version']) ?></strong> <?= $isLatest ? '<span class="badge ok">aktuell</span>' : '' ?></td>
<td class="muted"><?= site_h(substr($release['created_at'], 0, 16)) ?></td><td><?= number_format((int) $release['size'] / 1048576, 1, ',', '.') ?> MB</td>
<td class="muted small"><?= site_h(mb_substr((string) $release['notes'], 0, 120)) ?></td>
<td class="actions"><?= admin_btn('delete_app', ['id' => (int) $release['id']], 'Entfernen', 'link danger', 'Diese Version wirklich entfernen?') ?></td>
</tr><?php endforeach; ?>
</table></div>
<?php endif; ?>
</section>
<?php elseif ($tab === 'push'): ?> <?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="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> <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>
+33 -14
View File
@@ -4,7 +4,38 @@ require_once __DIR__ . '/inc/lib.php';
site_require_access(); site_require_access();
$platform = (string) ($_GET['platform'] ?? ''); $platform = (string) ($_GET['platform'] ?? '');
$release = site_app_release($platform); $wanted = (string) ($_GET['version'] ?? '');
$json = static function (array $data): void {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
};
/** Angaben zu einer Version; für das System-Update (server) mit Signatur des Herausgebers. */
$describe = static function (string $platform, array $r): array {
$d = [
'platform' => $platform,
'version' => $r['version'],
'sha256' => $r['sha256'],
'size' => (int) $r['size'],
'notes' => $r['notes'],
'created_at' => $r['created_at'],
];
if ($platform === 'server') $d['signature'] = site_sign_update((string) $r['version'], (string) $r['sha256']);
return $d;
};
// Liste aller Versionen (System-Update: „Bestimmte Version installieren“)
if (($_GET['list'] ?? '') === '1' && isset(site_app_platforms()[$platform])) {
$json(['platform' => $platform, 'releases' => array_map(fn($r) => $describe($platform, $r), array_slice(site_app_versions($platform), 0, 20))]);
}
$release = null;
if ($wanted !== '' && isset(site_app_platforms()[$platform]) && preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', ltrim($wanted, 'vV'))) {
foreach (site_app_versions($platform) as $r) if ($r['version'] === ltrim($wanted, 'vV')) { $release = $r; break; }
} elseif ($wanted === '') {
$release = site_app_release($platform);
}
if (!$release) { if (!$release) {
http_response_code(404); http_response_code(404);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
@@ -15,19 +46,7 @@ if (!is_file($path)) {
http_response_code(404); http_response_code(404);
exit; exit;
} }
if (($_GET['download'] ?? '') !== '1') { if (($_GET['download'] ?? '') !== '1') $json($describe($platform, $release));
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;
}
$kind = site_app_platforms()[$platform]; $kind = site_app_platforms()[$platform];
$filename = $kind['file']; $filename = $kind['file'];
+5
View File
@@ -137,3 +137,8 @@ details.card p { overflow-wrap: anywhere; }
.thumb-sm { width: 96px; height: 60px; object-fit: cover; border-radius: 8px; background: var(--line); } .thumb-sm { width: 96px; height: 60px; object-fit: cover; border-radius: 8px; background: var(--line); }
.thumb-sm.none { display: inline-flex; align-items: center; justify-content: center; font-size: 12px; border: 1px dashed var(--line); opacity: .7; } .thumb-sm.none { display: inline-flex; align-items: center; justify-content: center; font-size: 12px; border: 1px dashed var(--line); opacity: .7; }
.previewrow input[type=file] { max-width: 230px; } .previewrow input[type=file] { max-width: 230px; }
.langs { margin-left: auto; display: inline-flex; gap: 6px; font-size: 12px; }
.langs .lang { padding: 3px 8px; border-radius: 6px; border: 1px solid var(--line); text-decoration: none; color: inherit; opacity: .75; }
.langs .lang.on { opacity: 1; font-weight: 700; background: var(--card-2); }
.langs a.lang:hover { opacity: 1; }
+148
View File
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
/**
* Sprachwahl der Verwaltung (Deutsch ist die Quellsprache, English kommt aus lang/en.json).
*
* Übersetzt wird die fertige Seite, so wie bei DeskHub selbst: Textstücke zwischen den HTML-Tags und die Attribute placeholder, title,
* alt, aria-label und data-confirm. Ein Schlüssel ist der deutsche Text (Leerraum zählt nicht); "{}" steht für einen wechselnden Teil
* und "{#}" für eine Zahl; in der Übersetzung stehen "{1}", "{2}" für die gefundenen Teile (oder "{}" / "{#}" der Reihe nach).
* Was nicht übersetzt ist, bleibt deutsch. Inhalte in <code>, <pre>, <script> und <style> bleiben unverändert.
*
* Die Sprache steht in einem Cookie (site_lang); ?lang=en oder ?lang=de schaltet um.
*/
const SITE_LANGS = ['de' => 'Deutsch', 'en' => 'English'];
/** Gewählte Sprache: ?lang= (wird im Cookie gemerkt), sonst das Cookie, sonst Deutsch. */
function site_lang(): string
{
static $lang = null;
if ($lang !== null) return $lang;
$lang = 'de';
$asked = (string) ($_GET['lang'] ?? '');
if (isset(SITE_LANGS[$asked])) {
$lang = $asked;
if (!headers_sent()) {
setcookie('site_lang', $asked, ['expires' => time() + 365 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'httponly' => true, 'samesite' => 'Strict']);
}
} elseif (isset(SITE_LANGS[(string) ($_COOKIE['site_lang'] ?? '')])) {
$lang = (string) $_COOKIE['site_lang'];
}
return $lang;
}
/** @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>} */
function site_i18n_dictionary(string $lang): array
{
static $cache = [];
if (isset($cache[$lang])) return $cache[$lang];
$exact = [];
$patterns = [];
$file = dirname(__DIR__) . '/lang/' . $lang . '.json';
$data = $lang !== 'de' && preg_match('/^[a-z]{2}$/', $lang) && is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
if (is_array($data)) {
foreach ($data as $de => $tr) {
if (!is_string($de) || !is_string($tr)) continue;
$key = site_i18n_norm($de);
if (strpos($key, '{}') === false && strpos($key, '{#}') === false) {
$exact[$key] = $tr;
continue;
}
$quoted = preg_quote($key, '#');
$regex = '#^' . str_replace(['\{\}', '\{\#\}'], ['(.+?)', '(\d+)'], $quoted) . '$#su';
$patterns[] = [$regex, $tr];
}
// Längere (genauere) Muster zuerst
usort($patterns, fn($a, $b) => strlen($b[0]) <=> strlen($a[0]));
}
return $cache[$lang] = ['exact' => $exact, 'patterns' => $patterns];
}
function site_i18n_norm(string $s): string
{
return trim((string) preg_replace('/\s+/u', ' ', $s));
}
/** Übersetzt einen (entkodierten) Text oder gibt null zurück, wenn es keine Übersetzung gibt. */
function site_i18n_lookup(string $norm, array $dict, int $depth = 0): ?string
{
if ($norm === '') return null;
if (isset($dict['exact'][$norm])) return $dict['exact'][$norm];
foreach ($dict['patterns'] as [$regex, $tr]) {
if (!preg_match($regex, $norm, $m)) continue;
array_shift($m);
// Gefundene Teile selbst übersetzen, wenn möglich (zum Beispiel der Seitenname im Titel)
if ($depth < 2) foreach ($m as $i => $part) $m[$i] = site_i18n_lookup(site_i18n_norm($part), $dict, $depth + 1) ?? $part;
if (preg_match('/\{\d+\}/', $tr)) {
return (string) preg_replace_callback('/\{(\d+)\}/', fn($x) => $m[(int) $x[1] - 1] ?? '', $tr);
}
$i = 0;
return (string) preg_replace_callback('/\{#?\}/', function () use (&$i, $m) { return $m[$i++] ?? ''; }, $tr);
}
return null;
}
/** Übersetzt eine fertige HTML-Seite. */
function site_translate_html(string $html, string $lang): string
{
$dict = site_i18n_dictionary($lang);
if (!$dict['exact'] && !$dict['patterns']) return $html;
$h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$parts = preg_split('/(<!--.*?-->|<[^<>]*>)/su', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
$skip = 0;
$out = '';
foreach ($parts as $i => $part) {
if ($part === '') continue;
if ($i % 2 === 1) {
if (preg_match('/^<\s*(\/?)\s*(script|style|code|pre)\b/i', $part, $m)) {
$skip = max(0, $skip + ($m[1] === '/' ? -1 : (substr($part, -2) === '/>' ? 0 : 1)));
}
if ($part[1] !== '!' && $part[1] !== '/') {
$part = preg_replace_callback('/\b(placeholder|title|alt|aria-label|data-confirm)="([^"]*)"/i', function ($m) use ($dict, $h) {
$t = site_i18n_lookup(site_i18n_norm(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8')), $dict);
return $t === null ? $m[0] : $m[1] . '="' . $h($t) . '"';
}, $part) ?? $part;
}
$out .= $part;
continue;
}
if ($skip > 0) { $out .= $part; continue; }
$decoded = html_entity_decode($part, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$norm = site_i18n_norm($decoded);
$t = site_i18n_lookup($norm, $dict);
if ($t === null) { $out .= $part; continue; }
preg_match('/^\s*/u', $part, $lead);
preg_match('/\s*$/u', $part, $trail);
$out .= ($lead[0] ?? '') . htmlspecialchars($t, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8') . ($trail[0] ?? '');
}
return $out;
}
/** Startet die Übersetzung der folgenden Ausgabe (nur bei einer anderen Sprache als Deutsch). */
function site_i18n_start(): void
{
static $started = false;
if ($started) return;
$lang = site_lang();
if ($lang === 'de') return;
$started = true;
ob_start(fn(string $buffer) => site_translate_html($buffer, $lang));
}
/** Umschalter für den Seitenkopf: die jeweils andere Sprache als Link (behält die übrigen Angaben der Adresse). */
function site_lang_switcher(): string
{
$current = site_lang();
$query = $_GET;
$out = '';
foreach (SITE_LANGS as $code => $name) {
if ($code === $current) {
$out .= '<span class="lang on" aria-current="true">' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</span>';
continue;
}
$query['lang'] = $code;
$out .= '<a class="lang" href="?' . htmlspecialchars(http_build_query($query), ENT_QUOTES, 'UTF-8') . '" hreflang="' . $code . '">' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</a>';
}
return '<span class="langs">' . $out . '</span>';
}
+5 -1
View File
@@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/lib.php'; require_once __DIR__ . '/lib.php';
require_once __DIR__ . '/i18n.php';
function site_headers(): void function site_headers(): void
{ {
@@ -17,6 +18,7 @@ function site_tabs(): array
'overview' => ['Übersicht', '◧'], 'overview' => ['Übersicht', '◧'],
'packages' => ['Pakete', '▣'], 'packages' => ['Pakete', '▣'],
'apps' => ['Apps', '⬇'], 'apps' => ['Apps', '⬇'],
'server' => ['DeskHub-Update', '⟳'],
'push' => ['Push-Dienst', '🔔'], 'push' => ['Push-Dienst', '🔔'],
'settings' => ['Einstellungen', '⚙'], 'settings' => ['Einstellungen', '⚙'],
]; ];
@@ -25,9 +27,10 @@ function site_tabs(): array
function site_header(string $title, string $active = '', bool $nav = false): void function site_header(string $title, string $active = '', bool $nav = false): void
{ {
site_headers(); site_headers();
site_i18n_start(); // Sprachwahl: übersetzt die folgende Ausgabe (Deutsch bleibt unverändert)
$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="<?= site_h(site_lang()) ?>"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0f6f6a"> <meta name="theme-color" content="#0f6f6a">
<title><?= site_h($title) ?> · <?= site_h($name) ?></title> <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.svg" type="image/svg+xml">
@@ -45,6 +48,7 @@ function site_header(string $title, string $active = '', bool $nav = false): voi
<?php endforeach; ?> <?php endforeach; ?>
</nav> </nav>
<?php endif; ?> <?php endif; ?>
<?= site_lang_switcher() ?>
</div></header> </div></header>
<main class="wrap"> <main class="wrap">
<?php <?php
+25 -3
View File
@@ -116,6 +116,7 @@ function site_app_platforms(): array
'minecraft' => ['label' => 'Minecraft', 'what' => 'Plugin (JAR)', 'ext' => 'jar', 'magic' => 'PK', 'file' => 'DeskHub-Minecraft.jar', 'mime' => 'application/java-archive'], 'minecraft' => ['label' => 'Minecraft', 'what' => 'Plugin (JAR)', 'ext' => 'jar', 'magic' => 'PK', 'file' => 'DeskHub-Minecraft.jar', 'mime' => 'application/java-archive'],
'wordpress' => ['label' => 'WordPress', 'what' => 'Plugin (ZIP)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-tickets-wordpress.zip', 'mime' => 'application/zip'], 'wordpress' => ['label' => 'WordPress', 'what' => 'Plugin (ZIP)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-tickets-wordpress.zip', 'mime' => 'application/zip'],
'chrome' => ['label' => 'Chrome', 'what' => 'Erweiterung (ZIP)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-chrome-erweiterung.zip', 'mime' => 'application/zip'], 'chrome' => ['label' => 'Chrome', 'what' => 'Erweiterung (ZIP)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-chrome-erweiterung.zip', 'mime' => 'application/zip'],
'server' => ['label' => 'DeskHub-Server', 'what' => 'System-Update (ZIP des Projekts)', 'ext' => 'zip', 'magic' => 'PK', 'file' => 'deskhub-update.zip', 'mime' => 'application/zip'],
]; ];
} }
@@ -142,6 +143,8 @@ function site_zip_app_version(string $platform, string $zipPath): ?string
if ($platform === 'chrome' && preg_match('#^(?:[^/]+/)?manifest\.json$#', $name)) { if ($platform === 'chrome' && preg_match('#^(?:[^/]+/)?manifest\.json$#', $name)) {
$data = json_decode((string) $zip->getFromIndex($i), true); $data = json_decode((string) $zip->getFromIndex($i), true);
if (is_array($data) && isset($data['manifest_version'], $data['version'])) $found = (string) $data['version']; if (is_array($data) && isset($data['manifest_version'], $data['version'])) $found = (string) $data['version'];
} elseif ($platform === 'server' && preg_match('#^(?:[^/]+/)?version\.php$#', $name)) {
if (preg_match("/define\s*\(\s*'DESKHUB_VERSION'\s*,\s*'(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)'/", (string) $zip->getFromIndex($i, 8192), $m)) $found = $m[1];
} elseif ($platform === 'wordpress' && preg_match('#\.php$#i', $name)) { } elseif ($platform === 'wordpress' && preg_match('#\.php$#i', $name)) {
$head = (string) $zip->getFromIndex($i, 8192); $head = (string) $zip->getFromIndex($i, 8192);
if (preg_match('/^[ \/*#@]*Plugin Name:/mi', $head) && preg_match('/^[ \/*#@]*Version:\s*([0-9][0-9A-Za-z.+-]*)/mi', $head, $m)) $found = $m[1]; if (preg_match('/^[ \/*#@]*Plugin Name:/mi', $head) && preg_match('/^[ \/*#@]*Version:\s*([0-9][0-9A-Za-z.+-]*)/mi', $head, $m)) $found = $m[1];
@@ -164,6 +167,25 @@ function site_app_release(string $platform): ?array
return $rows[0]; return $rows[0];
} }
/** Signatur für das System-Update (Nachricht wie includes/update_security.php). Leer, wenn kein Signierschlüssel eingerichtet ist. */
function site_sign_update(string $version, string $sha256): string
{
$key = site_signing_key();
if (!$key) return '';
openssl_sign("deskhub-update\n" . ltrim(trim($version), 'vV') . "\n" . strtolower($sha256), $sig, $key, OPENSSL_ALGO_SHA256);
return base64_encode($sig);
}
/** Alle Versionen einer Plattform, neueste zuerst. */
function site_app_versions(string $platform): array
{
$stmt = site_db()->prepare('SELECT * FROM app_releases WHERE platform = ?');
$stmt->execute([$platform]);
$rows = $stmt->fetchAll();
usort($rows, fn($a, $b) => version_compare($b['version'], $a['version']) ?: ((int) $b['id'] <=> (int) $a['id']));
return $rows;
}
function site_app_releases(): array function site_app_releases(): array
{ {
$rows = site_db()->query('SELECT * FROM app_releases')->fetchAll(); $rows = site_db()->query('SELECT * FROM app_releases')->fetchAll();
@@ -181,10 +203,10 @@ function site_publish_app(string $platform, string $tmp, string $version, string
if ($size < 1024 || $size > SITE_MAX_APP_BYTES) return ['ok' => false, 'error' => 'Die Datei muss zwischen 1 KB und 300 MB groß sein.']; 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); $head = (string) file_get_contents($tmp, false, null, 0, 2);
if ($head !== $kind['magic']) return ['ok' => false, 'error' => 'Dateityp passt nicht zur gewählten Plattform.']; if ($head !== $kind['magic']) return ['ok' => false, 'error' => 'Dateityp passt nicht zur gewählten Plattform.'];
if (in_array($platform, ['wordpress', 'chrome'], true)) { if (in_array($platform, ['wordpress', 'chrome', 'server'], true)) {
// Version steht im Paket (Plugin-Kopf bzw. manifest.json); eine eingetippte Nummer hat Vorrang // Version steht im Paket (Plugin-Kopf, manifest.json bzw. version.php); eine eingetippte Nummer hat Vorrang
$inZip = site_zip_app_version($platform, $tmp); $inZip = site_zip_app_version($platform, $tmp);
if ($inZip === null) return ['ok' => false, 'error' => $platform === 'wordpress' ? 'Das ist kein WordPress-Plugin: In einer PHP-Datei fehlt der Kopf mit "Plugin Name:" und "Version:".' : 'Das ist keine Chrome-Erweiterung: manifest.json mit "version" fehlt.']; if ($inZip === null) return ['ok' => false, 'error' => ['wordpress' => 'Das ist kein WordPress-Plugin: In einer PHP-Datei fehlt der Kopf mit "Plugin Name:" und "Version:".', 'chrome' => 'Das ist keine Chrome-Erweiterung: manifest.json mit "version" fehlt.', 'server' => 'Das ist kein DeskHub-Paket: version.php mit DESKHUB_VERSION fehlt.'][$platform]];
if ($version === '') $version = $inZip; if ($version === '') $version = $inZip;
} }
if (!preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 3.4.0 eingeben.']; if (!preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) return ['ok' => false, 'error' => 'Bitte eine Versionsnummer wie 3.4.0 eingeben.'];
+196
View File
@@ -0,0 +1,196 @@
{
"Übersicht": "Overview",
"Pakete": "Packages",
"Apps": "Apps",
"Push-Dienst": "Push service",
"Einstellungen": "Settings",
"Bereiche": "Sections",
"Anmelden": "Sign in",
"Abmelden": "Sign out",
"Benutzername": "Username",
"Passwort": "Password",
"Verwaltung der DeskHub-Plugins, Themes und Apps.": "Administration of the DeskHub plugins, themes and apps.",
"DeskHub Plugins und Themes": "DeskHub plugins and themes",
"{} · Verwaltung · DeskHub Plugins und Themes": "{1} · Administration · DeskHub plugins and themes",
"{} · DeskHub Plugins und Themes": "{1} · DeskHub plugins and themes",
"Fremder Code läuft mit allen Rechten auf eurem Server. Installiert nur Pakete, deren Herkunft ihr prüfen könnt, am besten signierte.": "Third-party code runs with full rights on your server. Only install packages whose origin you can verify, preferably signed ones.",
"Alles Wichtige auf einen Blick: veröffentlichte Pakete, aktuelle App-Versionen und der Zustand der Dienste.": "Everything important at a glance: published packages, current app versions and the state of the services.",
"Plugins": "Plugins",
"Themes": "Themes",
"Windows": "Windows",
"Android": "Android",
"Minecraft": "Minecraft",
"WordPress": "WordPress",
"Chrome": "Chrome",
"Push-Geräte": "Push devices",
"{#} veröffentlicht": "{#} published",
"veröffentlicht": "published",
"aktuelle App-Version": "current app version",
"angemeldete Installationen": "registered installations",
"Systemstatus": "System status",
"Einrichtung dieser Webseite": "Setup of this website",
"Signierschlüssel": "Signing key",
"Eingerichtet – neue Pakete werden automatisch signiert.": "Set up – new packages are signed automatically.",
"Fehlt (signing_key_file in config.php) – Pakete werden unsigniert veröffentlicht.": "Missing (signing_key_file in config.php) – packages are published unsigned.",
"Kein Firebase-Dienstkonto": "No Firebase service account",
"Kein Firebase-Dienstkonto eingebaut – Push-Nachrichten sind nicht möglich.": "No Firebase service account built in – push messages are not possible.",
"Firebase-Dienstkonto eingebaut": "Firebase service account built in",
"Firebase-Projekt {} eingebaut.": "Firebase project {1} built in.",
"Projekt {}": "Project {1}",
"Zugangsschlüssel für Katalog und Downloads": "Access key for catalog and downloads",
"Nicht gesetzt – Katalog, Pakete und App-Dateien sind für jeden abrufbar. Image mit Zugangsschlüssel bauen (docker-build.ps1 erzeugt ihn).": "Not set – catalog, packages and app files can be fetched by anyone. Build the image with an access key (docker-build.ps1 creates it).",
"Gesetzt – nur DeskHub-Server mit dem Schlüssel dürfen Pakete und Apps laden.": "Set – only DeskHub servers with the key may load packages and apps.",
"Schnellzugriff": "Quick access",
"Häufige Aufgaben": "Common tasks",
"Plugin oder Theme hochladen": "Upload plugin or theme",
"App-Version veröffentlichen": "Publish app version",
"DeskHub-Einstellungen": "DeskHub settings",
"Plugins und Themes für den DeskHub-Katalog hochladen und verwalten.": "Upload and manage plugins and themes for the DeskHub catalog.",
"Neues Paket veröffentlichen": "Publish new package",
"Art": "Type",
"Plugin": "Plugin",
"Theme": "Theme",
"ZIP-Paket": "ZIP package",
"ZIP-Datei mit": "ZIP file with",
"bzw. Theme-CSS": "or theme CSS",
"Nur für Themes (bei Plugins steht alles in der plugin.json)": "Themes only (for plugins everything is in plugin.json)",
"Kennung (Slug)": "Identifier (slug)",
"Name": "Name",
"Version": "Version",
"1.0.0 (sonst aus dem CSS-Kopf)": "1.0.0 (otherwise from the CSS header)",
"Autor": "Author",
"Beschreibung": "Description",
"Vorschaubild (nur beim ersten Mal nötig, PNG/JPG/WebP, höchstens 1 MB)": "Preview image (only needed the first time, PNG/JPG/WebP, 1 MB at most)",
"Bei einem Update von Plugin oder Theme bleibt das vorhandene Bild erhalten: einfach weglassen. Ändern oder entfernen kannst du es unten bei dem Eintrag.": "When you update a plugin or theme the existing image is kept: just leave it out. You can change or remove it below at the entry.",
"Veröffentlichen": "Publish",
"Noch keine Plugins veröffentlicht.": "No plugins published yet.",
"Noch keine Themes veröffentlicht.": "No themes published yet.",
"★ Empfohlen": "★ Recommended",
"Als Empfehlung markieren": "Mark as recommended",
"Empfehlung entfernen": "Remove recommendation",
"kein Bild": "no image",
"Bild ersetzen": "Replace image",
"Bild hinzufügen": "Add image",
"Bild entfernen": "Remove image",
"Vorschaubild wirklich entfernen?": "Really remove the preview image?",
"Vorschaubild von {}": "Preview image of {1}",
"{#} Downloads": "{#} downloads",
"signiert": "signed",
"nicht signiert": "not signed",
"Entfernen": "Remove",
"Version wirklich entfernen?": "Really remove this version?",
"Windows-, Android-App, Minecraft-Plugin, WordPress-Plugin und Chrome-Erweiterung veröffentlichen. Die Apps zeigen ihren Nutzern automatisch „Update verfügbar“, sobald hier eine höhere Version liegt.": "Publish the Windows and Android apps, Minecraft plugin, WordPress plugin and Chrome extension. The apps automatically show their users “Update available” as soon as a higher version is here.",
"Neue App-Version hochladen": "Upload new app version",
"Windows-Setups dürfen bis 300 MB groß sein (PHP-Grenzen": "Windows setups may be up to 300 MB (note the PHP limits",
"beachten).": ").",
"Plattform": "Platform",
"Versionsnummer": "Version number",
"(leer lassen: wird aus dem Paket gelesen)": "(leave empty: read from the package)",
"Installationsdatei": "Installation file",
"Änderungen (optional, erscheint im Update-Hinweis)": "Changes (optional, shown in the update notice)",
"App-Version hochladen": "Upload app version",
"Windows – Setup (EXE)": "Windows – Setup (EXE)",
"Android – Installationsdatei (APK)": "Android – Installation file (APK)",
"Minecraft – Plugin (JAR)": "Minecraft – Plugin (JAR)",
"WordPress – Plugin (ZIP)": "WordPress – Plugin (ZIP)",
"Chrome – Erweiterung (ZIP)": "Chrome – Extension (ZIP)",
"Setup (EXE)": "Setup (EXE)",
"Installationsdatei (APK)": "Installation file (APK)",
"Plugin (JAR)": "Plugin (JAR)",
"Plugin (ZIP)": "Plugin (ZIP)",
"Erweiterung (ZIP)": "Extension (ZIP)",
"aktuell": "current",
"aktuell {}": "current {1}",
"noch nichts veröffentlicht": "nothing published yet",
"Noch keine Version veröffentlicht.": "No version published yet.",
"Datum": "Date",
"Größe": "Size",
"Diese App-Version wirklich entfernen?": "Really remove this app version?",
"Zentraler Push-Dienst der Android-App. Hier siehst du die angemeldeten DeskHub-Installationen.": "Central push service of the Android app. Here you see the registered DeskHub installations.",
"schluessel/firebase-dienstkonto.json fehlte beim Bauen – Push-Nachrichten sind nicht möglich.": "schluessel/firebase-dienstkonto.json was missing at build time – push messages are not possible.",
"Angemeldete Installationen": "Registered installations",
"{#} Einträge": "{#} entries",
"Noch keine DeskHub-Installation angemeldet.": "No DeskHub installation registered yet.",
"Angemeldet": "Registered",
"Zuletzt genutzt": "Last used",
"Gesendet": "Sent",
"Status": "Status",
"noch nie": "never",
"gesperrt": "blocked",
"aktiv": "active",
"Sperren": "Block",
"Entsperren": "Unblock",
"Löschen": "Delete",
"Installation wirklich löschen?": "Really delete this installation?",
"Einrichtung für DeskHub und Konto.": "Setup for DeskHub and account.",
"Nicht eingerichtet (": "Not set up (",
") – ohne Schlüssel ist der geprüfte Modus nicht nutzbar.": ") – without a key the verified mode cannot be used.",
"Einstellungen für DeskHub (offizieller Katalog)": "Settings for DeskHub (official catalog)",
"Diesen Inhalt in": "Put this content into",
"von DeskHub einsetzen, bevor ihr ein Release baut. Dann installieren eure Kunden Plugins nur aus diesem Katalog, und nur signierte.": "of DeskHub before you build a release. Your customers then install plugins only from this catalog, and only signed ones.",
"Konto": "Account",
"Du bist als Administrator angemeldet.": "You are signed in as administrator.",
"Veröffentlicht: {} {} (signiert).": "Published: {1} {2} (signed).",
"Veröffentlicht: {} {} (NICHT signiert, kein Signierschlüssel eingerichtet).": "Published: {1} {2} (NOT signed, no signing key set up).",
"App-Version {} veröffentlicht.": "App version {1} published.",
"App-Version entfernt.": "App version removed.",
"App-Version wurde nicht gefunden.": "App version was not found.",
"Version entfernt.": "Version removed.",
"Version nicht gefunden.": "Version not found.",
"Eintrag nicht gefunden.": "Entry not found.",
"Nicht gefunden.": "Not found.",
"Installation gesperrt.": "Installation blocked.",
"Installation entsperrt.": "Installation unblocked.",
"Installation gelöscht (sie meldet sich beim nächsten Senden von selbst neu an).": "Installation deleted (it registers again by itself the next time it sends).",
"Ungültige Installation.": "Invalid installation.",
"Vorschaubild geändert. Es bleibt bei weiteren Versionen erhalten.": "Preview image changed. It is kept for further versions.",
"Vorschaubild entfernt.": "Preview image removed.",
"Bitte ein Bild auswählen (PNG, JPG oder WebP, höchstens 1 MB).": "Please choose an image (PNG, JPG or WebP, 1 MB at most).",
"Das Vorschaubild muss PNG, JPG oder WebP sein und darf höchstens 1 MB groß sein.": "The preview image must be PNG, JPG or WebP and 1 MB at most.",
"Das Bild konnte nicht gespeichert werden.": "The image could not be saved.",
"Als „Empfohlen“ markiert. DeskHub zeigt es nach der nächsten Katalog-Aktualisierung (spätestens in wenigen Minuten).": "Marked as “Recommended”. DeskHub shows it after the next catalog refresh (within a few minutes at the latest).",
"Empfehlung entfernt.": "Recommendation removed.",
"Benutzername oder Passwort ist falsch.": "Username or password is wrong.",
"Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.": "Too many failed attempts. Please try again in 15 minutes.",
"Die Verwaltung ist nicht eingerichtet: In config.php fehlt admin_password_hash.": "The administration is not set up: admin_password_hash is missing in config.php.",
"Ungültige Anfrage. Bitte neu anmelden.": "Invalid request. Please sign in again.",
"Bitte eine ZIP-Datei hochladen.": "Please upload a ZIP file.",
"Unbekannter Typ.": "Unknown type.",
"Unbekannte Plattform.": "Unknown platform.",
"Die hochgeladene Datei wurde nicht gefunden.": "The uploaded file was not found.",
"Die Datei muss zwischen 1 KB und 300 MB groß sein.": "The file must be between 1 KB and 300 MB in size.",
"Dateityp passt nicht zur gewählten Plattform.": "File type does not match the chosen platform.",
"Bitte eine Versionsnummer wie 3.4.0 eingeben.": "Please enter a version number like 3.4.0.",
"App-Speicher konnte nicht angelegt werden.": "App storage could not be created.",
"App-Datei konnte nicht gespeichert werden.": "App file could not be saved.",
"Diese Version ist für diese Plattform bereits vorhanden.": "This version already exists for this platform.",
"Release konnte nicht gespeichert werden.": "The release could not be saved.",
"Das ist kein WordPress-Plugin: In einer PHP-Datei fehlt der Kopf mit \"Plugin Name:\" und \"Version:\".": "This is not a WordPress plugin: no PHP file has the header with \"Plugin Name:\" and \"Version:\".",
"Das ist keine Chrome-Erweiterung: manifest.json mit \"version\" fehlt.": "This is not a Chrome extension: manifest.json with \"version\" is missing.",
"Die PHP-Erweiterung zip fehlt.": "The PHP extension zip is missing.",
"Die ZIP-Datei konnte nicht geöffnet werden.": "The ZIP file could not be opened.",
"Alle Dateien müssen im selben Ordner liegen.": "All files must be in the same folder.",
"Das Paket enthält zu viele oder zu große Dateien.": "The package contains too many or too large files.",
"plugin.json ist kein gültiges JSON.": "plugin.json is not valid JSON.",
"plugin.json: \"name\" fehlt.": "plugin.json: \"name\" is missing.",
"plugin.json: ungültige Versionsnummer.": "plugin.json: invalid version number.",
"Dieser Plugin-Name ist reserviert.": "This plugin name is reserved.",
"Bitte einen Namen angeben.": "Please enter a name.",
"Das Paket konnte nicht gespeichert werden.": "The package could not be saved.",
"Version {} existiert bereits. Bitte eine neue Versionsnummer verwenden, veröffentlichte Versionen bleiben unverändert.": "Version {1} already exists. Please use a new version number, published versions stay unchanged.",
"Das Paket ist leer oder größer als {#} MB.": "The package is empty or larger than {#} MB.",
"{} wurde im Paket nicht gefunden.": "{1} was not found in the package.",
"Unzulässige Datei im Paket: {}": "Not allowed file in the package: {1}",
"Speichern fehlgeschlagen: {}": "Saving failed: {1}",
"DeskHub-Update": "DeskHub update",
"aktuelles System-Update": "current system update",
"DeskHub-Server": "DeskHub server",
"Das ZIP des DeskHub-Servers für das System-Update. Alle DeskHub-Installationen holen neue Versionen von hier (Adminportal → System-Update). Die Version wird aus <code>version.php</code> im Paket gelesen, das Paket wird mit dem Schlüssel dieser Seite automatisch signiert.": "The ZIP of the DeskHub server for the system update. All DeskHub installations fetch new versions from here (admin portal → System update). The version is read from <code>version.php</code> in the package; the package is signed automatically with this site’s key.",
"Kein Signierschlüssel eingerichtet: Die Pakete werden nicht signiert, DeskHub mit eingebautem Herausgeber-Schlüssel lehnt sie ab. Siehe Einstellungen.": "No signing key set up: packages are not signed, and DeskHub with a built-in publisher key rejects them. See Settings.",
"Neue Version hochladen": "Upload new version",
"Update-Paket (ZIP)": "Update package (ZIP)",
"Update veröffentlichen": "Publish update",
"Veröffentlichte Versionen": "Published versions",
"Änderungen (erscheint im Update-Fenster)": "Changes (shown in the update window)",
"(leer lassen: wird aus version.php gelesen)": "(leave empty: read from version.php)"
}