Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 5
272
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 add_bh_aws_ses_rest_endpoint
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 check_secret
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 process_new_aws_sns_notification
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 blindly_confirm_subscription_requests
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * The sns-invoked functionality of the plugin.
4 *
5 * @link       https://BrianHenry.ie
6 * @since      1.0.0
7 *
8 * @package brianhenryie/bh-wp-aws-ses-bounce-handler
9 */
10
11namespace BrianHenryIE\AWS_SES_Bounce_Handler\WP_Includes;
12
13use BrianHenryIE\AWS_SES_Bounce_Handler\API_Interface;
14use BrianHenryIE\AWS_SES_Bounce_Handler\Settings_Interface;
15use Psr\Log\LoggerAwareTrait;
16use Psr\Log\LoggerInterface;
17use SimpleXMLElement;
18use stdClass;
19
20/**
21 * The sns-invoked functionality of the plugin.
22 *
23 * Ignore snake case warnings for JSON objects.
24 * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
25 */
26class REST {
27
28    use LoggerAwareTrait;
29
30    /**
31     * The settings object contains the AWS ARNs to listen to, as configured by the user.
32     *
33     * @var Settings_Interface
34     */
35    protected $settings;
36
37    protected API_Interface $api;
38
39    /**
40     * Initialize the class and set its properties.
41     *
42     * @param Settings_Interface $settings The settings containing the ARNs to listen for.
43     * @param LoggerInterface    $logger PSR logger.
44     *
45     * @since    1.0.0
46     */
47    public function __construct( API_Interface $api, Settings_Interface $settings, LoggerInterface $logger ) {
48
49        $this->setLogger( $logger );
50        $this->settings = $settings;
51        $this->api      = $api;
52    }
53
54    /**
55     * Defines the REST endpoint itself. Added on WordPress `rest_api_init` action.
56     */
57    public function add_bh_aws_ses_rest_endpoint(): void {
58
59        register_rest_route(
60            'brianhenryie/v1',
61            '/aws-ses/',
62            array(
63                'methods'             => 'POST',
64                'callback'            => array( $this, 'process_new_aws_sns_notification' ),
65                'permission_callback' => array( $this, 'check_secret' ),
66            )
67        );
68    }
69
70    /**
71     * Check does the request have the 'secret' parameter and does it match what's in the database.
72     *
73     * @param \WP_REST_Request $request Request used to generate the response.
74     *
75     * @return bool
76     */
77    public function check_secret( $request ) {
78
79        $request_secret = $request->get_param( 'secret' );
80
81        // Check the URL `secret` querystring.
82        if ( ! $request_secret ) {
83            $this->logger->warning( 'secret not present' );
84            return false;
85        }
86
87        $saved_secret = $this->settings->get_secret_key();
88
89        if ( $saved_secret !== $request_secret ) {
90            $this->logger->warning( "secret incorrect. Was $request_secret expected $saved_secret." );
91            return false;
92        }
93
94        return true;
95    }
96
97
98    /**
99     * Parse the REST request for the SNS notification.
100     *
101     * @param \WP_REST_Request $request The HTTP request received at our REST endpoint.
102     *
103     * @return bool
104     */
105    public function process_new_aws_sns_notification( \WP_REST_Request $request ) {
106
107        $headers = $request->get_headers();
108
109        // If this is not an AWS SNS message.
110        if ( ! isset( $headers['x_amz_sns_message_type'] ) || ! isset( $headers['x_amz_sns_topic_arn'] ) ) {
111            return false;
112        }
113
114        $body = json_decode( $request->get_body() );
115
116        /**
117         * The possible message type values are SubscriptionConfirmation, Notification, and UnsubscribeConfirmation.
118         *
119         * @see https://docs.aws.amazon.com/sns/latest/dg/sns-message-and-json-formats.html
120         */
121        $message_type = $headers['x_amz_sns_message_type'][0];
122
123        switch ( $message_type ) {
124            case 'SubscriptionConfirmation':
125                $this->blindly_confirm_subscription_requests( $headers, $body );
126                return true;
127
128            case 'UnsubscribeConfirmation':
129                // It doesn't seem that SNS sends a confirmation when the subscription is deleted in the AWS console.
130                return false;
131
132            case 'Notification':
133                $topic_arn = $body->TopicArn;
134
135                // Do not process data from unknown sources.
136                if ( ! in_array( $topic_arn, $this->settings->get_confirmed_arns(), true ) ) {
137                    return false;
138                }
139
140                $message = json_decode( $body->Message );
141
142                $this->api->handle_bounces( $topic_arn, $headers, $body, $message );
143                $this->api->handle_complaints( $topic_arn, $headers, $body, $message );
144                $this->api->handle_unsubscribe_emails( $topic_arn, $headers, $body, $message );
145
146                break;
147            default:
148                // Unexpected.
149                return false;
150        }
151
152        return true;
153    }
154
155    /**
156     * Respond to AWS subscription requests with "yes"!
157     *
158     * @param array    $headers  The HTTP headers received from AWS SNS.
159     * @param stdClass $body     The parsed JSON received from AWS SNS.
160     *
161     * @return array
162     *
163     * phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
164     */
165    public function blindly_confirm_subscription_requests( $headers, $body ) {
166
167        $subscription_topic = $body->TopicArn;
168
169        $confirmation_url = $body->SubscribeURL;
170
171        $request_response = wp_remote_get( $confirmation_url );
172
173        if ( is_wp_error( $request_response ) ) {
174            /**
175             * The request_response is an error, usually when there is no response whatsoever, or a problem
176             * initiating the communication.
177             *
178             * @var \WP_Error $request_response
179             */
180
181            // TODO: Reschedule confirmation.
182            // Pretty unusual that this would fail, given it runs when being pinged by AWS.
183
184            $error_message = 'Error confirming subscription <b><i>' . $subscription_topic . '</i></b>: ' . $request_response->get_error_message();
185
186            $this->logger->error( $error_message );
187
188            return array(
189                'error'   => $request_response->get_error_code(),
190                'message' => $error_message,
191            );
192        }
193
194        // If unsuccessful.
195        if ( 2 !== intval( $request_response['response']['code'] / 100 ) ) {
196
197            $xml = new SimpleXMLElement( $request_response['body'] );
198
199            $error_message = 'Error confirming subscription for topic <b><i>' . $subscription_topic . '</i></b>. ' . $request_response['response']['message'] . ' : ' . $xml->{'Error'}->{'Message'};
200
201            return array(
202                'error'   => $request_response['response']['code'],
203                'message' => $error_message,
204            );
205        }
206
207        $message = "AWS SNS topic <b><i>$subscription_topic</i></b> subscription confirmed.";
208
209        $this->settings->set_confirmed_arn( $subscription_topic );
210
211        return array(
212            'success' => $subscription_topic,
213            'message' => $message,
214        );
215    }
216
217}
218