Passed
Push — master ( 350bb5...eae619 )
by Thomas
02:40
created

SparkPostController   F

Complexity

Total Complexity 70

Size/Duplication

Total Lines 469
Duplicated Lines 0 %

Importance

Changes 5
Bugs 2 Features 0
Metric Value
eloc 236
dl 0
loc 469
rs 2.8
c 5
b 2
f 0
wmc 70

8 Methods

Rating   Name   Duplication   Size   Complexity  
A index() 0 5 1
B authRequest() 0 26 8
A getLogger() 0 3 1
C processPayload() 0 66 14
C incoming() 0 70 12
C sent_emails() 0 67 13
A test() 0 21 4
F configure_inbound_emails() 0 118 17

How to fix   Complexity   

Complex Class

Complex classes like SparkPostController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SparkPostController, and based on these observations, apply Extract Interface, too.

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 SilverStripe\Control\HTTPResponse;
13
use LeKoala\SparkPost\Api\SparkPostApiClient;
14
use SilverStripe\Control\HTTP;
15
use SilverStripe\ORM\ArrayList;
16
17
/**
18
 * Provide extensions points for handling the webhook
19
 *
20
 * @author LeKoala <[email protected]>
21
 */
22
class SparkPostController extends Controller
23
{
24
    /**
25
     * @var int
26
     */
27
    protected $eventsCount = 0;
28
29
    /**
30
     * @var int
31
     */
32
    protected $skipCount = 0;
33
34
    /**
35
     * @var array<string>
36
     */
37
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
38
        'incoming',
39
        'test',
40
        'configure_inbound_emails',
41
        'sent_emails',
42
    ];
43
44
    /**
45
     * Inject public dependencies into the controller
46
     *
47
     * @var array<string,string>
48
     */
49
    private static $dependencies = [
0 ignored issues
show
introduced by
The private property $dependencies is not used, and could be removed.
Loading history...
50
        'logger' => '%$Psr\Log\LoggerInterface',
51
    ];
52
53
    /**
54
     * @var \Psr\Log\LoggerInterface
55
     */
56
    public $logger;
57
58
    /**
59
     * @param HTTPRequest $req
60
     * @return HTTPResponse|string
61
     */
62
    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

62
    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...
63
    {
64
        return $this->render([
65
            'Title' => 'SparkPost',
66
            'Content' => 'Please use a dedicated action'
67
        ]);
68
    }
69
70
    public function sent_emails(HTTPRequest $req)
71
    {
72
        if (!Director::isDev() && !Permission::check('ADMIN')) {
73
            return $this->httpError(404);
74
        }
75
76
        $logFolder = SparkPostHelper::getLogFolder();
77
        $view = $req->getVar('view');
78
        $download = $req->getVar('download');
79
        $iframe = $req->getVar('iframe');
80
        $base = Director::baseFolder();
81
82
        if ($download) {
83
            $file = $logFolder . '/' . $download;
84
            if (!is_file($file) || dirname($file) != $logFolder) {
85
                return $this->httpError(404);
86
            }
87
88
            $fileData = file_get_contents($file);
89
            $fileName = $download;
90
            return HTTPRequest::send_file($fileData, $fileName);
91
        }
92
93
        if ($iframe) {
94
            $file = $logFolder . '/' . $view;
95
            if (!is_file($file) || dirname($file) != $logFolder) {
96
                return $this->httpError(404);
97
            }
98
            $content = file_get_contents($file);
99
            return $content;
100
        }
101
102
        if ($view) {
103
            $file = $logFolder . '/' . $view;
104
            if (!is_file($file) || dirname($file) != $logFolder) {
105
                return $this->httpError(404);
106
            }
107
108
            $content = file_get_contents($file);
109
            $content = str_replace($logFolder . '/', '/__sparkpost/sent_emails?download=', $content);
110
            $content = str_replace($base, '', $content);
111
112
            $customFields = [
113
                'Email' => $content,
114
                'Name' => $view,
115
            ];
116
        } else {
117
            $emails = new ArrayList();
118
            $items = glob("$logFolder/*.html", GLOB_NOSORT);
119
            usort($items, function ($a, $b) {
120
                return filemtime($b) - filemtime($a);
121
            });
122
123
            foreach ($items as $email) {
124
                $emails->push([
125
                    'File' => $email,
126
                    'Name' => basename($email),
127
                    'Date' => date('Y-m-d H:i:s', filemtime($email)),
128
                ]);
129
            }
130
131
            $customFields = [
132
                'Emails' => $emails
133
            ];
134
        }
135
136
        return $this->renderWith('LeKoala/SparkPost/SparkPostController_sent_emails', $customFields);
137
    }
