diff --git a/.env b/.env index da8514df..f5fc27a6 100644 --- a/.env +++ b/.env @@ -1,7 +1,8 @@ MAILGUN_API_KEY= MAILGUN_DOMAIN= SENDGRID_API_KEY= -FCM_SERVER_KEY= +FCM_SERVICE_ACCOUNT_JSON= +FCM_TO= TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_TO= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 47086952..032c6b67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,8 @@ jobs: MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }} SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} - FCM_SERVER_KEY: ${{ secrets.FCM_SERVER_KEY }} - FCM_SERVER_TO: ${{ secrets.FCM_SERVER_TO }} + FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }} + FCM_TO: ${{ secrets.FCM_TO }} TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} TWILIO_TO: ${{ secrets.TWILIO_TO }} diff --git a/README.md b/README.md index 02b827ca..def239ed 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ $message = new Push( content: 'Hello World' ); -$messaging = new FCM('YOUR_SERVER_KEY'); +$messaging = new FCM('YOUR_SERVICE_ACCOUNT_JSON'); $messaging->send($message); ``` @@ -108,7 +108,7 @@ $messaging->send($message); ### Push - [x] [FCM](https://firebase.google.com/docs/cloud-messaging) -- [ ] [APNS](https://developer.apple.com/documentation/usernotifications) +- [x] [APNS](https://developer.apple.com/documentation/usernotifications) - [ ] [OneSignal](https://onesignal.com/) - [ ] [Pusher](https://pusher.com/) - [ ] [WebPush](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) diff --git a/composer.json b/composer.json index f76d03af..660ff82c 100644 --- a/composer.json +++ b/composer.json @@ -23,10 +23,10 @@ }, "require": { "php": ">=8.0.0", - "ext-curl": "*" + "ext-curl": "*", + "ext-openssl": "*" }, "require-dev": { - "ext-openssl": "*", "phpunit/phpunit": "9.6.10", "phpmailer/phpmailer": "6.8.*", "laravel/pint": "1.13.*", diff --git a/docker-compose.yml b/docker-compose.yml index 6cb8400a..fb87cc43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,41 +2,41 @@ version: '3.9' services: tests: - environment: - - MAILGUN_API_KEY - - MAILGUN_DOMAIN - - SENDGRID_API_KEY - - FCM_SERVER_KEY - - FCM_SERVER_TO - - TWILIO_ACCOUNT_SID - - TWILIO_AUTH_TOKEN - - TWILIO_TO - - TWILIO_FROM - - TELNYX_API_KEY - - TELNYX_PUBLIC_KEY - - APNS_AUTHKEY_8KVVCLA3HL - - APNS_AUTH_ID - - APNS_TEAM_ID - - APNS_BUNDLE_ID - - APNS_TO - - MSG_91_SENDER_ID - - MSG_91_AUTH_KEY - - MSG_91_TO - - MSG_91_FROM - - TEST_EMAIL - - TEST_FROM_EMAIL - - VONAGE_API_KEY - - VONAGE_API_SECRET - - VONAGE_TO - - VONAGE_FROM - - DISCORD_WEBHOOK_ID - - DISCORD_WEBHOOK_TOKEN build: context: . volumes: - ./src:/usr/local/src/src - ./tests:/usr/local/src/tests - ./phpunit.xml:/usr/local/src/phpunit.xml + environment: + - MAILGUN_API_KEY + - MAILGUN_DOMAIN + - SENDGRID_API_KEY + - FCM_SERVICE_ACCOUNT_JSON + - FCM_TO + - TWILIO_ACCOUNT_SID + - TWILIO_AUTH_TOKEN + - TWILIO_TO + - TWILIO_FROM + - TELNYX_API_KEY + - TELNYX_PUBLIC_KEY + - APNS_AUTHKEY_8KVVCLA3HL + - APNS_AUTH_ID + - APNS_TEAM_ID + - APNS_BUNDLE_ID + - APNS_TO + - MSG_91_SENDER_ID + - MSG_91_AUTH_KEY + - MSG_91_TO + - MSG_91_FROM + - TEST_EMAIL + - TEST_FROM_EMAIL + - VONAGE_API_KEY + - VONAGE_API_SECRET + - VONAGE_TO + - VONAGE_FROM + - DISCORD_WEBHOOK_ID + - DISCORD_WEBHOOK_TOKEN maildev: image: appwrite/mailcatcher:1.0.0 diff --git a/docs/add-new-adapter.md b/docs/add-new-adapter.md index 70d3d38d..4789b7ef 100644 --- a/docs/add-new-adapter.md +++ b/docs/add-new-adapter.md @@ -65,12 +65,25 @@ public function process(Email $message): string } ``` -The base `Adapter` class includes a helper method called `request()` that can be used to send HTTP requests to the messaging provider. It accepts the following parameters, and returns the response as a string: +The base `Adapter` class includes a two helper functions called `request()` and `requestMulti()` that can be used to send HTTP requests to the messaging provider. + +The `request()` function will send a single request and accepts the following parameters: - `method` - The HTTP method to use. For example, `POST`, `GET`, `PUT`, `PATCH` or `DELETE`. - `url` - The URL to send the request to. - `headers` - An array of headers to send with the request. -- `body` - The body of the request. It can be either a string or an array. +- `body` - The body of the request as a string, or null if no body is required. +- `timeout` - The timeout in seconds for the request. + +The `requestMulti()` function will send multiple concurrent requests via HTTP/2 multiplexing, and accepts the following parameters: + +- `method` - The HTTP method to use. For example, `POST`, `GET`, `PUT`, `PATCH` or `DELETE`. +- `urls` - An array of URLs to send the requests to. +- `headers` - An array of headers to send with the requests. +- `bodies` - An array of bodies of the requests as strings, or an empty array if no body is required. +- `timeout` - The timeout in seconds for the requests. + +`urls` and `bodies` must either be the same length, or one of them must contain only a single element. If `urls` contains only a single element, it will be used for all requests. If `bodies` contains only a single element, it will be used for all requests. The default content type of the request is `x-www-form-urlencoded`, but you can change it by adding a `Content-Type` header. No encoding is applied to the body, so you need to make sure it is encoded properly before sending the request. @@ -144,7 +157,7 @@ If everything goes well, raise a pull request and be ready to respond to any fee ## 4. Raise a pull request -First of all, commit the changes with the message `Added YYY Storage adapter` and push it. This will publish a new branch to your forked version of `utopia-php/messaging`. If you visit it at `github.com/YOUR_USERNAME/messaging`, you will see a new alert saying you are ready to submit a pull request. Follow the steps GitHub provides, and at the end, you will have your pull request submitted. +First of all, commit the changes with the message `Added YYY adapter` and push it. This will publish a new branch to your forked version of `utopia-php/messaging`. If you visit it at `github.com/YOUR_USERNAME/messaging`, you will see a new alert saying you are ready to submit a pull request. Follow the steps GitHub provides, and at the end, you will have your pull request submitted. ## 🤕 Stuck ? If you need any help with the contribution, feel free to head over to [our discord channel](https://appwrite.io/discord) and we'll be happy to help you out. diff --git a/src/Utopia/Messaging/Adapter.php b/src/Utopia/Messaging/Adapter.php index 3bb525c2..2732f501 100644 --- a/src/Utopia/Messaging/Adapter.php +++ b/src/Utopia/Messaging/Adapter.php @@ -27,12 +27,19 @@ abstract public function getMaxMessagesPerRequest(): int; /** * Send a message. * - * @param Message $message The message to send. - * @return string The response body. + * @return array{ + * deliveredTo: int, + * type: string, + * results: array> + * } | array> + * }> * - * @throws \Exception If the message fails. + * @throws \Exception */ - public function send(Message $message): string + public function send(Message $message): array { if (! \is_a($message, $this->getMessageType())) { throw new \Exception('Invalid message type.'); @@ -48,13 +55,19 @@ public function send(Message $message): string } /** - * Send an HTTP request. + * Send a single HTTP request. * * @param string $method The HTTP method to use. * @param string $url The URL to send the request to. * @param array $headers An array of headers to send with the request. * @param string|null $body The body of the request. - * @return array The response body. + * @param int $timeout The timeout in seconds. + * @return array{ + * url: string, + * statusCode: int, + * response: array|null, + * error: string|null + * } * * @throws \Exception If the request fails. */ @@ -63,6 +76,7 @@ protected function request( string $url, array $headers = [], string $body = null, + int $timeout = 30 ): array { $ch = \curl_init(); @@ -71,31 +85,141 @@ protected function request( \curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } - \curl_setopt($ch, CURLOPT_URL, $url); - \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - \curl_setopt($ch, CURLOPT_USERAGENT, "Appwrite {$this->getName()} Message Sender"); + \curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_URL => $url, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => "Appwrite {$this->getName()} Message Sender", + CURLOPT_TIMEOUT => $timeout, + ]); $response = \curl_exec($ch); + \curl_close($ch); - if (\curl_errno($ch)) { - throw new \Exception('Error: '.\curl_error($ch)); + try { + $response = \json_decode($response, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // Ignore + } + + return [ + 'url' => $url, + 'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE), + 'response' => $response, + 'error' => \curl_error($ch), + ]; + } + + /** + * Send multiple concurrent HTTP requests using HTTP/2 multiplexing. + * + * @param array $urls + * @param array $headers + * @param array $bodies + * @return array|null, + * error: string|null + * }> + * + * @throws \Exception + */ + protected function requestMulti( + string $method, + array $urls, + array $headers = [], + array $bodies = [], + int $timeout = 30 + ): array { + if (empty($urls)) { + throw new \Exception('No URLs provided. Must provide at least one URL.'); + } + + $sh = \curl_share_init(); + $mh = \curl_multi_init(); + $ch = \curl_init(); + + \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS); + \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); + + \curl_setopt_array($ch, [ + CURLOPT_SHARE => $sh, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FORBID_REUSE => false, + CURLOPT_FRESH_CONNECT => false, + CURLOPT_TIMEOUT => $timeout, + ]); + + $urlCount = \count($urls); + $bodyCount = \count($bodies); + + if ( + $urlCount != $bodyCount && + ($urlCount == 1 && $bodyCount != 1 || $urlCount != 1 && $bodyCount == 1) + ) { + throw new \Exception('URL and body counts must be equal or 1.'); + } + + if ($urlCount > $bodyCount) { + $bodies = \array_pad($bodies, $urlCount, $bodies[0]); + } elseif ($urlCount < $bodyCount) { + $urls = \array_pad($urls, $bodyCount, $urls[0]); } - $jsonResponse = \json_decode($response, true); + foreach (\array_combine($urls, $bodies) as $url => $body) { + if (! empty($body)) { + $headers[] = 'Content-Length: '.\strlen($body); + } + + \curl_setopt($ch, CURLOPT_URL, $url); + \curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + \curl_multi_add_handle($mh, \curl_copy_handle($ch)); + } + + $active = true; + do { + $status = \curl_multi_exec($mh, $active); + + if ($active) { + \curl_multi_select($mh); + } + } while ($active && $status == CURLM_OK); - if (\json_last_error() == JSON_ERROR_NONE) { - return [ - 'response' => $jsonResponse, - 'statusCode' => \curl_getinfo($ch, CURLINFO_HTTP_CODE), + $responses = []; + + // Check each handle's result + while ($info = \curl_multi_info_read($mh)) { + $ch = $info['handle']; + + $response = \curl_multi_getcontent($ch); + + try { + $response = \json_decode($response, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // Ignore + } + + $responses[] = [ + 'url' => \curl_getinfo($ch, CURLINFO_EFFECTIVE_URL), + 'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE), + 'response' => $response, + 'error' => \curl_error($ch), ]; + + \curl_multi_remove_handle($mh, $ch); + \curl_close($ch); } - return [ - 'response' => $response, - 'statusCode' => \curl_getinfo($ch, CURLINFO_HTTP_CODE), - ]; + \curl_multi_close($mh); + \curl_share_close($sh); + + return $responses; } } diff --git a/src/Utopia/Messaging/Adapter/Chat/Discord.php b/src/Utopia/Messaging/Adapter/Chat/Discord.php index f9141a17..30b46912 100644 --- a/src/Utopia/Messaging/Adapter/Chat/Discord.php +++ b/src/Utopia/Messaging/Adapter/Chat/Discord.php @@ -38,7 +38,12 @@ public function getMaxMessagesPerRequest(): int return 1; } - protected function process(DiscordMessage $message): string + /** + * @return array{deliveredTo: int, type: string, results: array>} + * + * @throws \Exception + */ + protected function process(DiscordMessage $message): array { $query = []; @@ -89,6 +94,6 @@ protected function process(DiscordMessage $message): string $response->addResultForRecipient($this->webhookId, 'Unknown Error.'); } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/Email.php b/src/Utopia/Messaging/Adapter/Email.php index 582943f5..c5414280 100644 --- a/src/Utopia/Messaging/Adapter/Email.php +++ b/src/Utopia/Messaging/Adapter/Email.php @@ -20,10 +20,9 @@ public function getMessageType(): string /** * Process an email message. * - * @param EmailMessage $message Message to process. - * @return string The response body. + * @return array{deliveredTo: int, type: string, results: array>} * - * @throws \Exception If the message fails. + * @throws \Exception */ - abstract protected function process(EmailMessage $message): string; + abstract protected function process(EmailMessage $message): array; } diff --git a/src/Utopia/Messaging/Adapter/Email/Mailgun.php b/src/Utopia/Messaging/Adapter/Email/Mailgun.php index f1894e09..5a5bba2e 100644 --- a/src/Utopia/Messaging/Adapter/Email/Mailgun.php +++ b/src/Utopia/Messaging/Adapter/Email/Mailgun.php @@ -38,7 +38,7 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} */ - protected function process(EmailMessage $message): string + protected function process(EmailMessage $message): array { $usDomain = 'api.mailgun.net'; $euDomain = 'api.eu.mailgun.net'; @@ -98,6 +98,6 @@ protected function process(EmailMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/Email/Mock.php b/src/Utopia/Messaging/Adapter/Email/Mock.php index ebf759ed..81f8c367 100644 --- a/src/Utopia/Messaging/Adapter/Email/Mock.php +++ b/src/Utopia/Messaging/Adapter/Email/Mock.php @@ -23,7 +23,7 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} */ - protected function process(EmailMessage $message): string + protected function process(EmailMessage $message): array { $response = new Response($this->getType()); $mail = new PHPMailer(); @@ -59,6 +59,6 @@ protected function process(EmailMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/Email/Sendgrid.php b/src/Utopia/Messaging/Adapter/Email/Sendgrid.php index 00ad6ba0..91ed9279 100644 --- a/src/Utopia/Messaging/Adapter/Email/Sendgrid.php +++ b/src/Utopia/Messaging/Adapter/Email/Sendgrid.php @@ -35,7 +35,7 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} */ - protected function process(EmailMessage $message): string + protected function process(EmailMessage $message): array { $personalizations = [ [ @@ -117,6 +117,6 @@ protected function process(EmailMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/Push.php b/src/Utopia/Messaging/Adapter/Push.php index 1f39393c..f2ae4697 100644 --- a/src/Utopia/Messaging/Adapter/Push.php +++ b/src/Utopia/Messaging/Adapter/Push.php @@ -20,10 +20,9 @@ public function getMessageType(): string /** * Send a push message. * - * @param PushMessage $message Message to process. - * @return string The response body. + * @return array{deliveredTo: int, type: string, results: array>} * - * @throws \Exception If the message fails. + * @throws \Exception */ - abstract protected function process(PushMessage $message): string; + abstract protected function process(PushMessage $message): array; } diff --git a/src/Utopia/Messaging/Adapter/Push/APNS.php b/src/Utopia/Messaging/Adapter/Push/APNS.php index e09c40b3..fbe0172e 100644 --- a/src/Utopia/Messaging/Adapter/Push/APNS.php +++ b/src/Utopia/Messaging/Adapter/Push/APNS.php @@ -3,6 +3,7 @@ namespace Utopia\Messaging\Adapter\Push; use Utopia\Messaging\Adapter\Push as PushAdapter; +use Utopia\Messaging\Helpers\JWT; use Utopia\Messaging\Messages\Push as PushMessage; use Utopia\Messaging\Response; @@ -39,7 +40,7 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} */ - public function process(PushMessage $message): string + public function process(PushMessage $message): array { $payload = [ 'aps' => [ @@ -53,40 +54,18 @@ public function process(PushMessage $message): string ], ]; - return \json_encode($this->notify($message->getTo(), $payload)); - } - - /** - * @param array $to - * @param array $payload - * @return array - */ - private function notify(array $to, array $payload): array - { - $headers = [ - 'authorization: bearer '.$this->generateJwt(), - 'apns-topic: '.$this->bundleId, - 'apns-push-type: alert', + $claims = [ + 'iss' => $this->teamId, // Issuer + 'iat' => \time(), // Issued at time + 'exp' => \time() + 3600, // Expiration time ]; - $sh = \curl_share_init(); - - \curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); - - $ch = \curl_init(); - - \curl_setopt($ch, CURLOPT_SHARE, $sh); - \curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2_0); - - \curl_setopt_array($ch, [ - CURLOPT_PORT => 443, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => \json_encode($payload), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - CURLOPT_HEADER => true, - ]); + $jwt = JWT::encode( + $claims, + $this->authKey, + 'ES256', + $this->authKeyId + ); $endpoint = 'https://api.push.apple.com'; @@ -94,108 +73,55 @@ private function notify(array $to, array $payload): array $endpoint = 'https://api.development.push.apple.com'; } - $mh = \curl_multi_init(); - $handles = []; - - // Create a handle for each request - foreach ($to as $token) { - \curl_setopt($ch, CURLOPT_URL, $endpoint.'/3/device/'.$token); - - $handle = \curl_copy_handle($ch); - \curl_multi_add_handle($mh, $handle); - - $handles[] = $handle; + $urls = []; + foreach ($message->getTo() as $token) { + $urls[] = $endpoint.'/3/device/'.$token; } - $active = 1; - $status = CURLM_OK; - - // Execute the handles - while ($active && $status == CURLM_OK) { - $status = \curl_multi_exec($mh, $active); - } + $results = $this->requestMulti( + method: 'POST', + urls: $urls, + headers: [ + 'Authorization: Bearer '.$jwt, + 'apns-topic: '.$this->bundleId, + 'apns-push-type: alert', + ], + bodies: [\json_encode($payload)] + ); $response = new Response($this->getType()); - // Check each handle's result - foreach ($handles as $ch) { - $urlInfo = curl_getinfo($ch); - $result = curl_multi_getcontent($ch); - - // Separate headers and body - [$headerString, $body] = explode("\r\n\r\n", $result, 2); - $body = \json_decode($body, true); - $errorMessage = $body ? $body['reason'] : ''; - $device = basename($urlInfo['url']); // Extracts deviceToken from the URL - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($httpCode === 200) { - $response->incrementDeliveredTo(); - $response->addResultForRecipient($device); - } else { - $response->addResultForRecipient( - $device, - $this->getSpecificErrorMessage($errorMessage) - ); - - if ($httpCode === 401) { - $response->popFromResults(); - $response->addResultForRecipient($device, 'Authentication error.'); - } + foreach ($results as $result) { + $device = \basename($result['url']); + $statusCode = $result['statusCode']; + + switch ($statusCode) { + case 200: + $response->incrementDeliveredTo(); + $response->addResultForRecipient($device); + break; + default: + $response->addResultForRecipient( + $device, + self::getSpecificErrorMessage($result['response']['reason']) + ); + break; } - - \curl_multi_remove_handle($mh, $ch); - \curl_close($ch); } - \curl_multi_close($mh); - \curl_share_close($sh); - return $response->toArray(); } - /** - * Generate JWT. - */ - private function generateJwt(): string - { - $header = json_encode(['alg' => 'ES256', 'kid' => $this->authKeyId]); - $claims = json_encode([ - 'iss' => $this->teamId, - 'iat' => time(), - ]); - - // Replaces URL sensitive characters that could be the result of base64 encoding. - // Replace to _ to avoid any special handling. - $base64UrlHeader = \str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($header)); - $base64UrlClaims = \str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($claims)); - - if (! $this->authKey) { - return ''; - } - - $signature = ''; - $success = \openssl_sign("$base64UrlHeader.$base64UrlClaims", $signature, $this->authKey, OPENSSL_ALGO_SHA256); - - if (! $success) { - return ''; - } - - $base64UrlSignature = \str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature)); - - return "$base64UrlHeader.$base64UrlClaims.$base64UrlSignature"; - } - - private function getSpecificErrorMessage(string $error): string + private static function getSpecificErrorMessage(string $error): string { return match ($error) { 'MissingDeviceToken' => 'Bad Request. Missing token.', - 'BadDeviceToken' => 'Invalid token.', - 'ExpiredToken' => 'Expired token.', - 'PayloadTooLarge' => 'Payload is too large. Please keep maximum 4096 bytes for messages.', - 'TooManyRequests' => 'Too many requests were made consecutively to the same device token.', + 'BadDeviceToken' => 'Invalid device token.', + 'ExpiredToken' => 'Expired device token.', + 'PayloadTooLarge' => 'Payload is too large. Messages must be less than 4096 bytes.', + 'TooManyRequests' => 'Too many requests were made to the same device token.', 'InternalServerError' => 'Internal server error.', - 'PayloadEmpty' => 'Bad Request.', + 'PayloadEmpty' => 'Missing payload.', default => $error, }; } diff --git a/src/Utopia/Messaging/Adapter/Push/FCM.php b/src/Utopia/Messaging/Adapter/Push/FCM.php index 169e07bf..eaa28905 100644 --- a/src/Utopia/Messaging/Adapter/Push/FCM.php +++ b/src/Utopia/Messaging/Adapter/Push/FCM.php @@ -3,16 +3,23 @@ namespace Utopia\Messaging\Adapter\Push; use Utopia\Messaging\Adapter\Push as PushAdapter; +use Utopia\Messaging\Helpers\JWT; use Utopia\Messaging\Messages\Push as PushMessage; use Utopia\Messaging\Response; class FCM extends PushAdapter { + private const DEFAULT_EXPIRY_SECONDS = 3600; // 1 hour + + private const DEFAULT_SKEW_SECONDS = 60; // 1 minute + + private const GOOGLE_TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token'; + /** - * @param string $serverKey The FCM server key. + * @param string $serviceAccountJSON Service account JSON file contents */ public function __construct( - private string $serverKey, + private string $serviceAccountJSON, ) { } @@ -29,89 +36,120 @@ public function getName(): string */ public function getMaxMessagesPerRequest(): int { - return 1000; + return 5000; } /** * {@inheritdoc} */ - protected function process(PushMessage $message): string + protected function process(PushMessage $message): array { - $response = new Response($this->getType()); - $result = $this->request( + $credentials = \json_decode($this->serviceAccountJSON, true); + + $now = \time(); + + $signingKey = $credentials['private_key']; + $signingAlgorithm = 'RS256'; + + $payload = [ + 'iss' => $credentials['client_email'], + 'exp' => $now + self::DEFAULT_EXPIRY_SECONDS, + 'iat' => $now - self::DEFAULT_SKEW_SECONDS, + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => self::GOOGLE_TOKEN_URL, + ]; + + $jwt = JWT::encode( + $payload, + $signingKey, + $signingAlgorithm, + ); + + $signingKey = null; + $payload = null; + + $token = $this->request( method: 'POST', - url: 'https://fcm.googleapis.com/fcm/send', + url: self::GOOGLE_TOKEN_URL, headers: [ - 'Content-Type: application/json', - "Authorization: key={$this->serverKey}", + 'Content-Type: application/x-www-form-urlencoded', ], - body: \json_encode([ - 'registration_ids' => $message->getTo(), + body: \http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]) + ); + + $accessToken = $token['response']['access_token']; + + $shared = [ + 'message' => [ 'notification' => [ 'title' => $message->getTitle(), 'body' => $message->getBody(), - 'click_action' => $message->getAction(), - 'icon' => $message->getIcon(), - 'badge' => $message->getBadge(), - 'color' => $message->getColor(), - 'sound' => $message->getSound(), - 'tag' => $message->getTag(), ], - 'data' => $message->getData(), - ]) + ], + ]; + + if (! \is_null($message->getData())) { + $shared['message']['data'] = $message->getData(); + } + if (! \is_null($message->getAction())) { + $shared['message']['android']['notification']['click_action'] = $message->getAction(); + $shared['message']['apns']['payload']['aps']['category'] = $message->getAction(); + } + if (! \is_null($message->getImage())) { + $shared['message']['android']['notification']['image'] = $message->getImage(); + $shared['message']['apns']['payload']['aps']['mutable-content'] = 1; + $shared['message']['apns']['fcm_options']['image'] = $message->getImage(); + } + if (! \is_null($message->getSound())) { + $shared['message']['android']['notification']['sound'] = $message->getSound(); + $shared['message']['apns']['payload']['aps']['sound'] = $message->getSound(); + } + if (! \is_null($message->getIcon())) { + $shared['message']['android']['notification']['icon'] = $message->getIcon(); + } + if (! \is_null($message->getColor())) { + $shared['message']['android']['notification']['color'] = $message->getColor(); + } + if (! \is_null($message->getTag())) { + $shared['message']['android']['notification']['tag'] = $message->getTag(); + } + if (! \is_null($message->getBadge())) { + $shared['message']['apns']['payload']['aps']['badge'] = $message->getBadge(); + } + + $bodies = []; + + foreach ($message->getTo() as $to) { + $body = $shared; + $body['message']['token'] = $to; + $bodies[] = \json_encode($body); + } + + $results = $this->requestMulti( + method: 'POST', + urls: ["https://fcm.googleapis.com/v1/projects/{$credentials['project_id']}/messages:send"], + headers: [ + 'Content-Type: application/json', + "Authorization: Bearer {$accessToken}", + ], + bodies: $bodies ); - $response->setDeliveredTo($result['response']['success']); + $response = new Response($this->getType()); - foreach ($result['response']['results'] as $index => $item) { + foreach ($results as $index => $result) { if ($result['statusCode'] === 200) { - $response->addResultForRecipient( - $message->getTo()[$index], - \array_key_exists('error', $item) - ? $this->getSpecificErrorMessage($item['error']) - : '', - ); - } elseif ($result['statusCode'] === 400) { - $response->addResultForRecipient( - $message->getTo()[$index], - match ($item['error']) { - 'Invalid JSON' => 'Bad Request.', - 'Invalid Parameters' => 'Bad Request.', - default => null, - }, - ); - } elseif ($result['statusCode'] === 401) { - $response->addResultForRecipient( - $message->getTo()[$index], - 'Authentication error.', - ); - } elseif ($result['statusCode'] >= 500) { - $response->addResultForRecipient( - $message->getTo()[$index], - 'Server unavailable.', - ); - } else { - $response->addResultForRecipient( - $message->getTo()[$index], - 'Unknown error', - ); + $response->incrementDeliveredTo(); } - + $response->addResultForRecipient( + $message->getTo()[$index], + $result['response']['error']['message'] ?? '' + ); } - return \json_encode($response->toArray()); - } - - private function getSpecificErrorMessage(string $error): string - { - return match ($error) { - 'MissingRegistration' => 'Bad Request. Missing token.', - 'InvalidRegistration' => 'Invalid token.', - 'NotRegistered' => 'Expired token.', - 'MessageTooBig' => 'Payload is too large. Please keep maximum 4096 bytes for messages.', - 'DeviceMessageRateExceeded' => 'Too many requests were made consecutively to the same device token.', - 'InternalServerError' => 'Internal server error.', - default => $error, - }; + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS.php b/src/Utopia/Messaging/Adapter/SMS.php index 02c0c1ba..84ae6da4 100644 --- a/src/Utopia/Messaging/Adapter/SMS.php +++ b/src/Utopia/Messaging/Adapter/SMS.php @@ -21,9 +21,9 @@ public function getMessageType(): string * Send an SMS message. * * @param SMSMessage $message Message to send. - * @return string The response body. + * @return array{deliveredTo: int, type: string, results: array>} * * @throws \Exception If the message fails. */ - abstract protected function process(SMSMessage $message): string; + abstract protected function process(SMSMessage $message): array; } diff --git a/src/Utopia/Messaging/Adapter/SMS/Clickatell.php b/src/Utopia/Messaging/Adapter/SMS/Clickatell.php index fbb0e1ad..291f56ef 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Clickatell.php +++ b/src/Utopia/Messaging/Adapter/SMS/Clickatell.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; +use Utopia\Messaging\Response; // Reference Material // https://docs.clickatell.com/channels/sms-channels/sms-api-reference/#tag/SMS-API/operation/sendMessageREST_1 @@ -33,8 +34,10 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { + $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: 'https://platform.clickatell.com/messages', @@ -49,6 +52,17 @@ protected function process(SMSMessage $message): string ]), ); - return \json_encode($result['response']); + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to); + } + } else { + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to, 'Unknown error.'); + } + } + + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index b3f66429..b62c86fa 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -53,7 +53,10 @@ protected function filterCallingCodesByAdapter(SMSAdapter $adapter): array return $result; } - protected function process(SMS $message): string + /** + * @return array>}> + */ + protected function process(SMS $message): array { $results = []; $recipients = $message->getTo(); @@ -62,14 +65,14 @@ protected function process(SMS $message): string [$nextRecipients, $nextAdapter] = $this->getNextRecipientsAndAdapter($recipients); try { - $results[$nextAdapter->getName()] = json_decode($nextAdapter->send( + $results[$nextAdapter->getName()] = $nextAdapter->send( new SMS( to: $nextRecipients, content: $message->getContent(), from: $message->getFrom(), attachments: $message->getAttachments() ) - )); + ); } catch (\Exception $e) { $results[$nextAdapter->getName()] = [ 'type' => 'error', @@ -80,7 +83,7 @@ protected function process(SMS $message): string $recipients = \array_diff($recipients, $nextRecipients); } while (count($recipients) > 0); - return \json_encode($results); + return $results; } /** diff --git a/src/Utopia/Messaging/Adapter/SMS/Infobip.php b/src/Utopia/Messaging/Adapter/SMS/Infobip.php index c25de823..693733eb 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Infobip.php +++ b/src/Utopia/Messaging/Adapter/SMS/Infobip.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; +use Utopia\Messaging\Response; // Reference Material // https://www.infobip.com/docs/api/channels/sms/sms-messaging/outbound-sms/send-sms-message @@ -35,10 +36,12 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $to = \array_map(fn ($number) => ['to' => \ltrim($number, '+')], $message->getTo()); + $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: "https://{$this->apiBaseUrl}/sms/2/text/advanced", @@ -55,6 +58,17 @@ protected function process(SMSMessage $message): string ]), ); - return \json_encode($result['response']); + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to); + } + } else { + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to, 'Unknown error.'); + } + } + + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Mock.php b/src/Utopia/Messaging/Adapter/SMS/Mock.php index 9eba18e2..983a3e59 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Mock.php +++ b/src/Utopia/Messaging/Adapter/SMS/Mock.php @@ -33,7 +33,7 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $response = new Response($this->getType()); @@ -65,6 +65,6 @@ protected function process(SMSMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91.php b/src/Utopia/Messaging/Adapter/SMS/Msg91.php index 6900ceb1..7b427d71 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Msg91.php +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91.php @@ -37,7 +37,7 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $recipients = []; foreach ($message->getTo() as $recipient) { @@ -74,6 +74,6 @@ protected function process(SMSMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Plivo.php b/src/Utopia/Messaging/Adapter/SMS/Plivo.php index 18439719..072b168c 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Plivo.php +++ b/src/Utopia/Messaging/Adapter/SMS/Plivo.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; +use Utopia\Messaging\Response; // Reference Material // https://www.plivo.com/docs/sms/api/message#send-a-message @@ -35,8 +36,10 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { + $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: "https://api.plivo.com/v1/Account/{$this->authId}/Message/", @@ -50,6 +53,17 @@ protected function process(SMSMessage $message): string ]), ); - return \json_encode($result['response']); + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to); + } + } else { + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to, 'Unknown error.'); + } + } + + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Seven.php b/src/Utopia/Messaging/Adapter/SMS/Seven.php index 8f0132fc..ecba9b3f 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Seven.php +++ b/src/Utopia/Messaging/Adapter/SMS/Seven.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; +use Utopia\Messaging\Response; // Reference Material // https://www.seven.io/en/docs/gateway/http-api/sms-dispatch/ @@ -33,8 +34,10 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { + $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: 'https://gateway.sms77.io/api/sms', @@ -49,6 +52,17 @@ protected function process(SMSMessage $message): string ]), ); - return \json_encode($result['response']); + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to); + } + } else { + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to, 'Unknown error.'); + } + } + + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Sinch.php b/src/Utopia/Messaging/Adapter/SMS/Sinch.php index 8fc96c1a..8a1a2423 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Sinch.php +++ b/src/Utopia/Messaging/Adapter/SMS/Sinch.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; +use Utopia\Messaging\Response; // Reference Material // https://developers.sinch.com/docs/sms/api-reference/ @@ -35,10 +36,12 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $to = \array_map(fn ($number) => \ltrim($number, '+'), $message->getTo()); + $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: "https://sms.api.sinch.com/xms/v1/{$this->servicePlanId}/batches", @@ -53,6 +56,17 @@ protected function process(SMSMessage $message): string ]), ); - return \json_encode($result['response']); + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to); + } + } else { + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to, 'Unknown error.'); + } + } + + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Telesign.php b/src/Utopia/Messaging/Adapter/SMS/Telesign.php index 51aa23b3..54b45fdf 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Telesign.php +++ b/src/Utopia/Messaging/Adapter/SMS/Telesign.php @@ -36,7 +36,7 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $to = $this->formatNumbers(\array_map( fn ($to) => $to, @@ -44,6 +44,7 @@ protected function process(SMSMessage $message): string )); $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: 'https://rest-ww.telesign.com/v1/verify/bulk_sms', @@ -67,7 +68,7 @@ protected function process(SMSMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } /** diff --git a/src/Utopia/Messaging/Adapter/SMS/Telnyx.php b/src/Utopia/Messaging/Adapter/SMS/Telnyx.php index 48199ad3..f05c7f4a 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Telnyx.php +++ b/src/Utopia/Messaging/Adapter/SMS/Telnyx.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; +use Utopia\Messaging\Response; class Telnyx extends SMSAdapter { @@ -31,8 +32,10 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { + $response = new Response($this->getType()); + $result = $this->request( method: 'POST', url: 'https://api.telnyx.com/v2/messages', @@ -47,6 +50,17 @@ protected function process(SMSMessage $message): string ]), ); - return \json_encode($result['response']); + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(\count($message->getTo())); + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to); + } + } else { + foreach ($message->getTo() as $to) { + $response->addResultForRecipient($to, 'Unknown error.'); + } + } + + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/TextMagic.php b/src/Utopia/Messaging/Adapter/SMS/TextMagic.php index d99cb69d..b540aa32 100644 --- a/src/Utopia/Messaging/Adapter/SMS/TextMagic.php +++ b/src/Utopia/Messaging/Adapter/SMS/TextMagic.php @@ -9,7 +9,7 @@ use Utopia\Messaging\Messages\SMS as SMSMessage; use Utopia\Messaging\Response; -class Textmagic extends SMSAdapter +class TextMagic extends SMSAdapter { /** * @param string $username Textmagic account username @@ -37,7 +37,7 @@ public function getMaxMessagesPerRequest(): int * * @throws \Exception */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $to = \array_map( fn ($to) => \ltrim($to, '+'), @@ -70,6 +70,6 @@ protected function process(SMSMessage $message): string } } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Twilio.php b/src/Utopia/Messaging/Adapter/SMS/Twilio.php index 213d2f45..e9e46b0e 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Twilio.php +++ b/src/Utopia/Messaging/Adapter/SMS/Twilio.php @@ -32,7 +32,7 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} */ - protected function process(SMSMessage $message): string + protected function process(SMSMessage $message): array { $response = new Response($this->getType()); @@ -56,6 +56,6 @@ protected function process(SMSMessage $message): string $response->addResultForRecipient($message->getTo()[0], $result['response']['message'] ?? ''); } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Adapter/SMS/Vonage.php b/src/Utopia/Messaging/Adapter/SMS/Vonage.php index 433c4a56..ce3ff096 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Vonage.php +++ b/src/Utopia/Messaging/Adapter/SMS/Vonage.php @@ -34,10 +34,8 @@ public function getMaxMessagesPerRequest(): int /** * {@inheritdoc} - * - * @throws \Exception */ - protected function process(SMS $message): string + protected function process(SMS $message): array { $to = \array_map( fn ($to) => \ltrim($to, '+'), @@ -66,6 +64,6 @@ protected function process(SMS $message): string $response->addResultForRecipient($message->getTo()[0], $result['response']['messages'][0]['error-text']); } - return \json_encode($response->toArray()); + return $response->toArray(); } } diff --git a/src/Utopia/Messaging/Helpers/JWT.php b/src/Utopia/Messaging/Helpers/JWT.php new file mode 100644 index 00000000..58ce506d --- /dev/null +++ b/src/Utopia/Messaging/Helpers/JWT.php @@ -0,0 +1,164 @@ + ['openssl', OPENSSL_ALGO_SHA384], + 'ES256' => ['openssl', OPENSSL_ALGO_SHA256], + 'ES256K' => ['openssl', OPENSSL_ALGO_SHA256], + 'RS256' => ['openssl', OPENSSL_ALGO_SHA256], + 'RS384' => ['openssl', OPENSSL_ALGO_SHA384], + 'RS512' => ['openssl', OPENSSL_ALGO_SHA512], + 'HS256' => ['hash_hmac', 'SHA256'], + 'HS384' => ['hash_hmac', 'SHA384'], + 'HS512' => ['hash_hmac', 'SHA512'], + ]; + + /** + * Convert an array to a JWT, signed with the given key and algorithm. + * + * @param array $payload + * + * @throws \Exception + */ + public static function encode(array $payload, string $key, string $algorithm, string $keyId = null): string + { + $header = [ + 'typ' => 'JWT', + 'alg' => $algorithm, + ]; + + if (! \is_null($keyId)) { + $header['kid'] = $keyId; + } + + $header = \json_encode($header, \JSON_UNESCAPED_SLASHES); + $payload = \json_encode($payload, \JSON_UNESCAPED_SLASHES); + + $segments = []; + $segments[] = self::safeBase64Encode($header); + $segments[] = self::safeBase64Encode($payload); + + $signingMaterial = \implode('.', $segments); + + $signature = self::sign($signingMaterial, $key, $algorithm); + + $segments[] = self::safeBase64Encode($signature); + + return \implode('.', $segments); + } + + /** + * @throws \Exception + */ + private static function sign(string $data, string $key, string $alg): string + { + if (empty(self::ALGORITHMS[$alg])) { + throw new \Exception('Algorithm not supported'); + } + + [$function, $algorithm] = self::ALGORITHMS[$alg]; + + switch ($function) { + case 'openssl': + $signature = ''; + + $success = \openssl_sign($data, $signature, $key, $algorithm); + + if (! $success) { + throw new \Exception('OpenSSL sign failed for JWT'); + } + + switch ($alg) { + case 'ES256': + case 'ES256K': + $signature = self::signatureFromDER($signature, 256); + break; + case 'ES384': + $signature = self::signatureFromDER($signature, 384); + break; + default: + break; + } + + return $signature; + case 'hash_hmac': + return \hash_hmac($algorithm, $data, $key, true); + default: + throw new \Exception('Algorithm not supported'); + } + } + + /** + * Encodes signature from a DER object. + * + * @param string $der binary signature in DER format + * @param int $keySize the number of bits in the key + */ + private static function signatureFromDER(string $der, int $keySize): string + { + // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE + [$offset, $_] = self::readDER($der); + [$offset, $r] = self::readDER($der, $offset); + [$_, $s] = self::readDER($der, $offset); + + // Convert r-value and s-value from signed two's compliment to unsigned big-endian integers + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + + // Pad out r and s so that they are $keySize bits long + $r = \str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT); + $s = \str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT); + + return $r.$s; + } + + /** + * Reads binary DER-encoded data and decodes into a single object + * + * @param int $offset + * to decode + * @return array{int, string|null} + */ + private static function readDER(string $der, int $offset = 0): array + { + $pos = $offset; + $size = \strlen($der); + $constructed = (\ord($der[$pos]) >> 5) & 0x01; + $type = \ord($der[$pos++]) & 0x1F; + + // Length + $len = \ord($der[$pos++]); + if ($len & 0x80) { + $n = $len & 0x1F; + $len = 0; + while ($n-- && $pos < $size) { + $len = ($len << 8) | \ord($der[$pos++]); + } + } + + // Value + if ($type === 0x03) { + $pos++; // Skip the first contents octet (padding indicator) + $data = \substr($der, $pos, $len - 1); + $pos += $len - 1; + } elseif (! $constructed) { + $data = \substr($der, $pos, $len); + $pos += $len; + } else { + $data = null; + } + + return [$pos, $data]; + } + + /** + * Encode a string with URL-safe Base64. + */ + private static function safeBase64Encode(string $input): string + { + return \str_replace(['+', '/', '='], ['-', '_', ''], \base64_encode($input)); + } +} diff --git a/src/Utopia/Messaging/Messages/Push.php b/src/Utopia/Messaging/Messages/Push.php index bf1712f6..b5172862 100644 --- a/src/Utopia/Messaging/Messages/Push.php +++ b/src/Utopia/Messaging/Messages/Push.php @@ -11,12 +11,13 @@ class Push implements Message * @param string $title The title of the push notification. * @param string $body The body of the push notification. * @param array|null $data This parameter specifies the custom key-value pairs of the message's payload. For example, with data:{"score":"3x1"}:

