Test Setup Failed
Push — master ( 4a5cac...5c3ffb )
by Stefan
17:36
created

OutsideComm   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 397
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 59
eloc 227
c 2
b 0
f 0
dl 0
loc 397
rs 4.08

7 Methods

Rating   Name   Duplication   Size   Complexity  
C mailAddressValidSecure() 0 69 15
A downloadFile() 0 19 4
A mailHandle() 0 27 4
B sendSMS() 0 52 9
A postJson() 0 19 3
A exfiltrateValidAddresses() 0 18 5
F adminInvitationMail() 0 112 19

How to fix   Complexity   

Complex Class

Complex classes like OutsideComm 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 OutsideComm, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * *****************************************************************************
5
 * Contributions to this work were made on behalf of the GÉANT project, a 
6
 * project that has received funding from the European Union’s Framework 
7
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
8
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
9
 * 691567 (GN4-1) and No. 731122 (GN4-2).
10
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
11
 * of the copyright in all material which was developed by a member of the GÉANT
12
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
13
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
14
 * UK as a branch of GÉANT Vereniging.
15
 * 
16
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
17
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
18
 *
19
 * License: see the web/copyright.inc.php file in the file structure or
20
 *          <base_url>/copyright.php after deploying the software
21
 */
22
23
namespace core\common;
24
25
/**
26
 * This class contains a number of functions for talking to the outside world
27
 * @author Stefan Winter <[email protected]>
28
 * @author Tomasz Wolniewicz <[email protected]>
29
 *
30
 * @package Developer
31
 */