138
139
    /**
140
     * You can also see /resources/sample.json
141
     *
142
     * @param HTTPRequest $req
143
     * @return string
144
     */
145
    public function test(HTTPRequest $req)
146
    {
147
        if (!Director::isDev()) {
148
            return $this->httpError(404);
149
        }
150
151
        $file = $this->getRequest()->getVar('file');
152
        if ($file) {
153
            $data = file_get_contents(Director::baseFolder() . '/' . rtrim($file, '/'));
154
        } else {
155
            $data = file_get_contents(dirname(__DIR__) . '/resources/sample.json');
156
        }
157
        if (!$data) {
158
            throw new Exception("Failed to get data");
159
        }
160
        $payload = json_decode($data, true);
161
        $payload['@headers'] = $req->getHeaders();
162
163
        $this->processPayload($payload, 'TEST');
164
165
        return 'TEST OK - ' . $this->eventsCount . ' events processed / ' . $this->skipCount . ' events skipped';
166
    }
167
168
    /**
169
     * @link https://support.sparkpost.com/customer/portal/articles/2039614-enabling-inbound-email-relaying-relay-webhooks
170
     * @param HTTPRequest $req
171
     * @return string
172
     */
173
    public function configure_inbound_emails(HTTPRequest $req)
174
    {
175
        if (!Director::isDev() && !Permission::check('ADMIN')) {
176
            return 'You must be in dev mode or be logged as an admin';
177
        }
178
179
        $clearExisting = $req->getVar('clear_existing');
180
        $clearWebhooks = $req->getVar('clear_webhooks');
181
        $clearInbound = $req->getVar('clear_inbound');
182
        if ($clearExisting) {
183
            echo '<strong>Existing inbounddomains and relay webhooks will be cleared</strong><br/>';
184
        } else {
185
            echo 'You can clear existing inbound domains and relay webhooks by passing ?clear_existing=1&clear_webhooks=1&clear_inbound=1<br/>';
186
        }
187
188
        $client = SparkPostHelper::getMasterClient();
189
190
        $inbound_domain = Environment::getEnv('SPARKPOST_INBOUND_DOMAIN');
191
        if (!$inbound_domain) {
192
            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...
193
        }
194
195
        /*
196
         *  "name": "Replies Webhook",
197
         *  "target": "https://webhooks.customer.example/replies",
198
         * "auth_token": "5ebe2294ecd0e0f08eab7690d2a6ee69",
199
         *  "match": {
200
         *  "protocol": "SMTP",
201
         * "domain": "email.example.com"
202
         */
203
204
        $listWebhooks = $client->listRelayWebhooks();
205
        $listInboundDomains = $client->listInboundDomains();
206
207
        if ($clearExisting) {
208
            // we need to delete relay webhooks first!
209
            if ($clearWebhooks) {
210
                foreach ($listWebhooks as $wh) {
211
                    $client->deleteRelayWebhook($wh['id']);
212
                    echo 'Delete relay webhook ' . $wh['id'] . '<br/>';
213
                }
214
            }
215
            if ($clearInbound) {
216
                foreach ($listInboundDomains as $id) {
217
                    $client->deleteInboundDomain($id['domain']);
218
                    echo 'Delete domain ' . $id['domain'] . '<br/>';
219
                }
220
            }
221
222
            $listWebhooks = $client->listRelayWebhooks();
223
            $listInboundDomains = $client->listInboundDomains();
224
        }
225
226
        echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
227
        echo 'List Inbounds Domains:<br/>';
228
        print_r($listInboundDomains);
229
        echo '</pre>';
230
231
        $found = false;
232
233
        foreach ($listInboundDomains as $id) {
234
            if ($id['domain'] == $inbound_domain) {
235
                $found = true;
236
            }
237
        }
238
239
        if (!$found) {
240
            echo "Domain is not found, we create it<br/>";
241
242
            // This is the domain that users will send email to.
243
            $result = $client->createInboundDomain($inbound_domain);
244
245
            echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
246
            echo 'Create Inbound Domain:<br/>';
247
            print_r($result);
248
            echo '</pre>';
249
        } else {
250
            echo "Domain is already configured<br/>";
251
        }
252
253
        // Now that you have your InboundDomain set up, you can create your Relay Webhook by sending a POST request to
254
        // https://api.sparkpost.com/api/v1/relay-webhooks. This step links your consumer with the Inbound Domain.
255
256
        echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
257
        echo 'List Webhooks:<br/>';
258
        print_r($listWebhooks);
259
        echo '</pre>';
260
261
        $found = false;
262
263
        foreach ($listWebhooks as $wh) {
264
            if ($wh['match']['domain'] == $inbound_domain) {
265
                $found = true;
266
            }
267
        }
268
269
        if (!$found) {
270
            //  The match.domain property should be the same as the Inbound Domain you set up in the previous step
271
            $webhookResult = $client->createRelayWebhook([
272
                'name' => 'Inbound Webhook',
273
                'target' => Director::absoluteURL('sparkpost/incoming'),
274
                'match' => [
275
                    'domain' => $inbound_domain
276
                ]
277
            ]);
278
279
            echo '<pre>' . __FILE__ . ':' . __LINE__ . '<br/>';
280
            echo 'Webhook result:<br/>';
281
            print_r($webhookResult);
282
            echo '</pre>';
283
284
            if ($webhookResult['id']) {
285
                echo "New webhook created with id " . $webhookResult['id'];
286
            }
287
        } else {
288
            echo "Webhook already configured";
289
        }
290
        return '';
291
    }