On Apple platforms, if the message is sent via APNs, it represents the custom data fields. If it is sent via FCM, it would be represented as key value dictionary in AppDelegate application:didReceiveRemoteNotification:.

On Android, this would result in an intent extra named score with the string value 3x1.

The key should not be a reserved word ("from", "message_type", or any word starting with "google" or "gcm"). Do not use any of the words defined in this table (such as collapse_key).

Values in string types are recommended. You have to convert values in objects or other non-string data types (e.g., integers or booleans) to string. - * @param string|null $sound The sound to play when the device receives the notification.

On Android, sound files must reside in /res/raw/.

On iOS, sounds files must reside in the main bundle of the client app or in the Library/Sounds folder of the app's data container. * @param string|null $action The action associated with a user click on the notification.

On Android, this is the activity to launch.

On iOS, this is the category to launch. + * @param string|null $sound The sound to play when the device receives the notification.

On Android, sound files must reside in /res/raw/.

On iOS, sounds files must reside in the main bundle of the client app or in the Library/Sounds folder of the app's data container. + * @param string|null $image The image to display when the device receives the notification.

On Android, this image is displayed as a badge on the notification.

On iOS, this image is displayed next to the body of the notification. If present, the notification's type is set to media. * @param string|null $icon Android only. The icon of the push notification. Sets the notification icon to myicon for drawable resource myicon. If you don't send this key in the request, FCM displays the launcher icon specified in your app manifest. * @param string|null $color Android only. The icon color of the push notification, expressed in #rrggbb format. * @param string|null $tag Android only. Identifier used to replace existing notifications in the notification drawer.

