Passed
Push — master ( 713636...c6b7bc )
by Thomas
11:28
created

SparkPostController::authRequest()   B

Complexity

Conditions 8
Paths 64

Size

Total Lines 26
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 8
eloc 19
c 1
b 1
f 0
nc 64
nop 1
dl 0
loc 26
rs 8.4444
1
<?php
2
3
namespace LeKoala\SparkPost;
4
5
use Exception;
6
use Psr\Log\LoggerInterface;
7
use SilverStripe\Control\Director;
8
use SilverStripe\Core\Environment;
9
use SilverStripe\Control\Controller;
10
use SilverStripe\Control\HTTPRequest;
11
use SilverStripe\Security\Permission;
12
use LeKoala\SparkPost\Api\SparkPostApiClient;
13
14
/**
15
 * Provide extensions points for handling the webhook
16
 *
17
 * @author LeKoala <[email protected]>
18
 */
19
class SparkPostController extends Controller
20
{
21
    protected $eventsCount = 0;
22
    protected $skipCount = 0;
23
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
24
        'incoming',
25
        'test',
26
        'configure_inbound_emails'
27
    ];
28
29
    /**
30
     * Inject public dependencies into the controller
31
     *
32
     * @var array
33
     */
34
    private static $dependencies = [
0 ignored issues
show
introduced by
The private property $dependencies is not used, and could be removed.
Loading history...
35
        'logger' => '%$Psr\Log\LoggerInterface',
36
    ];
37
38
    /**
39
     * @var Psr\Log\LoggerInterface
0 ignored issues
show
Bug introduced by
The type LeKoala\SparkPost\Psr\Log\LoggerInterface was not found. Did you mean Psr\Log\LoggerInterface? If so, make sure to prefix the type with \.
Loading history...
40
     */
41
    public $logger;
42
43
    public function index(HTTPRequest $req)
0 ignored issues
show
Unused Code introduced by
The parameter $req is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

43
    public function index(/** @scrutinizer ignore-unused */ HTTPRequest $req)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
44
    {
45
        return $this->render([
46
            'Title' => 'SparkPost',
47
            'Content' => 'Please use a dedicated action'
48
        ]);
49
    }
50
51
    /**
52
     * You can also see /resources/sample.json
53
     *
54
     * @param HTTPRequest $req
55
     */
56
    public function test(HTTPRequest $req)
57
    {
58
        if (!Director::isDev()) {
59
            return 'You can only test in dev mode';
60
        }
61
62
        $file = $this->getRequest()->getVar('file');
63
        if ($file) {
64
            $data = file_get_contents(Director::baseFolder() . '/' . rtrim($file, '/'));
65
        } else {
66
            $data = file_get_contents(dirname(__DIR__) . '/resources/sample.json');
67
        }
68
        $payload = json_decode($data, JSON_OBJECT_AS_ARRAY);
0 ignored issues
show
Bug introduced by
LeKoala\SparkPost\JSON_OBJECT_AS_ARRAY of type integer is incompatible with the type boolean|null expected by parameter $associative of json_decode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

68
        $payload = json_decode($data, /** @scrutinizer ignore-type */ JSON_OBJECT_AS_ARRAY);
Loading history...
69
        $payload['@headers'] = $req->getHeaders();
70
71
        $this->processPayload($payload, 'TEST');
72
73
        return 'TEST OK - ' . $this->eventsCount . ' events processed / ' . $this->skipCount . ' events skipped';
74
    }
75
76
    /**
77
     * @link https://support.sparkpost.com/customer/portal/articles/2039614-enabling-inbound-email-relaying-relay-webhooks
78
     * @param HTTPRequest $req
79
     * @return string
80
     */
81
    public function configure_inbound_emails(HTTPRequest $req)