292
293
    /**
294
     * Handle incoming webhook
295
     *
296
     * @link https://developers.sparkpost.com/api/#/reference/webhooks/create-a-webhook
297
     * @link https://www.sparkpost.com/blog/webhooks-beyond-the-basics/
298
     * @link https://support.sparkpost.com/customer/portal/articles/1976204-webhook-event-reference
299
     * @param HTTPRequest $req
300
     * @return HTTPResponse
301
     */
302
    public function incoming(HTTPRequest $req)
303
    {
304
        // Each webhook batch contains the header X-Messagesystems-Batch-Id,
305
        // which is useful for auditing and prevention of processing duplicate batches.
306
        $batchId = $req->getHeader('X-Messagesystems-Batch-Id');
307
        if (!$batchId) {
308
            $batchId = uniqid();
309
        }
310
311
        $json = file_get_contents('php://input');
312
313
        // By default, return a valid response
314
        $response = $this->getResponse();
315
        $response->setStatusCode(200);
316
        $response->setBody('NO DATA');
317
318
        if (!$json) {
319
            return $response;
320
        }
321
322
        $payload = json_decode($json, true);
323
324
        // Check credentials if defined
325
        $isAuthenticated = true;
326
        $authError = null;
327
        if (SparkPostHelper::getWebhookUsername()) {
328
            try {
329
                $this->authRequest($req);
330
            } catch (Exception $e) {
331
                $isAuthenticated = false;
332
                $authError = $e->getMessage();
333
            }
334
        }
335
336
        $webhookLogDir = Environment::getEnv('SPARKPOST_WEBHOOK_LOG_DIR');
337
        if ($webhookLogDir) {
338
            $dir = rtrim(Director::baseFolder(), '/') . '/' . rtrim($webhookLogDir, '/');
339
340
            if (!is_dir($dir) && Director::isDev()) {
341
                mkdir($dir, 0755, true);
342
            }
343
344
            if (is_dir($dir)) {
345
                $storedPayload = array_merge([], $payload);
346
                $storedPayload['@headers'] = $req->getHeaders();
347
                $storedPayload['@isAuthenticated'] = $isAuthenticated;
348
                $storedPayload['@authError'] = $authError;
349
                $prettyPayload = json_encode($storedPayload, JSON_PRETTY_PRINT);
350
                $time = date('Ymd-His');
351
                file_put_contents($dir . '/' . $time . '_' . $batchId . '.json', $prettyPayload);
352
            } else {
353
                $this->getLogger()->debug("Directory $dir does not exist");
354
            }
355
        }
356
357
        if (!$isAuthenticated) {
358
            return $response;
359
        }
360
361
        try {
362
            $this->processPayload($payload, $batchId);
363
        } catch (Exception $ex) {
364
            // Maybe processing payload will create exceptions, but we
365
            // catch them to send a proper response to the API
366
            $logLevel = self::config()->log_level ? self::config()->log_level : 7;
367
            $this->getLogger()->log($ex->getMessage(), $logLevel);
368
        }
369
370
        $response->setBody('OK');
371
        return $response;
372
    }
