Upload via GUI (28 Dateien)

This commit is contained in:
2026-10-21 16:55:37 +00:00
parent 4abd3453c9
commit 65b336373d
12 changed files with 315 additions and 3 deletions
+1
View File
@@ -4,3 +4,4 @@ storage
.git
docker-compose.yml
Caddyfile
schluessel
+2
View File
@@ -30,12 +30,14 @@ ARG ADMIN_USER=admin
RUN --mount=type=secret,id=admin_hash,required=true \
--mount=type=secret,id=signing_key,required=true \
--mount=type=secret,id=catalog_key,required=true \
--mount=type=secret,id=fcm_service_account,required=false \
mkdir -p /opt/plugin-site-secure /data \
&& php /tmp/docker-bake.php \
&& rm /tmp/docker-bake.php \
&& chown -R root:root /var/www/html \
&& chown root:www-data /var/www/html/config.php /opt/plugin-site-secure /opt/plugin-site-secure/herausgeber-privat.pem \
&& chmod 0440 /var/www/html/config.php /opt/plugin-site-secure/herausgeber-privat.pem \
&& if [ -f /opt/plugin-site-secure/fcm-dienstkonto.json ]; then chown root:www-data /opt/plugin-site-secure/fcm-dienstkonto.json && chmod 0440 /opt/plugin-site-secure/fcm-dienstkonto.json; fi \
&& chmod 0750 /opt/plugin-site-secure \
&& chmod -R go-w /var/www/html \
&& chown www-data:www-data /data
+13
View File
@@ -82,6 +82,19 @@ Der Container läuft mit schreibgeschütztem Dateisystem und ohne unnötige Rech
**Wichtig:** Das Image enthält den Passwort-Hash und den **privaten Signierschlüssel**. Wer das Image besitzt, besitzt beides. Nur in eine private Registry oder direkt auf den Server übertragen, nie öffentlich hochladen. Ein neues Passwort oder ein neuer Schlüssel bedeutet: neu bauen und das Image austauschen (bei einem neuen Schlüssel müssen alle DeskHub-Installationen den neuen öffentlichen Schlüssel erhalten).
## Push-Dienst für die Android-App (push.php)
Die Webseite ist auch der **zentrale Push-Dienst** der Android-App aus dem Store. Der Firebase-Schlüssel (Dienstkonto) liegt nur hier; die App und die DeskHub-Server der Kunden enthalten ihn nie.
1. Firebase-Konsole → Projekteinstellungen → Dienstkonten → „Neuen privaten Schlüssel generieren“ → die Datei als `schluessel/firebase-dienstkonto.json` ablegen (neben dem Signierschlüssel; der Ordner `schluessel/` ist von Git und vom Docker-Build ausgeschlossen). In der Firebase-Konsole muss die **Firebase Cloud Messaging API (V1)** aktiv sein.
2. Image neu bauen (`docker-build.ps1` bzw. `docker-build.sh`): Das Skript erkennt die Datei und baut sie als BuildKit-Secret fest ein (schreibgeschützt, nicht im Image-Verlauf). Ohne die Datei läuft die Webseite wie bisher, `push.php` antwortet dann mit 503.
3. DeskHub leitet die Adresse selbst aus der Katalog-Adresse ab (`https://<katalog-host>/push.php`). Jedes DeskHub meldet sich beim ersten Senden mit dem Zugangsschlüssel des Katalogs an (`access_key` in `includes/catalog_defaults.php`) und bekommt eine eigene Installations-ID samt Geheimnis (nur als Hash gespeichert).
Was der Dienst tut und nicht tut: Er nimmt nur Geräte-Token und Ticketnummer an (Betreff/Kundenname nur, wenn der Kunde „Details anzeigen“ einschaltet), baut den Text selbst, begrenzt auf 2000 Nachrichten je Installation und Stunde und 20 Anmeldungen je Adresse und Stunde und meldet abgemeldete Geräte zurück. Eine Installation sperren: in `storage/katalog.sqlite` (Volume `plugin_daten`) in Tabelle `push_installs` `blocked = 1` setzen. Datenschutz: Der Dienst und Google verarbeiten Geräte-Token und Ticketnummern; das gehört in eure Datenschutzerklärung für die App.
### Sicherung
`./backup.sh /home/docker/backups 14` sichert das Volume `plugin_daten` (Pakete, Katalog- und Push-Datenbank) als `.tar.gz` und löscht Sicherungen, die älter als 14 Tage sind. Als Cronjob z. B. täglich um 03:15. Die Schlüssel im Ordner `schluessel/` sind nicht enthalten und müssen getrennt gesichert werden. Die Wiederherstellung steht im Kopf des Skripts.
## Pakete selbst signieren (ohne automatische Signatur)
Wer den Schlüssel nicht auf dem Server haben möchte, lässt `signing_key_file` leer und signiert lokal mit dem Werkzeug `tools/plugin-sign.php` aus dem DeskHub-Paket. Die Webseite veröffentlicht dann unsignierte Pakete.
+26
View File
@@ -2,6 +2,7 @@
declare(strict_types=1);
/** Verwaltung der Webseite: Anmelden, Pakete veröffentlichen, Versionen entfernen. */
require_once __DIR__ . '/inc/layout.php';
require_once __DIR__ . '/inc/push.php';
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'httponly' => true, 'samesite' => 'Strict']);
session_name('plugin_site_admin');
@@ -71,6 +72,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($res['ok']) $message = "Veröffentlicht: {$res['slug']} {$res['version']} (" . ($res['signed'] ? 'signiert' : 'NICHT signiert, kein Signierschlüssel eingerichtet') . ').';
else $error = $res['error'];
}
} elseif (in_array($action, ['push_block', 'push_unblock', 'push_delete'], true)) {
$pid = (string) ($_POST['id'] ?? '');
if (preg_match('/^[a-f0-9]{24}$/', $pid)) {
$pp = push_db();
if ($action === 'push_delete') $pp->prepare('DELETE FROM push_installs WHERE id = ?')->execute([$pid]);
else $pp->prepare('UPDATE push_installs SET blocked = ? WHERE id = ?')->execute([$action === 'push_block' ? 1 : 0, $pid]);
$message = ['push_block' => 'Installation gesperrt.', 'push_unblock' => 'Installation entsperrt.', 'push_delete' => 'Installation gelöscht (sie meldet sich beim nächsten Senden von selbst neu an).'][$action];
} else {
$error = 'Ungültige Installation.';
}
} elseif ($action === 'delete') {
$message = site_delete_version((int) ($_POST['id'] ?? 0)) ? 'Version entfernt.' : null;
$error = $message ? null : 'Version nicht gefunden.';
@@ -116,6 +127,21 @@ site_header('Verwaltung');
<button type="submit">Veröffentlichen</button>
</form>
<h2>Push-Dienst (Android-App)</h2>
<?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(); ?>
<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>
<?php if (!$pushRows): ?><p class="muted">Noch keine DeskHub-Installation angemeldet.</p><?php else: ?>
<table class="admin">
<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>
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Sichert das Volume "plugin_daten" (veröffentlichte Pakete, Katalog- und Push-Datenbank) als .tar.gz und löscht alte Sicherungen.
# Aufruf auf dem Server im Ordner mit der docker-compose.yml: ./backup.sh [Zielordner] [Tage aufbewahren]
# Beispiel Cronjob (täglich 03:15): 15 3 * * * cd /home/docker/plugin-deskhub && ./backup.sh /home/docker/backups 14
# Wiederherstellen: docker compose down && docker run --rm -v plugin-deskhub_plugin_daten:/data -v /pfad:/backup alpine sh -c "rm -rf /data/* && tar xzf /backup/DATEI.tar.gz -C /data" && docker compose up -d
# Die Sicherung enthält Pakete und Installationsdaten, aber NICHT die Schlüssel (liegen im Ordner schluessel/, getrennt sichern).
set -euo pipefail
TARGET="${1:-./backups}"
KEEP_DAYS="${2:-14}"
PROJECT="$(basename "$(pwd)")"
VOLUME="${PROJECT}_plugin_daten"
if ! docker volume inspect "$VOLUME" >/dev/null 2>&1; then
echo "Volume $VOLUME nicht gefunden. Im Ordner mit der docker-compose.yml ausführen." >&2
exit 1
fi
[[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] || { echo "Tage muss eine Zahl sein." >&2; exit 1; }
mkdir -p "$TARGET"
chmod 700 "$TARGET"
FILE="plugin-daten-$(date +%Y%m%d-%H%M%S).tar.gz"
docker run --rm -v "$VOLUME":/data:ro -v "$(cd "$TARGET" && pwd)":/backup alpine tar czf "/backup/$FILE" -C /data .
chmod 600 "$TARGET/$FILE"
find "$TARGET" -maxdepth 1 -name 'plugin-daten-*.tar.gz' -mtime +"$KEEP_DAYS" -delete
echo "Gesichert: $TARGET/$FILE ($(du -h "$TARGET/$FILE" | cut -f1))"
+11
View File
@@ -15,6 +15,7 @@ $storage = getenv('BAKE_STORAGE_DIR') ?: '/data';
$hash = trim((string) @file_get_contents($secrets . '/admin_hash'));
$key = (string) @file_get_contents($secrets . '/signing_key');
$access = trim((string) @file_get_contents($secrets . '/catalog_key'));
$fcm = (string) @file_get_contents($secrets . '/fcm_service_account'); // optional: Firebase-Dienstkonto für den Push-Dienst
$base = rtrim((string) getenv('BASE_URL'), '/');
$user = (string) getenv('ADMIN_USER');
@@ -28,7 +29,15 @@ if (!$private) $fail('signing_key ist kein gültiger privater Schlüssel (PEM).'
$details = openssl_pkey_get_details($private);
if (($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA || ($details['bits'] ?? 0) < 2048) $fail('Der Signierschlüssel muss RSA mit mindestens 2048 Bit sein.');
$fcmFile = '';
if (trim($fcm) !== '') {
$sa = json_decode($fcm, true);
if (!is_array($sa) || ($sa['type'] ?? '') !== 'service_account' || empty($sa['client_email']) || empty($sa['project_id']) || !@openssl_pkey_get_private((string) ($sa['private_key'] ?? ''))) $fail('fcm_service_account ist keine gültige Firebase-Dienstkonto-Datei (JSON).');
$fcmFile = $keyDir . '/fcm-dienstkonto.json';
}
@mkdir($keyDir, 0750, true);
if ($fcmFile !== '') file_put_contents($fcmFile, $fcm);
file_put_contents($keyDir . '/herausgeber-privat.pem', $key);
$cfg = [
'site_name' => (string) (getenv('SITE_NAME') ?: 'DeskHub Plugins und Themes'),
@@ -38,7 +47,9 @@ $cfg = [
'signing_key_file' => $keyDir . '/herausgeber-privat.pem',
'storage_dir' => $storage,
'access_key' => $access,
'fcm_service_account_file' => $fcmFile,
'locked' => true,
];
file_put_contents($configPath, "<?php\nreturn " . var_export($cfg, true) . ";\n");
echo $fcmFile !== '' ? "Push-Dienst: Firebase-Dienstkonto fest eingebaut.\n" : "Push-Dienst: KEIN Firebase-Dienstkonto eingebaut (schluessel/firebase-dienstkonto.json fehlt).\n";
echo "Zugang für \"{$user}\" und Signierschlüssel fest eingebaut. Öffentlicher Schlüssel:\n" . $details['key'];
+7
View File
@@ -50,11 +50,18 @@ if (-not (Test-Path $keyFile)) {
Write-Host "Vorhandenen Signierschlüssel weiterverwenden: $keyFile"
}
# Firebase-Dienstkonto für den zentralen Push-Dienst (optional): Datei schluessel\firebase-dienstkonto.json aus der Firebase-Konsole
$fcmFile = Join-Path $KeyDir 'firebase-dienstkonto.json'
$fcmArgs = @()
if (Test-Path $fcmFile) { $fcmArgs = @('--secret', "id=fcm_service_account,src=$fcmFile"); Write-Host 'Firebase-Dienstkonto gefunden: Push-Dienst wird fest eingebaut.' }
else { Write-Host 'Hinweis: schluessel\firebase-dienstkonto.json fehlt - der Push-Dienst wird ohne Firebase-Schluessel gebaut (keine Push-Nachrichten).' }
$env:DOCKER_BUILDKIT = '1'
docker build `
--secret "id=admin_hash,src=$hashFile" `
--secret "id=signing_key,src=$keyFile" `
--secret "id=catalog_key,src=$catalogKeyFile" `
@fcmArgs `
--build-arg "BASE_URL=$BaseUrl" --build-arg "ADMIN_USER=$AdminUser" --build-arg "SITE_NAME=$SiteName" `
-t $Tag $PSScriptRoot
if ($LASTEXITCODE -ne 0) { throw 'docker build ist fehlgeschlagen.' }
+9 -1
View File
@@ -31,7 +31,15 @@ if [[ ! -f schluessel/herausgeber-privat.pem ]]; then
chmod 600 schluessel/herausgeber-privat.pem
fi
DOCKER_BUILDKIT=1 docker build \
FCM_ARGS=()
if [[ -f schluessel/firebase-dienstkonto.json ]]; then
FCM_ARGS=(--secret id=fcm_service_account,src=schluessel/firebase-dienstkonto.json)
echo "Firebase-Dienstkonto gefunden: Push-Dienst wird fest eingebaut."
else
echo "Hinweis: schluessel/firebase-dienstkonto.json fehlt - der Push-Dienst wird ohne Firebase-Schlüssel gebaut (keine Push-Nachrichten)."
fi
DOCKER_BUILDKIT=1 docker build "${FCM_ARGS[@]}" \
--secret id=admin_hash,src=schluessel/admin-hash.txt \
--secret id=signing_key,src=schluessel/herausgeber-privat.pem \
--secret id=catalog_key,src=schluessel/katalog-zugangsschluessel.txt \
+2 -2
View File
@@ -38,9 +38,9 @@ function site_config(): array
$locked = !empty($baked['locked']);
$file = $locked ? $default : (getenv('PLUGIN_SITE_CONFIG') ?: $default);
$cfg = is_file($file) ? (array) require $file : [];
$cfg += ['site_name' => 'DeskHub Plugins und Themes', 'base_url' => '', 'admin_user' => 'admin', 'admin_password_hash' => '', 'signing_key_file' => '', 'storage_dir' => dirname(__DIR__) . '/storage', 'access_key' => '', 'locked' => false];
$cfg += ['site_name' => 'DeskHub Plugins und Themes', 'base_url' => '', 'admin_user' => 'admin', 'admin_password_hash' => '', 'signing_key_file' => '', 'storage_dir' => dirname(__DIR__) . '/storage', 'access_key' => '', 'fcm_service_account_file' => '', 'locked' => false];
if (!$locked) {
foreach (['PLUGIN_SITE_STORAGE' => 'storage_dir', 'PLUGIN_SITE_BASE_URL' => 'base_url', 'PLUGIN_SITE_ADMIN_HASH' => 'admin_password_hash', 'PLUGIN_SITE_SIGNING_KEY' => 'signing_key_file', 'PLUGIN_SITE_ACCESS_KEY' => 'access_key'] as $env => $key) {
foreach (['PLUGIN_SITE_STORAGE' => 'storage_dir', 'PLUGIN_SITE_BASE_URL' => 'base_url', 'PLUGIN_SITE_ADMIN_HASH' => 'admin_password_hash', 'PLUGIN_SITE_SIGNING_KEY' => 'signing_key_file', 'PLUGIN_SITE_ACCESS_KEY' => 'access_key', 'PLUGIN_SITE_FCM_FILE' => 'fcm_service_account_file'] as $env => $key) {
if (getenv($env) !== false && getenv($env) !== '') $cfg[$key] = getenv($env);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
/**
* Zentraler Push-Dienst für die DeskHub-Android-App (Firebase Cloud Messaging, HTTP v1).
*
* Der Firebase-Dienstkonto-Schlüssel liegt nur hier (beim Bauen fest eingebaut, 'fcm_service_account_file'), nie in der App und nie
* auf den DeskHub-Servern der Kunden. Jedes DeskHub meldet sich einmal an (register) und bekommt eine eigene Installations-ID mit
* Geheimnis; damit darf es Nachrichten an Geräte-Token senden (send). Der Dienst baut den Text selbst: Im Standard steht nur
* "Neues Ticket" und die Ticketnummer drin. Betreff und Kundenname werden nur durchgereicht, wenn das DeskHub ausdrücklich "details" setzt.
*/
const PUSH_TYPES = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'test' => 'Testnachricht'];
const PUSH_MAX_TOKENS_PER_CALL = 50;
const PUSH_SEND_LIMIT_PER_HOUR = 2000; // Nachrichten je Installation und Stunde
const PUSH_REGISTER_LIMIT_PER_HOUR = 20; // Anmeldungen je Absenderadresse und Stunde
const PUSH_FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
function push_db(): PDO
{
$pdo = site_db();
$pdo->exec("CREATE TABLE IF NOT EXISTS push_installs (
id TEXT PRIMARY KEY, secret_hash TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', blocked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, last_used_at TEXT NULL, sent_total INTEGER NOT NULL DEFAULT 0)");
$pdo->exec('CREATE TABLE IF NOT EXISTS push_rate (bucket TEXT NOT NULL, hour INTEGER NOT NULL, n INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (bucket, hour))');
return $pdo;
}
/** Zählt einen Aufruf und sagt, ob das Limit überschritten ist. */
function push_rate_hit(PDO $pdo, string $bucket, int $limit, int $add = 1): bool
{
$hour = intdiv(time(), 3600);
$pdo->prepare('DELETE FROM push_rate WHERE hour < ?')->execute([$hour - 2]);
$pdo->prepare('INSERT OR IGNORE INTO push_rate (bucket, hour, n) VALUES (?, ?, 0)')->execute([$bucket, $hour]);
$pdo->prepare('UPDATE push_rate SET n = n + ? WHERE bucket = ? AND hour = ?')->execute([$add, $bucket, $hour]);
$s = $pdo->prepare('SELECT n FROM push_rate WHERE bucket = ? AND hour = ?');
$s->execute([$bucket, $hour]);
return (int) $s->fetchColumn() > $limit;
}
/** Dienstkonto aus der fest eingebauten Datei oder null, wenn der Dienst nicht eingerichtet ist. */
function push_service_account(): ?array
{
$file = (string) (site_config()['fcm_service_account_file'] ?? '');
if ($file === '' || !is_readable($file)) return null;
$d = json_decode((string) file_get_contents($file), true);
if (!is_array($d) || ($d['type'] ?? '') !== 'service_account') return null;
foreach (['client_email', 'private_key', 'project_id'] as $k) if (empty($d[$k]) || !is_string($d[$k])) return null;
$uri = (string) ($d['token_uri'] ?? 'https://oauth2.googleapis.com/token');
// Produktiv geht der Schlüssel nur an Googles Token-Adresse (nur im Testbetrieb darf sie abweichen)
if (getenv('PUSH_TESTING') !== '1' && !str_starts_with($uri, 'https://oauth2.googleapis.com/')) return null;
return ['client_email' => $d['client_email'], 'private_key' => $d['private_key'], 'project_id' => $d['project_id'], 'token_uri' => $uri];
}
function push_api_base(): string
{
if (getenv('PUSH_TESTING') === '1' && ($b = getenv('PUSH_FCM_API_BASE'))) return rtrim((string) $b, '/');
return 'https://fcm.googleapis.com';
}
function push_b64url(string $s): string
{
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
/** @return array{code:int,body:string,error:string} */
function push_http(string $url, array $headers, string $body): array
{
if (!function_exists('curl_init')) return ['code' => 0, 'body' => '', 'error' => 'PHP cURL fehlt'];
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS]);
$out = curl_exec($ch);
$r = ['code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'body' => is_string($out) ? $out : '', 'error' => $out === false ? curl_error($ch) : ''];
curl_close($ch);
return $r;
}
/** Zugriffstoken für FCM, bis kurz vor Ablauf in einer Datei im Speicherordner zwischengespeichert. */
function push_access_token(array $sa, bool $force = false): ?string
{
$cacheFile = site_storage() . '/fcm-token.json';
if (!$force && is_file($cacheFile)) {
$c = json_decode((string) file_get_contents($cacheFile), true);
if (is_array($c) && ($c['exp'] ?? 0) > time() + 120 && ($c['project'] ?? '') === $sa['project_id']) return (string) $c['token'];
}
$now = time();
$head = push_b64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claims = push_b64url(json_encode(['iss' => $sa['client_email'], 'scope' => PUSH_FCM_SCOPE, 'aud' => $sa['token_uri'], 'iat' => $now, 'exp' => $now + 3600]));
$key = openssl_pkey_get_private($sa['private_key']);
if (!$key || !openssl_sign($head . '.' . $claims, $sig, $key, OPENSSL_ALGO_SHA256)) return null;
$r = push_http($sa['token_uri'], ['Content-Type: application/x-www-form-urlencoded'],
http_build_query(['grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $head . '.' . $claims . '.' . push_b64url($sig)]));
$d = json_decode($r['body'], true);
if ($r['code'] !== 200 || empty($d['access_token'])) return null;
@file_put_contents($cacheFile, json_encode(['token' => $d['access_token'], 'exp' => $now + (int) ($d['expires_in'] ?? 3000), 'project' => $sa['project_id']]), LOCK_EX);
@chmod($cacheFile, 0600);
return (string) $d['access_token'];
}
/**
* Schickt eine reine Daten-Nachricht (hohe Priorität) an ein Gerät. @param array<string,string> $data
* @return array{ok:bool,gone:bool}
*/
function push_send_one(array $sa, string $token, array $data): array
{
$access = push_access_token($sa);
if ($access === null) return ['ok' => false, 'gone' => false];
$payload = json_encode(['message' => ['token' => $token, 'data' => $data, 'android' => ['priority' => 'HIGH', 'ttl' => '3600s']]], JSON_UNESCAPED_UNICODE);
$url = push_api_base() . '/v1/projects/' . rawurlencode($sa['project_id']) . '/messages:send';
for ($try = 0; $try < 2; $try++) {
$r = push_http($url, ['Authorization: Bearer ' . $access, 'Content-Type: application/json; charset=utf-8'], $payload);
if ($r['code'] === 401 && $try === 0) { $access = push_access_token($sa, true); if ($access === null) break; continue; }
break;
}
if (($r['code'] ?? 0) === 200) return ['ok' => true, 'gone' => false];
$status = (string) (json_decode($r['body'] ?? '', true)['error']['status'] ?? '');
return ['ok' => false, 'gone' => ($r['code'] ?? 0) === 404 || $status === 'NOT_FOUND' || $status === 'UNREGISTERED' || (($r['code'] ?? 0) === 400 && $status === 'INVALID_ARGUMENT')];
}
function push_token_valid(string $token): bool
{
return strlen($token) >= 20 && strlen($token) <= 4096 && preg_match('/^[A-Za-z0-9_:\-\.]+$/', $token) === 1;
}
/** Nachrichtentext: Standard nur mit Ticketnummer; Betreff/Name nur bei ausdrücklichem "details". @return array<string,string> */
function push_build_data(string $type, string $ticket, bool $details, string $body): array
{
$title = PUSH_TYPES[$type];
if ($type === 'test') return ['type' => 'test', 'ticket' => '', 'title' => $title, 'body' => 'Die Push-Benachrichtigungen funktionieren.'];
if ($details && trim($body) !== '') return ['type' => $type, 'ticket' => $ticket, 'title' => $title . ' #' . $ticket, 'body' => mb_strimwidth(trim($body), 0, 120, '…')];
return ['type' => $type, 'ticket' => $ticket, 'title' => $title, 'body' => '#' . $ticket];
}
function push_respond(int $code, array $body): never
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
exit(json_encode($body, JSON_UNESCAPED_UNICODE));
}
/** Bearbeitet einen Aufruf von push.php (JSON im Body). */
function push_handle(): never
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') push_respond(405, ['error' => 'Nur POST.']);
$raw = (string) file_get_contents('php://input', false, null, 0, 262144);
$in = json_decode($raw, true);
if (!is_array($in)) push_respond(400, ['error' => 'Ungültige Anfrage.']);
$pdo = push_db();
$action = (string) ($in['action'] ?? '');
if ($action === 'register') {
if (!site_access_ok()) { usleep(300000); push_respond(401, ['error' => 'Zugangsschlüssel fehlt oder ist falsch.']); }
if (push_rate_hit($pdo, 'reg:' . ($_SERVER['REMOTE_ADDR'] ?? ''), PUSH_REGISTER_LIMIT_PER_HOUR)) push_respond(429, ['error' => 'Zu viele Anmeldungen. Bitte später erneut versuchen.']);
$id = bin2hex(random_bytes(12));
$secret = bin2hex(random_bytes(24));
$name = mb_substr(preg_replace('/[^\p{L}\p{N} ._:\/@-]/u', '', (string) ($in['name'] ?? '')), 0, 120);
$pdo->prepare('INSERT INTO push_installs (id, secret_hash, name, created_at) VALUES (?,?,?,?)')->execute([$id, hash('sha256', $secret), $name, gmdate('Y-m-d H:i:s')]);
push_respond(200, ['installId' => $id, 'secret' => $secret]);
}
if ($action === 'send') {
$id = (string) ($in['installId'] ?? '');
$row = null;
if (preg_match('/^[a-f0-9]{24}$/', $id)) {
$q = $pdo->prepare('SELECT * FROM push_installs WHERE id = ?');
$q->execute([$id]);
$row = $q->fetch() ?: null;
}
if (!$row || !hash_equals((string) $row['secret_hash'], hash('sha256', (string) ($in['secret'] ?? '')))) { usleep(300000); push_respond(401, ['error' => 'Installation unbekannt.', 'reregister' => true]); }
if ((int) $row['blocked'] === 1) push_respond(403, ['error' => 'Diese Installation ist gesperrt.']);
$type = (string) ($in['type'] ?? '');
$ticket = (string) ($in['ticket'] ?? '');
$tokens = $in['tokens'] ?? null;
if (!isset(PUSH_TYPES[$type])) push_respond(422, ['error' => 'Unbekannter Nachrichtentyp.']);
if ($type !== 'test' && !preg_match('/^[A-Za-z0-9._#\-]{1,40}$/', $ticket)) push_respond(422, ['error' => 'Ungültige Ticketnummer.']);
if (!is_array($tokens) || !$tokens || count($tokens) > PUSH_MAX_TOKENS_PER_CALL) push_respond(422, ['error' => 'Zwischen 1 und ' . PUSH_MAX_TOKENS_PER_CALL . ' Geräte pro Aufruf.']);
$tokens = array_values(array_unique(array_filter(array_map('strval', $tokens), 'push_token_valid')));
if (!$tokens) push_respond(422, ['error' => 'Keine gültigen Geräte-Token.']);
if (push_rate_hit($pdo, 'send:' . $id, PUSH_SEND_LIMIT_PER_HOUR, count($tokens))) push_respond(429, ['error' => 'Stundenlimit erreicht.']);
$sa = push_service_account();
if (!$sa) push_respond(503, ['error' => 'Der Push-Dienst ist nicht eingerichtet.']);
$data = push_build_data($type, $ticket, !empty($in['details']), (string) ($in['body'] ?? ''));
$brand = mb_substr(trim((string) preg_replace('/[^\p{L}\p{N} .,:!&()\/_\'\-]/u', '', (string) ($in['brand'] ?? ''))), 0, 40);
if ($brand !== '') $data['brand'] = $brand;
$sent = 0;
$failed = 0;
$gone = [];
foreach ($tokens as $t) {
$r = push_send_one($sa, $t, $data);
if ($r['ok']) $sent++;
else { $failed++; if ($r['gone']) $gone[] = $t; }
}
$pdo->prepare('UPDATE push_installs SET last_used_at = ?, sent_total = sent_total + ? WHERE id = ?')->execute([gmdate('Y-m-d H:i:s'), $sent, $id]);
push_respond(200, ['sent' => $sent, 'failed' => $failed, 'gone' => $gone]);
}
push_respond(422, ['error' => 'Unbekannte Aktion.']);
}
+6
View File
@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
/** Push-Dienst für die DeskHub-Android-App (JSON per POST: register, send). Details in inc/push.php. */
require_once __DIR__ . '/inc/lib.php';
require_once __DIR__ . '/inc/push.php';
push_handle();
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "deskhub-96d3f",
"private_key_id": "a4026b577f73605192b1f16ea23c91a7a7b61b6d",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDxH1rVpfCEfDZr\nNKisyIV+SHo06Yr0/c35OPO184CqdY2fNP+Zs91oBMmxifsLQl8KFBjnIkfF1R9p\nqzzra/1ybI616D80T2P5UiXuxtaKnZWTls7wWTjWElBo2Q01JESJLXtX/jfwxKCB\ngRefQyFPaEk8Bu7IfAstofHxirwVpT6dtcIdsoDc6fqzpqAz+Us14Kfvpj+VBUk0\nzRZynZCvGsQS4xidcVoQtMNJ/HKvw0QegWYcyAJpIMCVwVD8KFpbgGMz3dvW6zjy\nt866gyLiALzln7QYewQAhD2aNpQKRko1fd5ue+005fV7B4iRhUDFH2CiKcygCuTZ\ntiESMyczAgMBAAECggEANi37dSJSRxK6D6DHffuYs2WJwDXx+VbGX4fCi1HpyWOc\nrPd1r43Q5DyfLXt5o3AIdvVJGzaCthtLcaz+hU/ApWNrNci32wMzzaP9zgdz0bXT\nFFTArxT9RmzbEJQk+ZnqpIBJxN3kp/8mqbLeIfUtmPAv9IMCSJ5GzLwk50drN9l3\n7ei8bxJTPMsnXK3yFgYBIkLzovV8ojK67MKcTCi1HHoKdozxg+KRcLiGLEAU8kmy\n9X/2K7c89g5n+INS5Kc36KzJA5pxvcJf9UNUbh+8eUqFf1LgkQT4urYRgHiNOqOe\ntuP/fDz7pkoQOJuV8kyWrvMC2W/cQ9off088U2zn4QKBgQD6qsKkv08+FWPDeQHL\nnQiP13DTdl+DeNIkKsDpiC1k3KXFl8ryxe7H41gAmb7zJijFAeqvaFYwFCU1an32\nPAmWONA2bdcXFnZugfPdJADiw8hifkWLjRiomOPnrEKaMm3ymZ0W3YR/sUGfJxX+\n5T7cyYGdJpODWXNfVNdlTbVsmwKBgQD2QJxci9oGQ0sJgvCCDMTeaS3Fj0PkEIdV\nlbhvRnzOB/ITFpbO3x91OjovZVg23XQo+CuQJvtsXk/Al5Q8d7IR+9/h8fLJwUD9\noDyYc358LheNhBli/fRI2A22pVsI6XU6iJqcPZ5Rd//fpszQC9gWht6BhO1rzklN\ny4qijIb9SQKBgEa7erM8XKL7LCyTb8NB65GTTH4PMAKMTFfyTH9Zct28OXbZT2ZU\nHf3/k+yPdcds5CI7WTrn6TIl7R32j9a0uEvQekhxlDkqmH60Ytjq+OkEL/5C/WLU\njLm+cskD3cNfKZFEcYRrO6FApnC/ecB0ddmdbtqmcQembRzgdutaEzCzAoGAGZO2\naepgt9vPdGp0Pp3C6PL1vRpXS93noJ0PbgddOS0cGJ11BoGoS83Dc17MXc58nTDB\nnZRe8u+Myw0zaWBpT3Lg2x/9PrVi4bT99juwvIcxDRLWbDDCabp/GiRsoy1UbsL3\nhBjmU8HqArnZdhZ02Eoj3EIJQvRHKtxkRuPftXkCgYEA7unw6YoFeXrf0daf8tYN\ns34no5U0bAui0utlPxZ4gpi6Px5mQkYAyZxwGtWqETG9dXvFdTLS5WE2I8yVf7/Q\nmItoAzEcWDge5xEUeZ0I8S+uCWs9wvukLaJd6/jm4LT+iBNZFRX7qCcpJVSxvKrG\nqBqE7BxYYfRXlXHilfd4Khs=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-fbsvc@deskhub-96d3f.iam.gserviceaccount.com",
"client_id": "115483883530547609607",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40deskhub-96d3f.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}