200 lines
12 KiB
PHP
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.']);
|
|
}
|