32
class OutsideComm extends Entity
33
{
34
35
    /**
36
     * downloads a file from the internet
37
     * @param string $url the URL to download
38
     * @return string|boolean the data we got back, or FALSE on failure
39
     */
40
    public static function downloadFile($url)
41
    {
42
        $loggerInstance = new \core\common\Logging();
43
        if (!preg_match("/:\/\//", $url)) {
44
            $loggerInstance->debug(3, "The specified string does not seem to be a URL!");
45
            return FALSE;
46
        }
47
        # we got a URL, download it
48
        $download = fopen($url, "rb");
49
        if ($download === FALSE) {
50
            $loggerInstance->debug(2, "Failed to open handle for $url");
51
            return FALSE;
52
        }
53
        $data = stream_get_contents($download);
54
        if ($data === FALSE) {
55
            $loggerInstance->debug(2, "Failed to download the file from $url");
56
            return FALSE;
57
        }
58
        return $data;
59
    }
60
61
    /**
62
     * create an email handle from PHPMailer for later customisation and sending
63
     * @return \PHPMailer\PHPMailer\PHPMailer
64
     */
65
    public static function mailHandle()
66
    {
67
// use PHPMailer to send the mail
68
        $mail = new \PHPMailer\PHPMailer\PHPMailer();
69
        $mail->isSMTP();
70
        $mail->Port = 587;
71
        $mail->SMTPSecure = 'tls';
72
        $mail->Host = \config\Master::MAILSETTINGS['host'];
73
        if (\config\Master::MAILSETTINGS['user'] === NULL && \config\Master::MAILSETTINGS['pass'] === NULL) {
74
            $mail->SMTPAuth = false;
75
        } else {
76
            $mail->SMTPAuth = true;
77
            $mail->Username = \config\Master::MAILSETTINGS['user'];
78
            $mail->Password = \config\Master::MAILSETTINGS['pass'];
79
        }
80
        $mail->SMTPOptions = \config\Master::MAILSETTINGS['options'];
81
// formatting nitty-gritty
82
        $mail->WordWrap = 72;
83
        $mail->isHTML(FALSE);
84
        $mail->CharSet = 'UTF-8';
85
        $configuredFrom = \config\Master::APPEARANCE['from-mail'] . "";
86
        $mail->From = $configuredFrom;
87
// are we fancy? i.e. S/MIME signing?
88
        if (isset(\config\Master::MAILSETTINGS['certfilename'], \config\Master::MAILSETTINGS['keyfilename'], \config\Master::MAILSETTINGS['keypass'])) {
89
            $mail->sign(\config\Master::MAILSETTINGS['certfilename'], \config\Master::MAILSETTINGS['keyfilename'], \config\Master::MAILSETTINGS['keypass']);
90
        }
91
        return $mail;
92
    }
93
94
    const MAILDOMAIN_INVALID = -1000;
95
    const MAILDOMAIN_NO_MX = -1001;
96
    const MAILDOMAIN_NO_HOST = -1002;
97
    const MAILDOMAIN_NO_CONNECT = -1003;
98
    const MAILDOMAIN_NO_STARTTLS = 1;
99
    const MAILDOMAIN_STARTTLS = 2;
100
101
    /**
102
     * verifies whether a mail address is in an existing and STARTTLS enabled mail domain
103
     * 
104
     * @param string $address the mail address to check
105
     * @return int status of the mail domain
106
     */
107
    public static function mailAddressValidSecure($address)
108
    {
109
        $loggerInstance = new \core\common\Logging();
110
        if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
111
            $loggerInstance->debug(4, "OutsideComm::mailAddressValidSecure: invalid mail address.");
112
            return OutsideComm::MAILDOMAIN_INVALID;
113
        }
114
        $domain = substr($address, strpos($address, '@') + 1); // everything after the @ sign
115
        // we can be sure that the @ was found (FILTER_VALIDATE_EMAIL succeeded)
116
        // but let's be explicit
117
        if ($domain === FALSE) {
118
            return OutsideComm::MAILDOMAIN_INVALID;
119
        }
120
        // does the domain have MX records?
121
        $mx = dns_get_record($domain, DNS_MX);
122
        if ($mx === FALSE) {
123
            $loggerInstance->debug(4, "OutsideComm::mailAddressValidSecure: no MX.");
124
            return OutsideComm::MAILDOMAIN_NO_MX;
125
        }
126
        $loggerInstance->debug(5, "Domain: $domain MX: " . /** @scrutinizer ignore-type */ print_r($mx, TRUE));
127
        // create a pool of A and AAAA records for all the MXes
128
        $ipAddrs = [];
129
        foreach ($mx as $onemx) {
130
            $v4list = dns_get_record($onemx['target'], DNS_A);
131
            $v6list = dns_get_record($onemx['target'], DNS_AAAA);
132
            foreach ($v4list as $oneipv4) {
133
                $ipAddrs[] = $oneipv4['ip'];
134
            }
135
            foreach ($v6list as $oneipv6) {
136
                $ipAddrs[] = "[" . $oneipv6['ipv6'] . "]";
137
            }
138
        }
139
        if (count($ipAddrs) == 0) {
140
            $loggerInstance->debug(4, "OutsideComm::mailAddressValidSecure: no mailserver hosts.");
141
            return OutsideComm::MAILDOMAIN_NO_HOST;
142
        }
143
        $loggerInstance->debug(5, "Domain: $domain Addrs: " . /** @scrutinizer ignore-type */ print_r($ipAddrs, TRUE));
144
        // connect to all hosts. If all can't connect, return MAILDOMAIN_NO_CONNECT. 
145
        // If at least one does not support STARTTLS or one of the hosts doesn't connect
146
        // , return MAILDOMAIN_NO_STARTTLS (one which we can't connect to we also
147
        // can't verify if it's doing STARTTLS, so better safe than sorry.
148
        $retval = OutsideComm::MAILDOMAIN_NO_CONNECT;
149
        $allWithStarttls = TRUE;
150
        foreach ($ipAddrs as $oneip) {
151
            $loggerInstance->debug(5, "OutsideComm::mailAddressValidSecure: connecting to $oneip.");
152
            $smtp = new \PHPMailer\PHPMailer\SMTP;
153
            if ($smtp->connect($oneip, 25)) {
154
                // host reached! so at least it's not a NO_CONNECT
155
                $loggerInstance->debug(4, "OutsideComm::mailAddressValidSecure: connected to $oneip.");
156
                $retval = OutsideComm::MAILDOMAIN_NO_STARTTLS;
157
                if ($smtp->hello('eduroam.org')) {
158
                    $extensions = $smtp->getServerExtList(); // Scrutinzer is wrong; is not always null - contains server capabilities
159
                    if (!is_array($extensions) || !array_key_exists('STARTTLS', $extensions)) {
160
                        $loggerInstance->debug(4, "OutsideComm::mailAddressValidSecure: no indication for STARTTLS.");
161
                        $allWithStarttls = FALSE;
162
                    }
163
                }
164
            } else {
165
                // no connect: then we can't claim all targets have STARTTLS
166
                $allWithStarttls = FALSE;
167
                $loggerInstance->debug(5, "OutsideComm::mailAddressValidSecure: failed $oneip.");
168
            }
169
        }
170
        // did the state $allWithStarttls survive? Then up the response to
171
        // appropriate level.
172
        if ($retval == OutsideComm::MAILDOMAIN_NO_STARTTLS && $allWithStarttls) {
173
            $retval = OutsideComm::MAILDOMAIN_STARTTLS;
174
        }
175
        return $retval;
176
    }