373
374
    /**
375
     * @param HTTPRequest $req
376
     * @return void
377
     */
378
    protected function authRequest(HTTPRequest $req)
379
    {
380
        $requestUser = $req->getHeader('php_auth_user');
381
        $requestPassword = $req->getHeader('php_auth_pw');
382
        if (!$requestUser) {
383
            $requestUser = $_SERVER['PHP_AUTH_USER'];
384
        }
385
        if (!$requestPassword) {
386
            $requestPassword = $_SERVER['PHP_AUTH_PW'];
387
        }
388
389
        $authError = null;
390
        $hasSuppliedCredentials = $requestUser && $requestPassword;
391
        if ($hasSuppliedCredentials) {
392
            $user = SparkPostHelper::getWebhookUsername();
393
            $password = SparkPostHelper::getWebhookPassword();
394
            if ($user != $requestUser) {
395
                $authError = "User $requestUser doesn't match";
396
            } elseif ($password != $requestPassword) {
397
                $authError = "Password $requestPassword don't match";
398
            }
399
        } else {
400
            $authError = "No credentials";
401
        }
402
        if ($authError) {
403
            throw new Exception($authError);
404
        }
405
    }
406
407
    /**
408
     * Process data
409
     *
410
     * @param array<mixed> $payload
411
     * @param string $batchId
412
     * @return void
413
     */
414
    protected function processPayload(array $payload, $batchId = null)
415
    {
416
        $this->extend('beforeProcessPayload', $payload, $batchId);
417
418
        $subaccount = SparkPostHelper::getClient()->getSubaccount();
419
420
        foreach ($payload as $idx => $r) {
421
            // This is a test payload
422
            if (empty($r)) {
423
                continue;
424
            }
425
            // This is a custom entry
426
            if (!is_numeric($idx)) {
427
                continue;
428
            }
429
430
            $ev = $r['msys'] ?? null;
431
432
            // Invalid payload: it should always be an object with a msys key containing the event
433
            if ($ev === null) {
434
                $this->getLogger()->warning("Invalid payload: " . substr((string)json_encode($r), 0, 100) . '...');
435
                continue;
436
            }
437
438
            // Check type: it should be an object with the type as key
439
            $type = key($ev);
440
            if (!isset($ev[$type])) {
441
                $this->getLogger()->warning("Invalid type $type in SparkPost payload");
442
                continue;
443
            }
444
            $data = $ev[$type];
445
446
            // Ignore events not related to the subaccount we are managing
447
            if (!empty($data['subaccount_id']) && $subaccount && $subaccount != $data['subaccount_id']) {
448
                $this->skipCount++;
449
                continue;
450
            }
451
452
            $this->eventsCount++;
453
            $this->extend('onAnyEvent', $data, $type);
454
455
            switch ($type) {
456
                    //Click, Open
457
                case SparkPostApiClient::TYPE_ENGAGEMENT:
458
                    $this->extend('onEngagementEvent', $data, $type);
459
                    break;
460
                    //Generation Failure, Generation Rejection
461
                case SparkPostApiClient::TYPE_GENERATION:
462
                    $this->extend('onGenerationEvent', $data, $type);
463
                    break;
464
                    //Bounce, Delivery, Injection, SMS Status, Spam Complaint, Out of Band, Policy Rejection, Delay
465
                case SparkPostApiClient::TYPE_MESSAGE:
466
                    $this->extend('onMessageEvent', $data, $type);
467
                    break;
468
                    //Relay Injection, Relay Rejection, Relay Delivery, Relay Temporary Failure, Relay Permanent Failure
469
                case SparkPostApiClient::TYPE_RELAY:
470
                    $this->extend('onRelayEvent', $data, $type);
471
                    break;
472
                    //List Unsubscribe, Link Unsubscribe
473
                case SparkPostApiClient::TYPE_UNSUBSCRIBE:
474
                    $this->extend('onUnsubscribeEvent', $data, $type);
475
                    break;
476
            }
477
        }
478
479
        $this->extend('afterProcessPayload', $payload, $batchId);
480
    }
481
482
483
    /**
484
     * Get logger
485
     *
486
     * @return \Psr\Log\LoggerInterface
487
     */
488
    public function getLogger()
489
    {
490
        return $this->logger;
491
    }
492
}
493