OutsideComm::postJson()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 19
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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