177
178
    const SMS_SENT = 100;
179
    const SMS_NOTSENT = 101;
180
    const SMS_FRAGEMENTSLOST = 102;
181
182
    /**
183
     * Send SMS invitations to end users
184
     * 
185
     * @param string $number  the number to send to: with country prefix, but without the + sign ("352123456" for a Luxembourg example)
186
     * @param string $content the text to send
187
     * @return integer status of the sending process
188
     * @throws \Exception
189
     */
190
    public static function sendSMS($number, $content)
191
    {
192
        $loggerInstance = new \core\common\Logging();
193
        switch (\config\ConfAssistant::SMSSETTINGS['provider']) {
194
            case 'Nexmo':
195
                // taken from https://docs.nexmo.com/messaging/sms-api
196
                $url = 'https://rest.nexmo.com/sms/json?' . http_build_query(
197
                                [
198
                                    'api_key' => \config\ConfAssistant::SMSSETTINGS['username'],
199
                                    'api_secret' => \config\ConfAssistant::SMSSETTINGS['password'],
200
                                    'to' => $number,
201
                                    'from' => \config\ConfAssistant::CONSORTIUM['name'],
202
                                    'text' => $content,
203
                                    'type' => 'unicode',
204
                                ]
205
                );
206
207
                $ch = curl_init($url);
208
                if ($ch === FALSE) {
209
                    $loggerInstance->debug(2, 'Problem with SMS invitation: unable to send API request with CURL!');
210
                    return OutsideComm::SMS_NOTSENT;
211
                }
212
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
213
                $response = curl_exec($ch);
214
                // we have set RETURNTRANSFER so anything except string means something went wrong
215
                if (!is_string($response)) {
216
                    throw new Exception("Error while sending API request with SMS: curl did not deliver a response string.");
0 ignored issues
show
Bug introduced by
The type core\common\Exception was not found. Did you mean Exception? If so, make sure to prefix the type with \.
Loading history...
217
                }
218
                $decoded_response = json_decode($response, true);
219
                $messageCount = $decoded_response['message-count'];
220
221
                if ($messageCount == 0) {
222
                    $loggerInstance->debug(2, 'Problem with SMS invitation: no message was sent!');
223
                    return OutsideComm::SMS_NOTSENT;
224
                }
225
                $loggerInstance->debug(2, 'Total of ' . $messageCount . ' messages were attempted to send.');
226
227
                $totalFailures = 0;
228
                foreach ($decoded_response['messages'] as $message) {
229
                    if ($message['status'] == 0) {
230
                        $loggerInstance->debug(2, $message['message-id'] . ": Success");
231
                    } else {
232
                        $loggerInstance->debug(2, $message['message-id'] . ": Failed (failure code = " . $message['status'] . ")");
233
                        $totalFailures++;
234
                    }
235
                }
236
                if ($messageCount == count($decoded_response['messages']) && $totalFailures == 0) {
237
                    return OutsideComm::SMS_SENT;
238
                }
239
                return OutsideComm::SMS_FRAGEMENTSLOST;
240
            default:
241
                throw new \Exception("Unknown SMS Gateway provider!");
242
        }
243
    }