82
    {
83
        if (!Director::isDev() && !Permission::check('ADMIN')) {
84
            return 'You must be in dev mode or be logged as an admin';
85
        }
86
87
        $clearExisting = $req->getVar('clear_existing');
88
        $clearWebhooks = $req->getVar('clear_webhooks');
89
        $clearInbound = $req->getVar('clear_inbound');
90
        if ($clearExisting) {
91
            echo '<strong>Existing inbounddomains and relay webhooks will be cleared</strong><br/>';
92
        } else {
93
            echo 'You can clear existing inbound domains and relay webhooks by passing ?clear_existing=1&clear_webhooks=1&clear_inbound=1<br/>';
94
        }
95
96
        $client = SparkPostHelper::getMasterClient();
97
98
        $inbound_domain = Environment::getEnv('SPARKPOST_INBOUND_DOMAIN');
99
        if (!$inbound_domain) {
100
            die('You must define a key SPARKPOST_INBOUND_DOMAIN');
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
101
        }
102
103
        /*
104
         *  "name": "Replies Webhook",
105
         *  "target": "https://webhooks.customer.example/replies",
106
         * "auth_token": "5ebe2294ecd0e0f08eab7690d2a6ee69",
107
         *  "match": {
108
         *  "protocol": "SMTP",
109
         * "domain": "email.example.com"
110
         */
111
112
        $listWebhooks = $client->listRelayWebhooks();
113
        $listInboundDomains = $client->listInboundDomains();
114
115
        if ($clearExisting) {
116
            // we need to delete relay webhooks first!
117
            if ($clearWebhooks) {
118
                foreach ($listWebhooks as $wh) {
119
                    $client->deleteRelayWebhook($wh['id']);
120
                    echo 'Delete relay webhook ' . $wh['id'] . '<br/>';
121
                }
122
            }
123
            if ($clearInbound) {
124
                foreach ($listInboundDomains as $id) {
125
                    $client->deleteInboundDomain($id['domain']);
126
                    echo 'Delete domain ' . $id['domain'] . '<br/>';
127
                }
128
            }
129
130
            $listWebhooks = $client->listRelayWebhooks();
131
            $listInboundDomains = $client->listInboundDomains();
132
        }
133
134
        echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
135
        echo 'List Inbounds Domains:<br/>';
136
        print_r($listInboundDomains);
137
        echo '</pre>';
138
139
        $found = false;
140
141
        foreach ($listInboundDomains as $id) {
142
            if ($id['domain'] == $inbound_domain) {
143
                $found = true;
144
            }
145
        }
146
147
        if (!$found) {
148
            echo "Domain is not found, we create it<br/>";
149
150
            // This is the domain that users will send email to.
151
            $result = $client->createInboundDomain($inbound_domain);
152
153
            echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
154
            echo 'Create Inbound Domain:<br/>';
155
            print_r($result);
156
            echo '</pre>';
157
        } else {
158
            echo "Domain is already configured<br/>";
159
        }
160
161
        // Now that you have your InboundDomain set up, you can create your Relay Webhook by sending a POST request to
162
        // https://api.sparkpost.com/api/v1/relay-webhooks. This step links your consumer with the Inbound Domain.
163
164
        echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
165
        echo 'List Webhooks:<br/>';
166
        print_r($listWebhooks);
167
        echo '</pre>';
168
169
        $found = false;
170
171
        foreach ($listWebhooks as $wh) {
172
            if ($wh['match']['domain'] == $inbound_domain) {
173
                $found = true;
174
            }
175
        }
176
177
        if (!$found) {
178
            //  The match.domain property should be the same as the Inbound Domain you set up in the previous step
179
            $webhookResult = $client->createRelayWebhook([
180
                'name' => 'Inbound Webhook',
181
                'target' => Director::absoluteURL('sparkpost/incoming'),
182
                'match' => [
183
                    'domain' => $inbound_domain
184
                ]
185
            ]);
186
187
            echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
188
            echo 'Webhook result:<br/>';
189
            print_r($webhookResult);
190
            echo '</pre>';
191
192
            if ($webhookResult['id']) {
193
                echo "New webhook created with id " . $webhookResult['id'];
194
            }
195
        } else {
196
            echo "Webhook already configured";
197
        }
198
    }