If not specified, each request creates a new notification.

If specified and a notification with the same tag is already being shown, the new notification replaces the existing one in the notification drawer. - * @param string|null $badge iOS only. The value of the badge on the home screen app icon. If not specified, the badge is not changed. If set to 0, the badge is removed. + * @param int|null $badge iOS only. The value of the badge on the home screen app icon. If not specified, the badge is not changed. If set to 0, the badge is removed. */ public function __construct( private array $to, @@ -25,10 +26,11 @@ public function __construct( private ?array $data = null, private ?string $action = null, private ?string $sound = null, + private ?string $image = null, private ?string $icon = null, private ?string $color = null, private ?string $tag = null, - private ?string $badge = null, + private ?int $badge = null, ) { } @@ -73,6 +75,11 @@ public function getSound(): ?string return $this->sound; } + public function getImage(): ?string + { + return $this->image; + } + public function getIcon(): ?string { return $this->icon; @@ -88,7 +95,7 @@ public function getTag(): ?string return $this->tag; } - public function getBadge(): ?string + public function getBadge(): ?int { return $this->badge; } diff --git a/src/Utopia/Messaging/Response.php b/src/Utopia/Messaging/Response.php index 3b929151..2b92ce37 100644 --- a/src/Utopia/Messaging/Response.php +++ b/src/Utopia/Messaging/Response.php @@ -49,7 +49,7 @@ public function addResultForRecipient(string $recipient, string $error = ''): vo { $this->results[] = [ 'recipient' => $recipient, - 'status' => $error === '' ? 'success' : 'failure', + 'status' => empty($error) ? 'success' : 'failure', 'error' => $error, ]; } diff --git a/tests/Messaging/Adapter/Chat/DiscordTest.php b/tests/Messaging/Adapter/Chat/DiscordTest.php index 7f159af6..7c7d2789 100644 --- a/tests/Messaging/Adapter/Chat/DiscordTest.php +++ b/tests/Messaging/Adapter/Chat/DiscordTest.php @@ -25,7 +25,7 @@ public function testSendMessage(): void wait: true ); - $result = \json_decode($sender->send($message), true); + $result = $sender->send($message); $this->assertResponse($result); } diff --git a/tests/Messaging/Adapter/Email/EmailTest.php b/tests/Messaging/Adapter/Email/EmailTest.php index 1b168c2d..2aaa03e5 100644 --- a/tests/Messaging/Adapter/Email/EmailTest.php +++ b/tests/Messaging/Adapter/Email/EmailTest.php @@ -26,7 +26,7 @@ public function testSendEmail(): void fromEmail: $fromEmail, ); - $response = \json_decode($sender->send($message), true); + $response = $sender->send($message); $lastEmail = $this->getLastEmail(); diff --git a/tests/Messaging/Adapter/Email/MailgunTest.php b/tests/Messaging/Adapter/Email/MailgunTest.php index 128c490a..396c6172 100644 --- a/tests/Messaging/Adapter/Email/MailgunTest.php +++ b/tests/Messaging/Adapter/Email/MailgunTest.php @@ -32,7 +32,7 @@ public function testSendEmail(): void fromEmail: $fromEmail, ); - $response = \json_decode($sender->send($message), true); + $response = $sender->send($message); $this->assertResponse($response); } diff --git a/tests/Messaging/Adapter/Email/SendgridTest.php b/tests/Messaging/Adapter/Email/SendgridTest.php index 9cef0cce..ab7bbb92 100644 --- a/tests/Messaging/Adapter/Email/SendgridTest.php +++ b/tests/Messaging/Adapter/Email/SendgridTest.php @@ -26,7 +26,7 @@ public function testSendEmail(): void fromEmail: $fromEmail, ); - $response = \json_decode($sender->send($message), true); + $response = $sender->send($message); $this->assertResponse($response); } diff --git a/tests/Messaging/Adapter/Push/APNSTest.php b/tests/Messaging/Adapter/Push/APNSTest.php index 8f53dd07..52164534 100644 --- a/tests/Messaging/Adapter/Push/APNSTest.php +++ b/tests/Messaging/Adapter/Push/APNSTest.php @@ -19,18 +19,18 @@ public function testSend(): void $message = new Push( to: [\getenv('APNS_TO')], - title: 'TestTitle', - body: 'TestBody', + title: 'Test title', + body: 'Test body', data: null, action: null, sound: 'default', icon: null, color: null, tag: null, - badge: '1' + badge: 1 ); - $response = \json_decode($adapter->send($message), true); + $response = $adapter->send($message); $this->assertResponse($response); } diff --git a/tests/Messaging/Adapter/Push/FCMTest.php b/tests/Messaging/Adapter/Push/FCMTest.php index 23c17c33..50e0391f 100644 --- a/tests/Messaging/Adapter/Push/FCMTest.php +++ b/tests/Messaging/Adapter/Push/FCMTest.php @@ -10,26 +10,26 @@ class FCMTest extends Base { public function testSend(): void { - $serverKey = \getenv('FCM_SERVER_KEY'); + $serverKey = \getenv('FCM_SERVICE_ACCOUNT_JSON'); $adapter = new FCMAdapter($serverKey); - $to = \getenv('FCM_SERVER_TO'); + $to = \getenv('FCM_TO'); $message = new Push( to: [$to], - title: 'TestTitle', - body: 'TestBody', + title: 'Test title', + body: 'Test body', data: null, action: null, sound: 'default', icon: null, color: null, tag: null, - badge: '1' + badge: 1 ); - $response = \json_decode($adapter->send($message), true); + $response = $adapter->send($message); $this->assertResponse($response); } diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index d10b7799..82633336 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -13,10 +13,8 @@ class GEOSMSTest extends Base public function testSendSMSUsingDefaultAdapter(): void { $defaultAdapterMock = $this->createMock(SMSAdapter::class); - $defaultAdapterMock->method('getName') - ->willReturn('default'); - $defaultAdapterMock->method('send') - ->willReturn(json_encode(['status' => 'success'])); + $defaultAdapterMock->method('getName')->willReturn('default'); + $defaultAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); $adapter = new GEOSMS($defaultAdapterMock); @@ -29,20 +27,18 @@ public function testSendSMSUsingDefaultAdapter(): void from: $from ); - $result = json_decode($adapter->send($message), true); + $result = $adapter->send($message); $this->assertEquals(1, count($result)); - $this->assertEquals('success', $result['default']['status']); + $this->assertEquals('success', $result['default']['results'][0]['status']); } public function testSendSMSUsingLocalAdapter(): void { $defaultAdapterMock = $this->createMock(SMSAdapter::class); $localAdapterMock = $this->createMock(SMSAdapter::class); - $localAdapterMock->method('getName') - ->willReturn('local'); - $localAdapterMock->method('send') - ->willReturn(json_encode(['status' => 'success'])); + $localAdapterMock->method('getName')->willReturn('local'); + $localAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); $adapter = new GEOSMS($defaultAdapterMock); $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); @@ -56,24 +52,20 @@ public function testSendSMSUsingLocalAdapter(): void from: $from ); - $result = json_decode($adapter->send($message), true); + $result = $adapter->send($message); $this->assertEquals(1, count($result)); - $this->assertEquals('success', $result['local']['status']); + $this->assertEquals('success', $result['local']['results'][0]['status']); } public function testSendSMSUsingLocalAdapterAndDefault(): void { $defaultAdapterMock = $this->createMock(SMSAdapter::class); - $defaultAdapterMock->method('getName') - ->willReturn('default'); - $defaultAdapterMock->method('send') - ->willReturn(json_encode(['status' => 'success'])); + $defaultAdapterMock->method('getName')->willReturn('default'); + $defaultAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); $localAdapterMock = $this->createMock(SMSAdapter::class); - $localAdapterMock->method('getName') - ->willReturn('local'); - $localAdapterMock->method('send') - ->willReturn(json_encode(['status' => 'success'])); + $localAdapterMock->method('getName')->willReturn('local'); + $localAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); $adapter = new GEOSMS($defaultAdapterMock); $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); @@ -87,21 +79,19 @@ public function testSendSMSUsingLocalAdapterAndDefault(): void from: $from ); - $result = json_decode($adapter->send($message), true); + $result = $adapter->send($message); $this->assertEquals(2, count($result)); - $this->assertEquals('success', $result['local']['status']); - $this->assertEquals('success', $result['default']['status']); + $this->assertEquals('success', $result['local']['results'][0]['status']); + $this->assertEquals('success', $result['default']['results'][0]['status']); } public function testSendSMSUsingGroupedLocalAdapter(): void { $defaultAdapterMock = $this->createMock(SMSAdapter::class); $localAdapterMock = $this->createMock(SMSAdapter::class); - $localAdapterMock->method('getName') - ->willReturn('local'); - $localAdapterMock->method('send') - ->willReturn(json_encode(['status' => 'success'])); + $localAdapterMock->method('getName')->willReturn('local'); + $localAdapterMock->method('send')->willReturn(['results' => [['status' => 'success']]]); $adapter = new GEOSMS($defaultAdapterMock); $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); @@ -116,9 +106,9 @@ public function testSendSMSUsingGroupedLocalAdapter(): void from: $from ); - $result = json_decode($adapter->send($message), true); + $result = $adapter->send($message); $this->assertEquals(1, count($result)); - $this->assertEquals('success', $result['local']['status']); + $this->assertEquals('success', $result['local']['results'][0]['status']); } } diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index a81f77f0..854bdeab 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -17,7 +17,7 @@ public function testSendSMS(): void content: 'Test Content', ); - $response = \json_decode($sender->send($message), true); + $response = $sender->send($message); $this->assertResponse($response); } diff --git a/tests/Messaging/Adapter/SMS/TelnyxTest.php b/tests/Messaging/Adapter/SMS/TelnyxTest.php index 3db276a5..029305af 100644 --- a/tests/Messaging/Adapter/SMS/TelnyxTest.php +++ b/tests/Messaging/Adapter/SMS/TelnyxTest.php @@ -19,7 +19,7 @@ public function testSendSMS(): void // from: '+15005550006' // ); - // $result = \json_decode($sender->send($message), true); + // $result = $sender->send($message); // $this->assertEquals('success', $result["type"]); diff --git a/tests/Messaging/Adapter/SMS/TwilioTest.php b/tests/Messaging/Adapter/SMS/TwilioTest.php index 92a3523d..7cc078c3 100644 --- a/tests/Messaging/Adapter/SMS/TwilioTest.php +++ b/tests/Messaging/Adapter/SMS/TwilioTest.php @@ -23,9 +23,8 @@ public function testSendSMS(): void from: $from ); - $result = \json_decode($sender->send($message), true); - - $this->assertResponse($result); + $response = $sender->send($message); + $this->assertResponse($response); } }