244
245
    const INVITE_CONTEXTS = [
246
        0 => "CO-ADMIN",
247
        1 => "NEW-FED",
248
        2 => "EXISTING-FED",
249
    ];
250
251
    /**
252
     * 
253
     * @param string           $targets       one or more mail addresses, comma-separated
254
     * @param string           $introtext     introductory sentence (varies by situation)
255
     * @param string           $newtoken      the token to send
256
     * @param string           $idpPrettyName the name of the IdP, in best-match language
257
     * @param \core\Federation $federation    if not NULL, indicates that invitation comes from authorised fed admin of that federation
258
     * @param string           $type          the type of participant we're invited to
259
     * @return array
260
     * @throws \Exception
261
     */
262
    public static function adminInvitationMail($targets, $introtext, $newtoken, $idpPrettyName, $federation, $type)
263
    {
264
        if (!in_array($introtext, OutsideComm::INVITE_CONTEXTS)) {
265
            throw new \Exception("Unknown invite mode!");
266
        }
267
        if ($introtext == OutsideComm::INVITE_CONTEXTS[1] && $federation === NULL) { // comes from fed admin, so federation must be set
268
            throw new \Exception("Invitation from a fed admin, but we do not know the corresponding federation!");
269
        }
270
        $prettyPrintType = "";
271
        switch ($type) {
272
            case \core\IdP::TYPE_IDP:
273
                $prettyPrintType = Entity::$nomenclature_inst;
274
                break;
275
            case \core\IdP::TYPE_SP:
276
                $prettyPrintType = Entity::$nomenclature_hotspot;
277
                break;
278
            case \core\IdP::TYPE_IDPSP:
279
                $prettyPrintType = sprintf(_("%s and %s"), Entity::$nomenclature_inst, Entity::$nomenclature_hotspot);
280
                break;
281
            default:
282
                throw new \Exception("This is controlled vocabulary, impossible.");
283
        }
284
        Entity::intoThePotatoes();
285
        $mail = OutsideComm::mailHandle();
286
        new \core\CAT(); // makes sure Entity is initialised
287
        // we have a few stock intro texts on file
288
        $introTexts = [
289
            OutsideComm::INVITE_CONTEXTS[0] => sprintf(_("a %s of the %s %s \"%s\" has invited you to manage the %s together with him."), Entity::$nomenclature_fed, \config\ConfAssistant::CONSORTIUM['display_name'], Entity::$nomenclature_participant, $idpPrettyName, Entity::$nomenclature_participant),
290
            OutsideComm::INVITE_CONTEXTS[1] => sprintf(_("a %s %s has invited you to manage the future %s  \"%s\" (%s). The organisation will be a %s."), \config\ConfAssistant::CONSORTIUM['display_name'], Entity::$nomenclature_fed, Entity::$nomenclature_participant, $idpPrettyName, strtoupper($federation->tld), $prettyPrintType),
291
            OutsideComm::INVITE_CONTEXTS[2] => sprintf(_("a %s %s has invited you to manage the %s  \"%s\". This is a %s."), \config\ConfAssistant::CONSORTIUM['display_name'], Entity::$nomenclature_fed, Entity::$nomenclature_participant, $idpPrettyName, $prettyPrintType),
292
        ];
293
        $validity = sprintf(_("This invitation is valid for 24 hours from now, i.e. until %s."), strftime("%x %X %Z", time() + 86400));
294
        // need some nomenclature
295
        // are we on https?
296
        $proto = "http://";
297
        if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == "on") {
298
            $proto = "https://";
299
        }
