'sent', 'MESSAGE_DELIVERED' => 'delivered', 'MESSAGE_FAILED' => 'failed', ); /** * Hook the route in. * * @return void */ public static function register(): void { add_action( 'rest_api_init', array( self::class, 'register_route' ) ); } /** * Register the route. * * @return void */ public static function register_route(): void { register_rest_route( self::REST_NAMESPACE, self::ROUTE, array( 'methods' => 'POST', 'callback' => array( self::class, 'handle' ), // smsportal.app has no WordPress login: a request is allowed only with the HMAC signature of this // connection, checked before the callback runs. 'permission_callback' => array( self::class, 'authorize' ), ) ); } /** * Permission check of the route: the request must carry the HMAC-SHA256 signature of its raw body made with * this connection's webhook secret. Without a connection the answer is 410, which tells smsportal.app to * switch the subscription off. * * @param WP_REST_Request $request Request. * @return true|WP_Error */ public static function authorize( WP_REST_Request $request ) { $secret = Connection::webhook_secret(); if ( null === $secret ) { return new WP_Error( 'smsportal_not_connected', 'not_connected', array( 'status' => 410 ) ); } $body = $request->get_body(); if ( ! self::signature_valid( $body, (string) $request->get_header( 'X-Signature' ), $secret ) ) { self::log_rejected( $body ); return new WP_Error( 'smsportal_invalid_signature', 'invalid_signature', array( 'status' => 401 ) ); } return true; } /** * Dedupe and process one delivery (the signature was checked by authorize()). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function handle( WP_REST_Request $request ): WP_REST_Response { $body = $request->get_body(); $payload = json_decode( $body, true ); if ( ! is_array( $payload ) ) { return self::answer( 400, 'invalid_body' ); } $key = isset( $payload['idempotencyKey'] ) && is_string( $payload['idempotencyKey'] ) ? $payload['idempotencyKey'] : ''; if ( '' !== $key ) { $transient = self::DEDUPE_PREFIX . md5( $key ); if ( false !== get_transient( $transient ) ) { return self::answer( 200, 'duplicate' ); } // Marked before processing: a retry must not add a second note or e-mail. set_transient( $transient, 1, self::DEDUPE_TTL ); } self::process( $payload ); return self::answer( 200, 'ok' ); } /** * Constant-time check of the hex HMAC-SHA256 of the raw body. * * @param string $body Raw request body. * @param string $signature X-Signature header. * @param string $secret Webhook secret. * @return bool */ private static function signature_valid( string $body, string $signature, string $secret ): bool { $signature = strtolower( trim( $signature ) ); return '' !== $signature && hash_equals( hash_hmac( 'sha256', $body, $secret ), $signature ); } /** * Act on a verified event. * * @param array $payload Event. * @return void */ private static function process( array $payload ): void { $event = self::field( $payload, 'webhookEvent' ); if ( 'MESSAGE_RECEIVED' === $event ) { $sender = self::field( $payload, 'sender' ); if ( '' !== $sender ) { Replies::receive( $sender, self::field( $payload, 'message' ), self::field( $payload, 'receivedAt' ) ); } return; } if ( ! isset( self::STATUS_EVENTS[ $event ] ) ) { return; } $status = self::STATUS_EVENTS[ $event ]; if ( 'failed' === $status && 'expired' === self::field( $payload, 'errorCode' ) ) { $status = 'expired'; } $recipient = self::field( $payload, 'recipient' ); Messages::apply_report( self::field( $payload, 'smsBatchId' ), '' === $recipient ? null : Phone::normalize( $recipient, Phone::default_country_code() ), $status, sanitize_text_field( self::field( $payload, 'errorMessage' ) ) ); } /** * String field of the payload ('' when missing or not a string). * * @param array $payload Event. * @param string $name Field. * @return string */ private static function field( array $payload, string $name ): string { return isset( $payload[ $name ] ) && is_scalar( $payload[ $name ] ) ? (string) $payload[ $name ] : ''; } /** * Log a rejected delivery: event type and reason only, never the body. * * @param string $body Raw body. * @return void */ private static function log_rejected( string $body ): void { if ( false !== get_transient( self::REJECT_LOG_LOCK ) ) { return; } set_transient( self::REJECT_LOG_LOCK, 1, self::REJECT_LOG_INTERVAL ); $payload = json_decode( $body, true ); $event = is_array( $payload ) ? self::field( $payload, 'webhookEvent' ) : ''; $event = 1 === preg_match( '/^[A-Z_]{1,40}$/', $event ) ? $event : __( 'unknown', '85pl-android-sms-api' ); ConnectionLog::add( 'webhook_rejected', /* translators: %s: event type, e.g. MESSAGE_DELIVERED. */ sprintf( __( 'Rejected a webhook (%s): the signature does not match this connection.', '85pl-android-sms-api' ), $event ) ); } /** * Short JSON answer. * * @param int $status HTTP status. * @param string $result Machine-readable result. * @return WP_REST_Response */ private static function answer( int $status, string $result ): WP_REST_Response { return new WP_REST_Response( array( 'result' => $result ), $status ); } }