199
200
    /**
201
     * Handle incoming webhook
202
     *
203
     * @link https://developers.sparkpost.com/api/#/reference/webhooks/create-a-webhook
204
     * @link https://www.sparkpost.com/blog/webhooks-beyond-the-basics/
205
     * @link https://support.sparkpost.com/customer/portal/articles/1976204-webhook-event-reference
206
     * @param HTTPRequest $req
207
     */
208
    public function incoming(HTTPRequest $req)
209
    {
210
        // Each webhook batch contains the header X-Messagesystems-Batch-Id,
211
        // which is useful for auditing and prevention of processing duplicate batches.
212
        $batchId = $req->getHeader('X-Messagesystems-Batch-Id');
213
        if (!$batchId) {
214
            $batchId = uniqid();
215
        }
216
217
        $json = file_get_contents('php://input');
218
219
        // By default, return a valid response
220
        $response = $this->getResponse();
221
        $response->setStatusCode(200);
222
        $response->setBody('NO DATA');
223
224
        if (!$json) {
225
            return $response;
226
        }
227
228
        $payload = json_decode($json, JSON_OBJECT_AS_ARRAY);
0 ignored issues
show
Bug introduced by
LeKoala\SparkPost\JSON_OBJECT_AS_ARRAY of type integer is incompatible with the type boolean|null expected by parameter $associative of json_decode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

228
        $payload = json_decode($json, /** @scrutinizer ignore-type */ JSON_OBJECT_AS_ARRAY);
Loading history...
229
230
        // Check credentials if defined
231
        $isAuthenticated = true;
232
        $authError = null;
233
        if (SparkPostHelper::getWebhookUsername()) {
234
            try {
235
                $this->authRequest($req);
236
            } catch (Exception $e) {
237
                $isAuthenticated = false;
238
                $authError = $e->getMessage();
239
            }
240
        }
241
242
        $webhookLogDir = Environment::getEnv('SPARKPOST_WEBHOOK_LOG_DIR');
243
        if ($webhookLogDir) {
244
            $dir = rtrim(Director::baseFolder(), '/') . '/' . rtrim($webhookLogDir, '/');
245
246
            if (!is_dir($dir) && Director::isDev()) {
247
                mkdir($dir, 0755, true);
248
            }
249
250
            if (is_dir($dir)) {
251
                $storedPayload = array_merge([], $payload);
252
                $storedPayload['@headers'] = $req->getHeaders();
253
                $storedPayload['@isAuthenticated'] = $isAuthenticated;
254
                $storedPayload['@authError'] = $authError;
255
                $prettyPayload = json_encode($storedPayload, JSON_PRETTY_PRINT);
256
                $time = date('Ymd-His');
257
                file_put_contents($dir . '/' . $time . '_' . $batchId . '.json', $prettyPayload);
258
            } else {
259
                $this->getLogger()->debug("Directory $dir does not exist");
260
            }
261
        }
262
263
        if (!$isAuthenticated) {
264
            return $response;
265
        }
266
267
        try {
268
            $this->processPayload($payload, $batchId);
269
        } catch (Exception $ex) {
270
            // Maybe processing payload will create exceptions, but we
271
            // catch them to send a proper response to the API
272
            $logLevel = self::config()->log_level ? self::config()->log_level : 7;
273
            $this->getLogger()->log($ex->getMessage(), $logLevel);
274
        }
275
276
        $response->setBody('OK');
277
278
        return $response;
279
    }
280
281
    protected function authRequest(HTTPRequest $req)