300
        // then, send out the mail
301
        $message = _("Hello,") . "\n\n" . wordwrap($introTexts[$introtext] . " " . $validity, 72) . "\n\n";
302
        // default means we don't have a Reply-To.
303
        $replyToMessage = wordwrap(_("manually. Please do not reply to this mail; this is a send-only address."));
304
305
        if ($federation !== NULL) {
306
            // see if we are supposed to add a custom message
307
            $customtext = $federation->getAttributes('fed:custominvite');
308
            if (count($customtext) > 0) {
309
                $message .= wordwrap(sprintf(_("Additional message from your %s administrator:"), Entity::$nomenclature_fed), 72) . "\n---------------------------------" .
310
                        wordwrap($customtext[0]['value'], 72) . "\n---------------------------------\n\n";
311
            }
312
            // and add Reply-To already now
313
            foreach ($federation->listFederationAdmins() as $fedadmin_id) {
314
                $fedadmin = new \core\User($fedadmin_id);
315
                $mailaddrAttrib = $fedadmin->getAttributes("user:email");
316
                $nameAttrib = $fedadmin->getAttributes("user:realname");
317
                $name = $nameAttrib[0]['value'] ?? sprintf(_("%s administrator"), Entity::$nomenclature_fed);
318
                if (count($mailaddrAttrib) > 0) {
319
                    $mail->addReplyTo($mailaddrAttrib[0]['value'], $name);
320
                    $replyToMessage = wordwrap(sprintf(_("manually. If you reply to this mail, you will reach your %s administrators."), Entity::$nomenclature_fed), 72);
321
                }
322
            }
323
        }
324
        $productname = \config\Master::APPEARANCE['productname'];
325
        $consortium = \config\ConfAssistant::CONSORTIUM['display_name'];
326
        $message .= wordwrap(sprintf(_("To enlist as an administrator for that %s, please click on the following link:"), Entity::$nomenclature_participant), 72) . "\n\n" .
327
                $proto . $_SERVER['SERVER_NAME'] . \config\Master::PATHS['cat_base_url'] . "admin/action_enrollment.php?token=$newtoken\n\n" .
328
                wordwrap(sprintf(_("If clicking the link doesn't work, you can also go to the %s Administrator Interface at"), $productname), 72) . "\n\n" .
329
                $proto . $_SERVER['SERVER_NAME'] . \config\Master::PATHS['cat_base_url'] . "admin/\n\n" .
330
                _("and enter the invitation token") . "\n\n" .
331
                $newtoken . "\n\n$replyToMessage\n\n" .
332
                wordwrap(_("Do NOT forward the mail before the token has expired - or the recipients may be able to consume the token on your behalf!"), 72) . "\n\n" .
333
                wordwrap(sprintf(_("We wish you a lot of fun with the %s."), $productname), 72) . "\n\n" .
334
                sprintf(_("Sincerely,\n\nYour friendly folks from %s Operations"), $consortium);
335
336
337
// who to whom?
338
        $mail->FromName = \config\Master::APPEARANCE['productname'] . " Invitation System";
339
340
        if (isset(\config\Master::APPEARANCE['invitation-bcc-mail']) && \config\Master::APPEARANCE['invitation-bcc-mail'] !== NULL) {
341
            $mail->addBCC(\config\Master::APPEARANCE['invitation-bcc-mail']);
342
        }
343
344
// all addresses are wrapped in a string, but PHPMailer needs a structured list of addressees
345
// sigh... so convert as needed
346
// first split multiple into one if needed
347
        $recipients = explode(", ", $targets);
