Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:18 +00:00
parent 56deade877
commit 6cced3beda
50 changed files with 11148 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
<?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);
}