From 65b336373dc5e61f3a456ac05245a9e8f3ac4d44 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 21 Oct 2026 16:55:37 +0000 Subject: [PATCH] Upload via GUI (28 Dateien) --- plugin-webseite/.dockerignore | 1 + plugin-webseite/Dockerfile | 2 + plugin-webseite/README.md | 13 ++ plugin-webseite/admin.php | 26 +++ plugin-webseite/backup.sh | 26 +++ plugin-webseite/docker-bake.php | 11 + plugin-webseite/docker-build.ps1 | 7 + plugin-webseite/docker-build.sh | 10 +- plugin-webseite/inc/lib.php | 4 +- plugin-webseite/inc/push.php | 199 ++++++++++++++++++ plugin-webseite/push.php | 6 + .../schluessel/firebase-dienstkonto.json | 13 ++ 12 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 plugin-webseite/backup.sh create mode 100644 plugin-webseite/inc/push.php create mode 100644 plugin-webseite/push.php create mode 100644 plugin-webseite/schluessel/firebase-dienstkonto.json diff --git a/plugin-webseite/.dockerignore b/plugin-webseite/.dockerignore index f2f4fa9e..08928f91 100644 --- a/plugin-webseite/.dockerignore +++ b/plugin-webseite/.dockerignore @@ -4,3 +4,4 @@ storage .git docker-compose.yml Caddyfile +schluessel diff --git a/plugin-webseite/Dockerfile b/plugin-webseite/Dockerfile index 788666b3..d9472526 100644 --- a/plugin-webseite/Dockerfile +++ b/plugin-webseite/Dockerfile @@ -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 diff --git a/plugin-webseite/README.md b/plugin-webseite/README.md index c4e0a28e..10aa3cba 100644 --- a/plugin-webseite/README.md +++ b/plugin-webseite/README.md @@ -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:///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. diff --git a/plugin-webseite/admin.php b/plugin-webseite/admin.php index 1e738479..6d5a6d2e 100644 --- a/plugin-webseite/admin.php +++ b/plugin-webseite/admin.php @@ -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'); +

Push-Dienst (Android-App)

+ query('SELECT * FROM push_installs ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT 200')->fetchAll(); ?> +

+

Noch keine DeskHub-Installation angemeldet.

+ + + + + + + + +
Angemeldete DeskHub-Installationen ()
gesendetgesperrt' : 'aktiv' ?>
+ +

Veröffentlichte Pakete

'Plugins', 'theme' => 'Themes'] as $t => $label): $items = site_items($t); ?>

diff --git a/plugin-webseite/backup.sh b/plugin-webseite/backup.sh new file mode 100644 index 00000000..9d5f4218 --- /dev/null +++ b/plugin-webseite/backup.sh @@ -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))" diff --git a/plugin-webseite/docker-bake.php b/plugin-webseite/docker-bake.php index 52cd83dd..5ac1aa07 100644 --- a/plugin-webseite/docker-bake.php +++ b/plugin-webseite/docker-bake.php @@ -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, " '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); } } diff --git a/plugin-webseite/inc/push.php b/plugin-webseite/inc/push.php new file mode 100644 index 00000000..aa114121 --- /dev/null +++ b/plugin-webseite/inc/push.php @@ -0,0 +1,199 @@ + '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 $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 */ +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.']); +} diff --git a/plugin-webseite/push.php b/plugin-webseite/push.php new file mode 100644 index 00000000..a86c1151 --- /dev/null +++ b/plugin-webseite/push.php @@ -0,0 +1,6 @@ +