348
349
        $secStatus = TRUE;
350
        $domainStatus = TRUE;
351
352
// fill the destinations in PHPMailer API
353
        foreach ($recipients as $recipient) {
354
            $mail->addAddress($recipient);
355
            $status = OutsideComm::mailAddressValidSecure($recipient);
356
            if ($status < OutsideComm::MAILDOMAIN_STARTTLS) {
357
                $secStatus = FALSE;
358
            }
359
            if ($status < 0) {
360
                $domainStatus = FALSE;
361
            }
362
        }
363
        Entity::outOfThePotatoes();
364
        if (!$domainStatus) {
365
            return ["SENT" => FALSE, "TRANSPORT" => FALSE];
366
        }
367
368
// what do we want to say?
369
        Entity::intoThePotatoes();
370
        $mail->Subject = sprintf(_("%s: you have been invited to manage an %s"), \config\Master::APPEARANCE['productname'], Entity::$nomenclature_participant);
371
        Entity::outOfThePotatoes();
372
        $mail->Body = $message;
373
        return ["SENT" => $mail->send(), "TRANSPORT" => $secStatus];
374
    }
375
376
    /**
377
     * sends a POST with some JSON inside
378
     * 
379
     * @param string $url       the URL to POST to
380
     * @param array  $dataArray the data to be sent in PHP array representation
381
     * @return array the JSON response, decoded into PHP associative array
382
     * @throws \Exception
383
     */
384
    public static function postJson($url, $dataArray)
385
    {
386
        $loggerInstance = new Logging();
387
        $ch = \curl_init($url);
388
        if ($ch === FALSE) {
389
            $loggerInstance->debug(2, "Unable to POST JSON request: CURL init failed!");
390
            return json_decode(json_encode(FALSE), TRUE);
391
        }
392
        \curl_setopt_array($ch, array(
393
            CURLOPT_POST => TRUE,
394
            CURLOPT_RETURNTRANSFER => TRUE,
395
            CURLOPT_POSTFIELDS => json_encode($dataArray),
396
            CURLOPT_FRESH_CONNECT => TRUE,
397
        ));
398
        $response = \curl_exec($ch);
399
        if (!is_string($response)) { // With RETURNTRANSFER, TRUE is not a valid return
400
            throw new \Exception("the POST didn't work!");
401
        }
402
        return json_decode($response, TRUE);
403
    }
404
405
    /**
406
     * aborts code execution if a required mail address is invalid
407
     * 
408
     * @param mixed $newmailaddress input string, possibly one or more mail addresses
409
     * @return array mail addresses that passed validation
410
     */
411
    public static function exfiltrateValidAddresses($newmailaddress)
412
    {
413
        $validator = new \web\lib\common\InputValidation();
414
        $addressSegments = explode(",", $newmailaddress);
415
        $confirmedMails = [];
416
        if ($addressSegments === FALSE) {
417
            return [];
418
        }
419
        foreach ($addressSegments as $oneAddressCandidate) {
420
            $candidate = trim($oneAddressCandidate);
421
            if ($validator->email($candidate) !== FALSE) {
422
                $confirmedMails[] = $candidate;
423
            }
424
        }
425
        if (count($confirmedMails) == 0) {
426
            return [];
427
        }
428
        return $confirmedMails;
429
    }
430
    /**
431
     * performs an HTTP request. Currently unused, will be for external CA API calls.
432
     * 
433
     * @param string $url the URL to send the request to
434
     * @param array $postValues POST values to send
435
     * @return string the returned HTTP content
436
437
      public static function PostHttp($url, $postValues) {
438
      $options = [
439
      'http' => ['header' => 'Content-type: application/x-www-form-urlencoded\r\n', "method" => 'POST', 'content' => http_build_query($postValues)]
440
      ];
441
      $context = stream_context_create($options);
442
      return file_get_contents($url, false, $context);
443
      }
444
     * 
445
     */
446
}