OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1' ]); $keyGenerationDetails = openssl_pkey_get_details($keyGeneration); $key = []; openssl_pkey_export($keyGeneration, $key['private']['pem']); // Private Key PEM $key['public']['pem'] = $keyGenerationDetails['key']; // Public Key PEM $key['private']['b64url'] = $this->base64url($keyGenerationDetails['ec']['d']); // Private Key base64-url-encoded $key['public']['b64url'] = $this->base64url("\x04" . $keyGenerationDetails['ec']['x'] . $keyGenerationDetails['ec']['y']); // Public Key base64-url-encoded return $key; } // ######################################## [ SIGNATURE CONVERSION ] ######################################## private function der2rawSignature(string $der, int $partLength = 64) : string { $retrievePositiveInteger = function(string $data) : string { while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') $data = mb_substr($data, 2, null, '8bit'); return $data; }; $hex = unpack('H*', $der)[1]; if ('30' !== mb_substr($hex, 0, 2, '8bit')) throw new RuntimeException(); if ('81' === mb_substr($hex, 2, 2, '8bit')) $hex = mb_substr($hex, 6, null, '8bit'); else $hex = mb_substr($hex, 4, null, '8bit'); if ('02' !== mb_substr($hex, 0, 2, '8bit')) throw new RuntimeException(); $Rl = hexdec(mb_substr($hex, 2, 2, '8bit')); $R = $retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit')); $R = str_pad($R, $partLength, '0', STR_PAD_LEFT); $hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit'); if ('02' !== mb_substr($hex, 0, 2, '8bit')) throw new RuntimeException(); $Sl = hexdec(mb_substr($hex, 2, 2, '8bit')); $S = $retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit')); $S = str_pad($S, $partLength, '0', STR_PAD_LEFT); return pack('H*', $R.$S); } // ######################################## [ JWT GENERATION ] ######################################## private function generateJWT(string $data, string $privateKeyPEM) : string { // Head $head = $this->base64url(json_encode([ "typ" => "JWT", "alg" => "ES256" ])); // Body $body = $this->base64url($data); // Signature openssl_sign("$head.$body", $sign, openssl_pkey_get_private($privateKeyPEM), OPENSSL_ALGO_SHA256); $sign = $this->base64url($this->der2rawSignature($sign)); // Return JWT return "$head.$body.$sign"; } // ######################################## [ REGISTRATION / (DE)SUBSCRIPTION ] ######################################## private function getUserID(?string $endpoint) : int { // Get new ID (if no endpoint specified) if ($endpoint === null) { $userFiles = glob($this->userPath . "*.json"); natsort($userFiles); return $userFiles ? (explode(".", substr(end($userFiles), strlen($this->userPath)), 2)[0] + 1) : 1; } // Get ID from endpoint $userFile = glob($this->userPath . "*." . hash("sha512", $endpoint) . ".json"); return $userFile ? explode(".", substr($userFile[0], strlen($this->userPath)), 2)[0] : false; } // ---------------------------------------------------------------------------------------------------- ( PUBLIC ) ---------------------------------------------------------------------------------------------------- // ######################################## [ 'webpush.js' API ] ######################################## public function __construct() { // Check that the class isn't used without extending if (!is_subclass_of($this, 'WebPushClass')) throw new Exception("'WebPushClass' was not extended by 'WebPush'! Refer to the example: https://lukat.lu/sites/webpush/"); // Get correct path to '.user' folder $includedFiles = get_included_files(); $this->userPath = dirname($includedFiles[array_search(__FILE__, $includedFiles) - 1]) . "/.users/"; // Check if the 'webpush.php' file is called directly if (count(get_included_files()) == 2 && isset($_SERVER['REQUEST_METHOD'])) { switch ($_SERVER['REQUEST_METHOD']) { // ### New registration ### case "POST": // Get next free ID $nextID = $this->getUserID(null); // Create new user $json = [ 'key' => $this->getVapidKeys() ]; file_put_contents($this->userPath . "$nextID.json", json_encode($json)); // Send the required data to JS header("Content-Type: application/json"); echo json_encode([ 'id' => $nextID, 'applicationServerKey' => $json['key']['public']['b64url'] ]); break; // ### Save subscription ### case "PUT": if (isset($_GET['id']) && ($path = $this->userPath . $_GET['id'] . ".json") && file_exists($path) && ($subscription = json_decode(file_get_contents('php://input'), true)) && $this->subscribeCallback($_GET['id'])) { // Get created user $json = json_decode(file_get_contents($path), true); $json['subscription'] = $subscription; // Save user with added subscription & rename file file_put_contents($path, json_encode($json)); rename($path, $this->userPath . $_GET['id'] . "." . hash("sha512", $subscription['endpoint']) . ".json"); // Return 204 (OK, No Content) http_response_code(204); } // Return 401 (Unauthorized) else http_response_code(401); break; // ### Delete subscription ### case "DELETE": if (($subscription = json_decode(file_get_contents('php://input'), true)) && ($id = $this->getUserID($subscription['endpoint'])) && $this->unsubscribeCallback($id)) { // Delete user file unlink($this->userPath . $id . "." . hash("sha512", $subscription['endpoint']) . ".json"); // Return 204 (OK, No Content) http_response_code(204); } // Return 401 (Unauthorized) else http_response_code(401); break; // ### Wrong request ### default: // Return 404 (Not Found), pretend file doesn't exist http_response_code(404); break; } } } // ######################################## [ SEND NOTIFICATION ] ######################################## public function sendNotification(?int $id, string $title, array $options, $ttl = 600) : bool { // Start curl $curl = curl_init(); // Go trough each user (or specific user if ID is given) foreach (glob($this->userPath . (($id === null) ? "*" : $id) . ".*.json") as $file) { // Get the user json data $json = json_decode(file_get_contents($file), true); // Generate the Json Web Token (JWT) $jwt = $this->generateJWT( // Data json_encode([ "aud" => substr($json['subscription']['endpoint'], 0, strpos($json['subscription']['endpoint'], "/", 8)), "sub" => "mailto:info@lukat.lu", "exp" => strtotime("+$ttl seconds") ]), // Private key PEM $json['key']['private']['pem'] ); // Execute the curl request curl_setopt_array($curl, [ CURLOPT_URL => $json['subscription']['endpoint'], CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ // Authorization & Encryption "Authorization: WebPush $jwt", "Crypto-Key: p256ecdsa=" . $json['key']['public']['b64url'], // TTL (How long a Notification should retry if it couldn't be sent) "TTL: $ttl" ], CURLOPT_POSTFIELDS => json_encode([ "title" => $title, "options" => $options ]) ]); $response = curl_exec($curl); $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); // Check if notification worked if ($httpCode != 201 || !empty($response)) $error = true; } // Stop curl curl_close($curl); // Return if >= 1 notification failed return !isset($error); } } ?>