282
    {
283
        $requestUser = $req->getHeader('php_auth_user');
284
        $requestPassword = $req->getHeader('php_auth_pw');
285
        if (!$requestUser) {
286
            $requestUser = $_SERVER['PHP_AUTH_USER'];
287
        }
288
        if (!$requestPassword) {
289
            $requestPassword = $_SERVER['PHP_AUTH_PW'];
290
        }
291
292
        $authError = null;
293
        $hasSuppliedCredentials = $requestUser && $requestPassword;
294
        if ($hasSuppliedCredentials) {
295
            $user = SparkPostHelper::getWebhookUsername();
296
            $password = SparkPostHelper::getWebhookPassword();
297
            if ($user != $requestUser) {
298
                $authError = "User $requestUser doesn't match";
299
            } elseif ($password != $requestPassword) {
300
                $authError = "Password $requestPassword don't match";
301
            }
302
        } else {
303
            $authError = "No credentials";
304
        }
305
        if ($authError) {
306
            throw new Exception($authError);
307
        }
308
    }
309
310
    /**
311
     * Process data
312
     *
313
     * @param array $payload
314
     * @param string $batchId
315
     */
316
    protected function processPayload(array $payload, $batchId = null)
317
    {
318
        $this->extend('beforeProcessPayload', $payload, $batchId);
319
320
        $subaccount = SparkPostHelper::getClient()->getSubaccount();
321
322
        foreach ($payload as $idx => $r) {
323
            // This is a test payload
324
            if (empty($r)) {
325
                continue;
326
            }
327
            // This is a custom entry
328
            if (!is_numeric($idx)) {
329
                continue;
330
            }
331
332
            $ev = $r['msys'] ?? null;
333
334
            // Invalid payload: it should always be an object with a msys key containing the event
335
            if ($ev === null) {
336
                $this->getLogger()->warn("Invalid payload: " . substr(json_encode($r), 0, 100) . '...');
337
                continue;
338
            }
339
340
            // Check type: it should be an object with the type as key
341
            $type = key($ev);
342
            if (!isset($ev[$type])) {
343
                $this->getLogger()->warn("Invalid type $type in SparkPost payload");
344
                continue;
345
            }
346
            $data = $ev[$type];
347
348
            // Ignore events not related to the subaccount we are managing
349
            if (!empty($data['subaccount_id']) && $subaccount && $subaccount != $data['subaccount_id']) {
350
                $this->skipCount++;
351
                continue;
352
            }
353
354
            $this->eventsCount++;
355
            $this->extend('onAnyEvent', $data, $type);
356
357
            switch ($type) {
358
                    //Click, Open
359
                case SparkPostApiClient::TYPE_ENGAGEMENT:
360
                    $this->extend('onEngagementEvent', $data, $type);
361
                    break;
362
                    //Generation Failure, Generation Rejection
363
                case SparkPostApiClient::TYPE_GENERATION:
364
                    $this->extend('onGenerationEvent', $data, $type);
365
                    break;
366
                    //Bounce, Delivery, Injection, SMS Status, Spam Complaint, Out of Band, Policy Rejection, Delay
367
                case SparkPostApiClient::TYPE_MESSAGE:
368
                    $this->extend('onMessageEvent', $data, $type);
369
                    break;
370
                    //Relay Injection, Relay Rejection, Relay Delivery, Relay Temporary Failure, Relay Permanent Failure
371
                case SparkPostApiClient::TYPE_RELAY:
372
                    $this->extend('onRelayEvent', $data, $type);
373
                    break;
374
                    //List Unsubscribe, Link Unsubscribe
375
                case SparkPostApiClient::TYPE_UNSUBSCRIBE:
376
                    $this->extend('onUnsubscribeEvent', $data, $type);
377
                    break;
378
            }
379
        }
380
381
        $this->extend('afterProcessPayload', $payload, $batchId);
382
    }
383
384
385
    /**
386
     * Get logger
387
     *
388
     * @return Psr\SimpleCache\CacheInterface
0 ignored issues
show
Bug introduced by
The type LeKoala\SparkPost\Psr\SimpleCache\CacheInterface was not found. Did you mean Psr\SimpleCache\CacheInterface? If so, make sure to prefix the type with \.
Loading history...
389
     */
390
    public function getLogger()
391
    {
392
        return $this->logger;
393
    }
394
}
395