Files
2026-10-16 19:12:18 +00:00

345 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* includes/push.php
*
* Browser Push Notifications via Web Push API (VAPID).
*
* Kein externer Composer-Paket nötig – minimale VAPID-Implementierung
* mit PHP-OpenSSL und JWT.
*
* Einrichtung:
* 1. VAPID-Keys einmalig generieren: php includes/push.php --generate-keys
* 2. Keys in config/config.php eintragen:
* 'push' => ['public_key' => '...', 'private_key' => '...']
* 3. Service Worker erweitert (sw-staff.js) – bereits erledigt
*/
// ── VAPID Key Generator (CLI) ─────────────────────────────────────────────────
if (php_sapi_name() === 'cli' && in_array('--generate-keys', $argv ?? [])) {
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
$details = openssl_pkey_get_details($key);
$pub = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']);
openssl_pkey_export($key, $privPem);
$priv = base64url_encode($details['ec']['d']);
echo "VAPID Keys generiert – in config/config.php eintragen:\n\n";
echo "'push' => [\n";
echo " 'public_key' => '$pub',\n";
echo " 'private_key' => '$priv',\n";
echo "],\n\n";
echo "Public Key für Frontend (push_subscribe.php):\n$pub\n";
exit(0);
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64url_decode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
/**
* Sendet eine Web-Push-Benachrichtigung an einen Subscriber.
*
* @param array $sub ['endpoint'=>..., 'p256dh'=>..., 'auth'=>...]
* @param string $title Titel der Benachrichtigung
* @param string $body Nachrichtentext
* @param string $url Ziel-URL beim Klick
* @param string $icon Icon-URL (optional)
*/
/**
* Gibt die VAPID-Keys zurück. Existieren sie noch nicht, werden sie
* automatisch generiert und in der settings-Tabelle gespeichert.
* Kein manueller Einrichtungsschritt nötig.
*/
function get_or_create_vapid_keys(): ?array
{
// 1. Config hat explizite Keys → Vorrang
$cfg = config()['push'] ?? [];
if (!empty($cfg['public_key']) && !empty($cfg['private_key'])) {
return $cfg;
}
// 2. Keys aus DB laden
try {
$pub = get_setting('vapid_public_key');
$priv = get_setting('vapid_private_key');
if ($pub && $priv) {
return ['public_key' => $pub, 'private_key' => $priv];
}
// 3. Neu generieren und speichern
if (!function_exists('openssl_pkey_new')) return null;
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
if (!$key) return null;
$details = openssl_pkey_get_details($key);
$pubKey = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']);
$privKey = base64url_encode($details['ec']['d']);
set_setting('vapid_public_key', $pubKey);
set_setting('vapid_private_key', $privKey);
return ['public_key' => $pubKey, 'private_key' => $privKey];
} catch (Throwable $e) {
return null;
}
}
function send_push(array $sub, string $title, string $body, string $url = '/staff/', string $icon = '/assets/icons/icon-192.png'): bool
{
$keys = get_or_create_vapid_keys();
if (!$keys) return false;
$payload = json_encode([
'title' => $title,
'body' => $body,
'url' => $url,
'icon' => $icon,
'badge' => '/assets/icons/icon-192.png',
]);
// Payload mit AES-128-GCM verschlüsseln (RFC 8291)
$encrypted = encrypt_push_payload($payload, $sub['p256dh'], $sub['auth']);
if (!$encrypted) return false;
// VAPID JWT erstellen
$endpoint = $sub['endpoint'];
$parsedUrl = parse_url($endpoint);
$audience = $parsedUrl['scheme'] . '://' . $parsedUrl['host'];
$jwt = create_vapid_jwt($audience, $keys['public_key'], $keys['private_key']);
$headers = [
'Authorization: vapid t=' . $jwt . ',k=' . $keys['public_key'],
'Content-Type: application/octet-stream',
'Content-Encoding: aes128gcm',
'TTL: 86400',
'Content-Length: ' . strlen($encrypted['ciphertext']),
];
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encrypted['ciphertext'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 410 Gone = Subscription abgelaufen, aus DB löschen
if ($status === 410) {
try {
get_db()->prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')->execute([$endpoint]);
} catch (Throwable $e) {}
}
return $status >= 200 && $status < 300;
}
/**
* Sendet Push an alle Subscriptions eines Mitarbeiters.
*/
function push_to_staff(int $staffId, string $title, string $body, string $url = '/staff/'): void
{
if (!get_or_create_vapid_keys()) return;
try {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM push_subscriptions WHERE staff_id = ?');
$stmt->execute([$staffId]);
foreach ($stmt->fetchAll() as $sub) {
send_push($sub, $title, $body, $url);
}
} catch (Throwable $e) {}
}
/**
* Sendet Push an alle aktiven Mitarbeiter einer Abteilung (neues Ticket).
*/
function push_new_ticket(array $ticket): void
{
if (!get_or_create_vapid_keys()) return;
try {
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN staff s ON s.id = ps.staff_id
LEFT JOIN staff_department sd ON sd.staff_id = s.id
WHERE s.is_active = 1
AND (s.role = 'admin' OR sd.department_id = ?)"
);
$stmt->execute([$ticket['department_id']]);
foreach ($stmt->fetchAll() as $sub) {
send_push(
$sub,
'Neues Ticket: ' . mb_strimwidth($ticket['subject'], 0, 60, '…'),
'Von: ' . $ticket['requester_name'],
'/staff/ticket_view.php?n=' . $ticket['ticket_number']
);
}
} catch (Throwable $e) {}
}
/**
* Sendet Push bei neuer Antwort an den zugewiesenen Mitarbeiter.
*/
function push_new_reply(array $ticket, string $authorName): void
{
if (empty($ticket['assigned_staff_id']) || empty(config()['push']['public_key'])) return;
push_to_staff(
(int)$ticket['assigned_staff_id'],
'Neue Antwort: #' . $ticket['ticket_number'],
'Von: ' . $authorName . ' – ' . mb_strimwidth($ticket['subject'], 0, 50, '…'),
'/staff/ticket_view.php?n=' . $ticket['ticket_number']
);
}
/**
* Push bei neuer Live-Chat-Nachricht: an den zugewiesenen Mitarbeiter, oder – solange
* der Chat noch niemandem zugewiesen ist – an alle aktiven Mitarbeiter der Abteilung
* (wie bei einem neuen Ticket), damit ein wartender Chat niemals unbemerkt bleibt.
*/
function push_new_chat_message(int $departmentId, ?int $assignedStaffId, string $ticketNumber, string $requesterName): void
{
if (!get_or_create_vapid_keys()) return;
$title = 'Neue Chat-Nachricht: #' . $ticketNumber;
$body = 'Von: ' . $requesterName;
$url = '/staff/live_chat.php?n=' . urlencode($ticketNumber);
if ($assignedStaffId) {
push_to_staff($assignedStaffId, $title, $body, $url);
return;
}
try {
$pdo = get_db();
$stmt = $pdo->prepare(
"SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN staff s ON s.id = ps.staff_id
LEFT JOIN staff_department sd ON sd.staff_id = s.id
WHERE s.is_active = 1
AND (s.role = 'admin' OR sd.department_id = ?)"
);
$stmt->execute([$departmentId]);
foreach ($stmt->fetchAll() as $sub) {
send_push($sub, $title, $body, $url);
}
} catch (Throwable $e) {}
}
// ── Crypto ────────────────────────────────────────────────────────────────────
function create_vapid_jwt(string $audience, string $publicKey, string $privateKeyB64): string
{
$header = base64url_encode(json_encode(['typ' => 'JWT', 'alg' => 'ES256']));
$payload = base64url_encode(json_encode([
'aud' => $audience,
'exp' => time() + 43200,
'sub' => 'mailto:' . (get_setting('admin_email') ?: 'admin@localhost'),
]));
$signing = $header . '.' . $payload;
// Private Key aus base64url laden
$privDer = base64url_decode($privateKeyB64);
// Reconstruct PEM from raw EC private key bytes
$privPem = ec_private_key_to_pem($privDer, base64url_decode($publicKey));
$key = openssl_pkey_get_private($privPem);
openssl_sign($signing, $sig, $key, OPENSSL_ALGO_SHA256);
// DER-Signatur in r||s umwandeln (P-256 = 32 Bytes je)
$sig = der_to_raw_ec_sig($sig);
return $signing . '.' . base64url_encode($sig);
}
function ec_private_key_to_pem(string $d, string $pubPoint): string
{
// SEC1 EC private key DER für prime256v1
$oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // prime256v1
$pub = "\x03" . chr(strlen($pubPoint) + 1) . "\x00" . $pubPoint;
$prv = "\x04" . chr(strlen($d)) . $d;
$seq = "\x02\x01\x01" . $prv . "\xa1" . chr(strlen($pub)) . $pub;
$inner = "\x30" . chr(strlen($seq)) . $seq;
$wrap = "\x30\x77\x02\x01\x00" . "\x30" . chr(strlen($oid)) . $oid . "\x04" . chr(strlen($inner)) . $inner;
return "-----BEGIN PRIVATE KEY-----\n" . chunk_split(base64_encode($wrap), 64) . "-----END PRIVATE KEY-----\n";
}
function der_to_raw_ec_sig(string $der): string
{
// Parse DER SEQUENCE { INTEGER r, INTEGER s }
$pos = 2; // skip SEQUENCE tag + length
$rLen = ord($der[$pos + 1]);
$r = substr($der, $pos + 2, $rLen);
$pos += 2 + $rLen;
$sLen = ord($der[$pos + 1]);
$s = substr($der, $pos + 2, $sLen);
// Pad to 32 bytes
return str_pad(ltrim($r, "\x00"), 32, "\x00", STR_PAD_LEFT)
. str_pad(ltrim($s, "\x00"), 32, "\x00", STR_PAD_LEFT);
}
function encrypt_push_payload(string $payload, string $p256dhB64, string $authB64): ?array
{
// RFC 8291 aes128gcm content encoding
$recipientPubKey = base64url_decode($p256dhB64);
$authSecret = base64url_decode($authB64);
// Generate ephemeral key pair
$eph = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
if (!$eph) return null;
$ephDetails = openssl_pkey_get_details($eph);
$ephPublicKey = "\x04" . $ephDetails['ec']['x'] . $ephDetails['ec']['y'];
// Recipient public key as EC key
$recipientKey = ec_public_key_from_uncompressed($recipientPubKey);
if (!$recipientKey) return null;
// ECDH
if (!openssl_dh_compute_key($sharedSecret, $recipientKey, $eph)) return null;
// HKDF to derive content encryption key and nonce (RFC 8291)
$salt = random_bytes(16);
$prk = hkdf_sha256($authSecret, $sharedSecret, "WebPush: info\x00" . $recipientPubKey . $ephPublicKey, 32);
$cek = hkdf_sha256($salt, $prk, "Content-Encoding: aes128gcm\x00", 16);
$nonce = hkdf_sha256($salt, $prk, "Content-Encoding: nonce\x00", 12);
// Encrypt with AES-128-GCM
$tag = '';
$ciphertext = openssl_encrypt($payload . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
if ($ciphertext === false) return null;
// RFC 8291 header: salt(16) + rs(4) + keyid_len(1) + keyid
$keyId = $ephPublicKey;
$rs = pack('N', strlen($payload) + 16 + 2);
$header = $salt . $rs . chr(strlen($keyId)) . $keyId;
return ['ciphertext' => $header . $ciphertext . $tag];
}
function ec_public_key_from_uncompressed(string $point): mixed
{
// Build PEM from uncompressed EC point
$oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07";
$bitStr = "\x03" . chr(strlen($point) + 1) . "\x00" . $point;
$algId = "\x30" . chr(strlen($oid) + 2) . "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" . $oid;
$spki = "\x30" . chr(strlen($algId) + strlen($bitStr)) . $algId . $bitStr;
$pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64) . "-----END PUBLIC KEY-----\n";
return openssl_pkey_get_public($pem);
}
function hkdf_sha256(string $salt, string $ikm, string $info, int $length): string
{
$prk = hash_hmac('sha256', $ikm, $salt, true);
$t = '';
$okm = '';
for ($i = 1; strlen($okm) < $length; $i++) {
$t = hash_hmac('sha256', $t . $info . chr($i), $prk, true);
$okm .= $t;
}
return substr($okm, 0, $length);
}