Files
2026-10-21 16:55:37 +00:00

200 lines
12 KiB
PHP

<?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.']);
}