From 4022c9de22eeb8141739033f19375098f6c9e71a Mon Sep 17 00:00:00 2001 From: ASacanell Date: Thu, 2 Mar 2023 10:52:26 +0100 Subject: [PATCH] Allow APNs with .p8 key --- readme.md | 13 +- src/ApnP8.php | 337 +++++++++++++++++++++++++++++++++ src/Config/config.php | 6 + src/PushNotification.php | 1 + tests/PushNotificationTest.php | 95 +++++++++- 5 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 src/ApnP8.php diff --git a/readme.md b/readme.md index 49049e1..9077823 100644 --- a/readme.md +++ b/readme.md @@ -12,6 +12,7 @@ This is an easy to use package to send push notification. * GCM * FCM * APN +* APN with .P8 key ## Installation @@ -67,7 +68,14 @@ The default configuration parameters for **APN** are: * ```passFile => __DIR__ . '/iosCertificates/yourKey.pem' //Optional``` * ```dry_run => false``` -(Make sure to set `dry_run` to `true` if you're using development *.pem certificate, and `false` for production) +The default configuration parameters for **APN P8** are: + +* ```key => __DIR__ . '/key.p8'``` +* ```keyId => 'MyKeyId'``` +* ```teamId => 'MyAppleTeamId'``` +* ```dry_run => false``` + +(Make sure to set `dry_run` to `true` if you're using development token, and `false` for production) Also you can update those values and add more dynamically ```php @@ -111,6 +119,9 @@ For APN Service: ```php $push = new PushNotification('apn'); ``` +```php +$push = new PushNotification('apnp8'); +``` For FCM Service: ```php diff --git a/src/ApnP8.php b/src/ApnP8.php new file mode 100644 index 0000000..8c2c0e9 --- /dev/null +++ b/src/ApnP8.php @@ -0,0 +1,337 @@ +url = self::APNS_PRODUCTION_SERVER; + + $this->config = $this->initializeConfig('apnp8'); + } + + /** + * Provide the unregistered tokens of the notification sent. + * + * @param array $devices_token + * @return array + */ + public function getUnregisteredDeviceTokens(array $devices_token) + { + $tokens = []; + + if (!empty($this->feedback->tokenFailList)) { + $tokens = $this->feedback->tokenFailList; + } + if (!empty($this->feedback->apnsFeedback)) { + $tokens = array_merge($tokens, Arr::pluck($this->feedback->apnsFeedback, 'devtoken')); + } + + return $tokens; + } + + /** + * Send Push Notification + * @param array $deviceTokens + * @param array $message + * @return \stdClass APN Response + */ + public function send(array $deviceTokens, array $message) + { + if (false == $this->existKey()) { + return $this->feedback; + } + + $responseCollection = [ + 'success' => true, + 'error' => '', + 'results' => [], + ]; + + if (!$this->curlMultiHandle) { + $this->curlMultiHandle = curl_multi_init(); + + if (!defined('CURLPIPE_MULTIPLEX')) { + define('CURLPIPE_MULTIPLEX', 2); + } + + curl_multi_setopt($this->curlMultiHandle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX); + if (defined('CURLMOPT_MAX_HOST_CONNECTIONS')) { + curl_multi_setopt($this->curlMultiHandle, CURLMOPT_MAX_HOST_CONNECTIONS, $this->maxConcurrentConnections); + } + } + + $mh = $this->curlMultiHandle; + $errors = []; + + $i = 0; + while (!empty($deviceTokens) && $i++ < $this->nbConcurrentRequests) { + $deviceToken = array_pop($deviceTokens); + curl_multi_add_handle($mh, $this->prepareHandle($deviceToken, $message)); + } + + // Clear out curl handle buffer + do { + $execrun = curl_multi_exec($mh, $running); + } while ($execrun === CURLM_CALL_MULTI_PERFORM); + + // Continue processing while we have active curl handles + while ($running > 0 && $execrun === CURLM_OK) { + // Block until data is available + $select_fd = curl_multi_select($mh); + // If select returns -1 while running, wait 250 microseconds before continuing + // Using curl_multi_timeout would be better but it isn't available in PHP yet + // https://php.net/manual/en/function.curl-multi-select.php#115381 + if ($running && $select_fd === -1) { + usleep(250); + } + + // Continue to wait for more data if needed + do { + $execrun = curl_multi_exec($mh, $running); + } while ($execrun === CURLM_CALL_MULTI_PERFORM); + + // Start reading results + while ($done = curl_multi_info_read($mh)) { + $handle = $done['handle']; + + $result = curl_multi_getcontent($handle); + + // find out which token the response is about + $token = curl_getinfo($handle, CURLINFO_PRIVATE); + + $responseParts = explode("\r\n\r\n", $result, 2); + $headers = ''; + $body = ''; + if (isset($responseParts[0])) { + $headers = $responseParts[0]; + } + if (isset($responseParts[1])) { + $body = $responseParts[1]; + } + + $statusCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); + if ($statusCode === 0) { + $responseCollection['success'] = false; + + $responseCollection['error'] = [ + 'status' => $statusCode, + 'headers' => $headers, + 'body' => curl_error($handle), + 'token' => $token + ]; + continue; + } + + $responseCollection['success'] = $responseCollection['success'] && $statusCode == 200; + + $responseCollection['results'][] = [ + 'status' => $statusCode, + 'headers' => $headers, + 'body' => (string)$body, + 'token' => $token + ]; + curl_multi_remove_handle($mh, $handle); + curl_close($handle); + + if (!empty($deviceTokens)) { + $deviceToken = array_pop($deviceTokens); + curl_multi_add_handle($mh, $this->prepareHandle($deviceToken, $message)); + $running++; + } + } + } + + if ($this->autoCloseConnections) { + curl_multi_close($mh); + $this->curlMultiHandle = null; + } + + //Set the global feedback + $this->setFeedback(json_decode(json_encode($responseCollection))); + + return $responseCollection; + } + + /** + * Get Url for APNs production server. + * + * @param Notification $notification + * @return string + */ + private function getProductionUrl(string $deviceToken) + { + return self::APNS_PRODUCTION_SERVER . $this->getUrlPath($deviceToken); + } + + /** + * Get Url for APNs sandbox server. + * + * @param Notification $notification + * @return string + */ + private function getSandboxUrl(string $deviceToken) + { + return self::APNS_DEVELOPMENT_SERVER . $this->getUrlPath($deviceToken); + } + + /** + * Get Url path. + * + * @param Notification $notification + * @return mixed + */ + private function getUrlPath(string $deviceToken) + { + return str_replace("{token}", $deviceToken, self::APNS_PATH_SCHEMA); + } + + /** + * Decorate headers + * + * @return array + */ + public function decorateHeaders(array $headers): array + { + $decoratedHeaders = []; + foreach ($headers as $name => $value) { + $decoratedHeaders[] = $name . ': ' . $value; + } + return $decoratedHeaders; + } + + /** + * @param $token + * @param array $message + * @param $request + * @param array $deviceTokens + */ + public function prepareHandle($deviceToken, array $message) + { + $uri = false === $this->config['dry_run'] ? $this->getProductionUrl($deviceToken) : $this->getSandboxUrl($deviceToken); + $headers = $message['headers']; + $headers['authorization'] = "bearer {$this->generateJwt()}"; + unset($message['headers']); + $body = json_encode($message); + + $options = [ + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2, + CURLOPT_URL => $uri, + CURLOPT_PORT => self::APNS_PORT, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HEADER => true, + CURLOPT_SSL_VERIFYPEER => true + ]; + + $ch = curl_init(); + + curl_setopt_array($ch, $options); + if (!empty($headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $this->decorateHeaders($headers)); + } + + // store device token to identify response + curl_setopt($ch, CURLOPT_PRIVATE, $deviceToken); + + return $ch; + } + + protected function generateJwt() { + $key = openssl_pkey_get_private('file://'.$this->config['key']); + $header = ['alg' => 'ES256','kid' => $this->config['keyId']]; + $claims = ['iss' => $this->config['teamId'],'iat' => time()]; + + $header_encoded = $this->base64($header); + $claims_encoded = $this->base64($claims); + + $signature = ''; + openssl_sign($header_encoded . '.' . $claims_encoded, $signature, $key, 'sha256'); + return $header_encoded . '.' . $claims_encoded . '.' . base64_encode($signature); + } + + protected function base64($data) { + return rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '='); + } + + /** + * Set the feedback with no exist any key. + * + * @return mixed|void + */ + private function messageNoExistKey() + { + $response = [ + 'success' => false, + 'error' => "Please, add your APN key to the iosKeys folder." . PHP_EOL + ]; + + $this->setFeedback(json_decode(json_encode($response))); + } + + /** + * Check if the key file exist. + * @return bool + */ + private function existKey() + { + if (isset($this->config['key'])) { + $key = $this->config['key']; + if (!file_exists($key)) { + $this->messageNoExistKey(); + return false; + } + + return true; + } + + $this->messageNoExistKey(); + return false; + } + +} \ No newline at end of file diff --git a/src/Config/config.php b/src/Config/config.php index fd3f91c..c00e128 100644 --- a/src/Config/config.php +++ b/src/Config/config.php @@ -26,4 +26,10 @@ 'passFile' => __DIR__ . '/iosCertificates/yourKey.pem', //Optional 'dry_run' => true, ], + 'apnp8' => [ + 'key' => __DIR__ . '/apns-key.p8', + 'keyId' => 'My_Key_Id', + 'teamId' => 'My_Apple_Team_Id', + 'dry_run' => true, + ], ]; diff --git a/src/PushNotification.php b/src/PushNotification.php index 6e7305f..67aaccc 100644 --- a/src/PushNotification.php +++ b/src/PushNotification.php @@ -17,6 +17,7 @@ class PushNotification */ protected $servicesList = [ 'gcm' => Gcm::class, + 'apnp8' => ApnP8::class, 'apn' => Apn::class, 'fcm' => Fcm::class ]; diff --git a/tests/PushNotificationTest.php b/tests/PushNotificationTest.php index b8344e3..2184f2a 100644 --- a/tests/PushNotificationTest.php +++ b/tests/PushNotificationTest.php @@ -125,6 +125,35 @@ public function send_method_in_apn_service() $this->assertIsArray($push->getUnregisteredDeviceTokens()); } + /** @test */ + public function send_method_in_apnp8_service() + { + $push = new PushNotification('apnp8'); + + $message = [ + 'aps' => [ + 'alert' => [ + 'title' => '1 Notification test', + 'body' => 'Just for testing purposes' + ], + 'sound' => 'default' + + ] + ]; + + $push->setMessage($message) + ->setDevicesToken([ + '507e3adaf433ae3e6234f35c82f8a43ad0d84218bff08f16ea7be0869f066c0312', + 'ac566b885e91ee74a8d12482ae4e1dfd2da1e26881105dec262fcbe0e082a358', + '507e3adaf433ae3e6234f35c82f8a43ad0d84218bff08f16ea7be0869f066c0312' + ]); + + $push = $push->send(); + //var_dump($push->getFeedback()); + $this->assertInstanceOf('stdClass', $push->getFeedback()); + $this->assertIsArray($push->getUnregisteredDeviceTokens()); + } + /** @test */ public function apn_without_certificate() { @@ -136,6 +165,17 @@ public function apn_without_certificate() $this->assertFalse($push->feedback->success); } + /** @test */ + public function apnp8_without_key() + { + $push = new PushNotification('app8'); + + $push->setConfig(['custom' => 'Custom Value','key' => 'MycustomValue']); + $push->send(); + $this->assertTrue(isset($push->feedback->error)); + $this->assertFalse($push->feedback->success); + } + /** @test */ public function fcm_assert_send_method_returns_an_stdClass_instance() { @@ -165,7 +205,7 @@ public function get_available_push_service_list() { $push = new PushNotification(); - $this->assertCount(3, $push->servicesList); + $this->assertCount(4, $push->servicesList); $this->assertIsArray($push->servicesList); } @@ -221,6 +261,32 @@ public function apn_feedback() $this->assertIsArray($push->getUnregisteredDeviceTokens()); } + /** @test */ + public function apnp8_feedback() + { + $push = new PushNotification('apnp8'); + + $message = [ + 'aps' => [ + 'alert' => [ + 'title' => 'New Notification test', + 'body' => 'Just for testing purposes' + ], + 'sound' => 'default' + + ] + ]; + + $push->setMessage($message) + ->setDevicesToken([ + 'asdfasdf' + ]); + + $push->send(); + $this->assertInstanceOf('stdClass', $push->getFeedback()); + $this->assertIsArray($push->getUnregisteredDeviceTokens()); + } + /** @test */ public function allow_apikey_from_config_file() @@ -313,6 +379,17 @@ public function apn_connection_attempts_default() $key = 'connection_attempts'; $this->assertArrayNotHasKey($key, $push->config); } + + /** @test */ + public function apnp8_connection_attempts_default() + { + $push = new PushNotification('apnp8'); + + $push->setConfig(['dry_run' => true]); + + $key = 'connection_attempts'; + $this->assertArrayNotHasKey($key, $push->config); + } /** @test */ public function set_apn_connect_attempts_override_default() @@ -330,6 +407,22 @@ public function set_apn_connect_attempts_override_default() $this->assertEquals($expected, $push->config[$key]); } + /** @test */ + public function set_apnp8_connect_attempts_override_default() + { + $push = new PushNotification('apnp8'); + + $expected = 0; + $push->setConfig([ + 'dry_run' => true, + 'connection_attempts' => $expected, + ]); + + $key = 'connection_attempts'; + $this->assertArrayHasKey($key, $push->config); + $this->assertEquals($expected, $push->config[$key]); + } + /** @test */ public function apn_connect_attempts_bailout_badcert() {