Passed
Push — master ( 17bffe...10bee5 )
by Stefan
06:13
created

OptionParser   F

Complexity

Total Complexity 73

Size/Duplication

Total Lines 442
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 73
dl 0
loc 442
rs 2.459
c 0
b 0
f 0

9 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
C postProcessValidAttributes() 0 49 12
D checkUploadSanity() 0 27 9
C sendOptionsToDatabase() 0 32 11
A collateOptionArrays() 0 9 1
A postProcessCoordinates() 0 9 3
B processSubmittedFields() 0 61 4
B displaySummaryInUI() 0 23 5
D sanitiseInputs() 0 110 27

How to fix   Complexity   

Complex Class

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

1
<?php
2
3
/*
4
 * ******************************************************************************
5
 * Copyright 2011-2017 DANTE Ltd. and GÉANT on behalf of the GN3, GN3+, GN4-1 
6
 * and GN4-2 consortia
7
 *
8
 * License: see the web/copyright.php file in the file structure
9
 * ******************************************************************************
10
 */
11
12
namespace web\lib\admin;
13
use Exception;
14
15
?>
16
<?php
17
18
require_once(dirname(dirname(dirname(dirname(__FILE__)))) . "/config/_config.php");
19
20
/**
21
 * This class parses HTML field input from POST and FILES and extracts valid and authorized options to be set.
22
 * 
23
 * @author Stefan Winter <[email protected]>
24
 */
25
class OptionParser {
26
27
    /**
28
     * an instance of the InputValidation class which we use heavily for syntax checks.
29
     * 
30
     * @var \web\lib\common\InputValidation
31
     */
32
    private $validator;
33
34
    /**
35
     * an instance of the UIElements() class to draw some UI widgets from.
36
     * 
37
     * @var UIElements
38
     */
39
    private $uiElements;
40
41
    /**
42
     * a handle for the Options singleton
43
     * 
44
     * @var \core\Options
45
     */
46
    private $optioninfoObject;
47
48
    /**
49
     * initialises the various handles.
50
     */
51
    public function __construct() {
52
        $this->validator = new \web\lib\common\InputValidation();
53
        $this->uiElements = new UIElements();
54
        $this->optioninfoObject = \core\Options::instance();
55
    }
56
57
    /**
58
     * Verifies whether an incoming upload was actually valid data
59
     * 
60
     * @param string $optiontype for which option was the data uploaded
61
     * @param string $incomingBinary the uploaded data
62
     * @return boolean whether the data was valid
63
     */
64
    private function checkUploadSanity(string $optiontype, string $incomingBinary) {
65
        switch ($optiontype) {
66
            case "general:logo_file":
67
            case "fed:logo_file":
68
            case "internal:logo_from_url":
69
                // we check logo_file with ImageMagick
70
                return $this->validator->image($incomingBinary);
71
            case "eap:ca_file":
72
                // echo "Checking $optiontype with file $filename";
73
                $func = new \core\common\X509;
74
                $cert = $func->processCertificate($incomingBinary);
75
                if (is_array($cert)) { // could also be FALSE if it was incorrect incoming data
76
                    return TRUE;
77
                }
78
                // the certificate seems broken
79
                return FALSE;
80
            case "support:info_file":
81
                $info = new \finfo();
0 ignored issues
show
Bug introduced by
The call to finfo::finfo() has too few arguments starting with options. ( Ignorable by Annotation )

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

81
                $info = /** @scrutinizer ignore-call */ new \finfo();

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
82
                $filetype = $info->buffer($incomingBinary, FILEINFO_MIME_TYPE);
83
84
                // we only take plain text files in UTF-8!
85
                if ($filetype == "text/plain" && iconv("UTF-8", "UTF-8", $incomingBinary) !== FALSE) {
86
                    return TRUE;
87
                }
88
                return FALSE;
89
            default:
90
                return FALSE;
91
        }
92
    }
93
94
    /**
95
     * Known-good options are sometimes converted, this function takes care of that.
96
     * 
97
     * Cases in point:
98
     * - CA import by URL reference: fetch cert from URL and store it as CA file instead
99
     * - Logo import by URL reference: fetch logo from URL and store it as logo file instead
100
     * - CA file: mangle the content so that *only* the valid content remains (raw input may contain line breaks or spaces which are valid, but some supplicants choke upon)
101
     * 
102
     * @param array $options the list of options we got
103
     * @param array $good by-reference: the future list of actually imported options
104
     * @param array $bad by-reference: the future list of submitted but rejected options
105
     * @return array the options, post-processed
106
     */
107
    private function postProcessValidAttributes(array $options, array &$good, array &$bad) {
108
        foreach ($options as $index => $iterateOption) {
109
            foreach ($iterateOption as $name => $optionPayload) {
110
                switch ($name) {
111
                    case "eap:ca_url": // eap:ca_url becomes eap:ca_file by downloading the file
112
                        $finalOptionname = "eap:ca_file";
113
                    // intentional fall-through, treatment identical to logo_url
114
                    case "general:logo_url": // logo URLs become logo files by downloading the file
115
                        $finalOptionname = $finalOptionname ?? "general:logo_file";
116
                        if (empty($optionPayload['content'])) {
117
                            break;
118
                        }
119
                        $bindata = \core\common\OutsideComm::downloadFile($optionPayload['content']);
120
                        unset($options[$index]);
121
                        if ($bindata === FALSE) {
122
                            $bad[] = $name;
123
                            break;
124
                        }
125
                        if ($this->checkUploadSanity($finalOptionname, $bindata)) {
126
                            $good[] = $name;
127
                            $options[] = [$finalOptionname => ['lang' => NULL, 'content' => base64_encode($bindata)]];
128
                        } else {
129
                            $bad[] = $name;
130
                        }
131
                        break;
132
                    case "eap:ca_file": // CA files get split (PEM files can contain more than one CA cert)
133
                        // the data being processed here is always "good": 
134
                        // if it was eap:ca_file initially then its sanity was checked in step 1;
135
                        // if it was eap:ca_url then it was checked after we downloaded it
136
                        if (empty($optionPayload['content']) || preg_match('/^ROWID-/', $optionPayload['content'])) {
137
                            break;
138
                        }
139
                        $content = base64_decode($optionPayload['content']);
140
                        unset($options[$index]);
141
                        $x509 = new \core\common\X509();
142
                        $cAFiles = $x509->splitCertificate($content);
143
                        foreach ($cAFiles as $cAFile) {
144
                            $options[] = ["eap:ca_file" => ['lang' => NULL, 'content' => base64_encode($x509->pem2der($cAFile))]];
145
                        }
146
                        $good[] = $name;
147
                        break;
148
                    default:
149
                        $good[] = $name; // all other options were checked and are sane in step 1 already
150
                        break;
151
                }
152
            }
153
        }
154
155
        return $options;
156
    }
157
158
    /**
159
     * extracts a coordinate pair from _POST (if any) and returns it in our 
160
     * standard attribute notation
161
     * 
162
     * @param array $postArray
163
     * @param array $good
164
     * @return array
165
     */
166
    private function postProcessCoordinates(array $postArray, array &$good) {
167
        if (!empty($postArray['geo_long']) && !empty($postArray['geo_lat'])) {
168
169
            $lat = $this->validator->coordinate($postArray['geo_lat']);
170
            $lon = $this->validator->coordinate($postArray['geo_long']);
171
            $good[] = ("general:geo_coordinates");
172
            return [0 => ["general:geo_coordinates" => ['lang' => NULL, 'content' => json_encode(["lon" => $lon, "lat" => $lat])]]];
173
        }
174
        return [];
175
    }
176
177
    /**
178
     * creates HTML code for a user-readable summary of the imports
179
     * @param array $good list of actually imported options
180
     * @param array $bad list of submitted but rejected options
181
     * @param array $mlAttribsWithC list of language-variant options
182
     * @return string HTML code
183
     */
184
    private function displaySummaryInUI(array $good, array $bad, array $mlAttribsWithC) {
185
        $retval = "";
186
        // don't do your own table - only the <tr>s here
187
        // list all attributes that were set correctly
188
        $listGood = array_count_values($good);
189
        $uiElements = new UIElements();
190
        foreach ($listGood as $name => $count) {
191
            /// number of times attribute is present, and its name
192
            /// Example: "5x Support E-Mail"
193
            $retval .= $this->uiElements->boxOkay(sprintf(_("%dx %s"), $count, $uiElements->displayName($name)));
194
        }
195
        // list all atributes that had errors
196
        $listBad = array_count_values($bad);
197
        foreach ($listBad as $name => $count) {
198
            $retval .= $this->uiElements->boxError(sprintf(_("%dx %s"), (int) $count, $uiElements->displayName($name)));
199
        }
200
        // list multilang without default
201
        foreach ($mlAttribsWithC as $attribName => $isitsetornot) {
202
            if ($isitsetornot == FALSE) {
203
                $retval .= $this->uiElements->boxWarning(sprintf(_("You did not set a 'default language' value for %s. This means we can only display this string for installers which are <strong>exactly</strong> in the language you configured. For the sake of all other languages, you may want to edit the profile again and populate the 'default/other' language field."), $uiElements->displayName($attribName)));
204
            }
205
        }
206
        return $retval;
207
    }
208
209
    /**
210
     * Incoming data is in $_POST and possibly in $_FILES. Collate values into 
211
     * one array according to our name and numbering scheme.
212
     * 
213
     * @param array $postArray _POST
214
     * @param array $filesArray _FILES
215
     * @return array
216
     */
217
    private function collateOptionArrays(array $postArray, array $filesArray) {
218
219
        $optionarray = $postArray['option'] ?? [];
220
        $valuearray = $postArray['value'] ?? [];
221
        $filesarray = $filesArray['value']['tmp_name'] ?? [];
222
223
        $iterator = array_merge($optionarray, $valuearray, $filesarray);
224
225
        return $iterator;
226
    }
227
228
    /**
229
     * The very end of the processing: clean input data gets sent to the database
230
     * for storage
231
     * 
232
     * @param mixed $object for which object are the options
233
     * @param array $options the options to store
234
     * @param array $pendingattributes list of attributes which are already stored but may need to be deleted
235
     * @param string $device when the $object is Profile, this indicates device-specific attributes
236
     * @param int $eaptype when the $object is Profile, this indicates eap-specific attributes
237
     * @return array list of attributes which were previously stored but are to be deleted now
238
     * @throws Exception
239
     */
240
    private function sendOptionsToDatabase($object, array $options, array $pendingattributes, string $device = NULL, int $eaptype = NULL) {
241
        $retval = [];
242
        foreach ($options as $iterateOption) {
243
            foreach ($iterateOption as $name => $optionPayload) {
244
                $optiontype = $this->optioninfoObject->optionType($name);
245
                // some attributes are in the DB and were only called by reference
246
                // keep those which are still referenced, throw the rest away
247
                if ($optiontype["type"] == "file" && preg_match("/^ROWID-.*-([0-9]+)/", $optionPayload['content'], $retval)) {
248
                    unset($pendingattributes[$retval[1]]);
249
                    continue;
250
                }
251
                switch (get_class($object)) {
252
                    case 'core\\ProfileRADIUS':
253
                        if ($device !== NULL) {
254
                            $object->addAttributeDeviceSpecific($name, $optionPayload['lang'], $optionPayload['content'], $device);
255
                        } elseif ($eaptype !== NULL) {
256
                            $object->addAttributeEAPSpecific($name, $optionPayload['lang'], $optionPayload['content'], $eaptype);
257
                        } else {
258
                            $object->addAttribute($name, $optionPayload['lang'], $optionPayload['content']);
259
                        }
260
                        break;
261
                    case 'core\\IdP':
262
                    case 'core\\User':
263
                    case 'core\\Federation':
264
                        $object->addAttribute($name, $optionPayload['lang'], $optionPayload['content']);
265
                        break;
266
                    default:
267
                        throw new Exception("This type of object can't have options that are parsed by this file!");
268
                }
269
            }
270
        }
271
        return $pendingattributes;
272
    }
273
274
    /**
275
     * filters the input to find syntactically correctly submitted attributes
276
     * 
277
     * @param array $listOfEntries list of POST and FILES entries
278
     * @param array $multilangAttrsWithC by-reference: future list of language-variant options and their "default lang" state
279
     * @param array $bad by-reference: future list of submitted but rejected options
280
     * @return array sanitised list of options
281
     * @throws Exception
282
     */
283
    private function sanitiseInputs(array $listOfEntries, array &$multilangAttrsWithC, array &$bad) {
284
        $retval = [];
285
        foreach ($listOfEntries as $objId => $objValueRaw) {
286
// pick those without dash - they indicate a new value        
287
            if (preg_match('/^S[0123456789]*$/', $objId)) {
288
                $objValue = $this->validator->optionName(preg_replace('/#.*$/', '', $objValueRaw));
289
                $optioninfo = $this->optioninfoObject->optionType($objValue);
290
                $lang = NULL;
291
                if ($optioninfo["flag"] == "ML") {
292
                    if (isset($listOfEntries["$objId-lang"])) {
293
                        if (!isset($multilangAttrsWithC[$objValue])) { // on first sight, initialise the attribute as "no C language set"
294
                            $multilangAttrsWithC[$objValue] = FALSE;
295
                        }
296
                        $lang = $listOfEntries["$objId-lang"];
297
                        if ($lang == "") { // user forgot to select a language
298
                            $lang = "C";
299
                        }
300
                    } else {
301
                        $bad[] = $objValue;
302
                        continue;
303
                    }
304
                    // did we get a C language? set corresponding value to TRUE
305
                    if ($lang == "C") {
306
                        $multilangAttrsWithC[$objValue] = TRUE;
307
                    }
308
                }
309
310
                // many of the cases below condense due to identical treatment
311
                // except validator function to call and where in POST the
312
                // content is
313
                $validators = [
314
                    "text" => ["function" => "string", "field" => 1, "extraarg" => [TRUE]],
315
                    "coordinates" => ["function" => "coordJsonEncoded", "field" => 1, "extraarg" => []],
316
                    "boolean" => ["function" => "boolean", "field" => 3, "extraarg" => []],
317
                    "integer" => ["function" => "integer", "field" => 4, "extraarg" => []],
318
                ];
319
320
                switch ($optioninfo["type"]) {
321
                    case "text":
322
                    case "coordinates":
323
                    case "integer":
324
                        $varName = "$objId-" . $validators[$optioninfo['type']]['field'];
325
                        if (!empty($listOfEntries[$varName])) {
326
                            $content = call_user_func_array([$this->validator, $validators[$optioninfo['type']]['function']], array_merge([$listOfEntries[$varName]], $validators[$optioninfo['type']]['extraarg']));
327
                            break;
328
                        }
329
                        continue 2;
330
                    case "boolean":
331
                        $varName = "$objId-3";
332
                        if (!empty($listOfEntries[$varName])) {
333
                            $contentValid = $this->validator->boolean($listOfEntries[$varName]);
334
                            if ($contentValid) {
335
                                $content = "on";
336
                            } else {
337
                                $bad[] = $objValue;
338
                                continue 2;
339
                            }
340
                            break;
341
                        }
342
                        continue 2;
343
                    case "string":
344
                        if (!empty($listOfEntries["$objId-0"])) {
345
                            switch ($objValue) {
346
                                case "media:consortium_OI":
347
                                    $content = $this->validator->consortiumOI($listOfEntries["$objId-0"]);
348
                                    if ($content === FALSE) {
349
                                        $bad[] = $objValue;
350
                                        continue 3;
351
                                    }
352
                                    break;
353
                                case "media:remove_SSID":
354
                                    $content = $this->validator->string($listOfEntries["$objId-0"]);
355
                                    if ($content == "eduroam") {
356
                                        $bad[] = $objValue;
357
                                        continue 3;
358
                                    }
359
                                    break;
360
                                default:
361
                                    $content = $this->validator->string($listOfEntries["$objId-0"]);
362
                                    break;
363
                            }
364
                            break;
365
                        }
366
                        continue 2;
367
                    case "file":
368
// echo "In file processing ...<br/>";
369
                        if (!empty($listOfEntries["$objId-1"])) { // was already in, by ROWID reference, extract
370
                            // ROWID means it's a multi-line string (simple strings are inline in the form; so allow whitespace)
371
                            $content = $this->validator->string(urldecode($listOfEntries["$objId-1"]), TRUE);
372
                            break;
373
                        } else if (isset($listOfEntries["$objId-2"]) && ($listOfEntries["$objId-2"] != "")) { // let's do the download
374
// echo "Trying to download file:///".$a["$obj_id-2"]."<br/>";
375
                            $rawContent = \core\common\OutsideComm::downloadFile("file:///" . $listOfEntries["$objId-2"]);
376
                            
377
                            if ($rawContent === FALSE || !$this->checkUploadSanity($objValue, $rawContent)) {
378
                                $bad[] = $objValue;
379
                                continue 2;
380
                            }
381
                            $content = base64_encode($rawContent);
382
                            break;
383
                        }
384
                        continue 2;
385
                    default:
386
                        throw new Exception("Internal Error: Unknown option type " . $objValue . "!");
387
                }
388
                // lang can be NULL here, if it's not a multilang attribute, or a ROWID reference. Never mind that.
389
                $retval[] = ["$objValue" => ["lang" => $lang, "content" => $content]];
390
            }
391
        }
392
        return $retval;
393
    }
394
395
    /**
396
     * The main function: takes all HTML field inputs, makes sense of them and stores valid data in the database
397
     * 
398
     * @param mixed $object The object for which attributes were submitted
399
     * @param array $postArray incoming attribute names and values as submitted with $_POST
400
     * @param array $filesArray incoming attribute names and values as submitted with $_FILES
401
     * @param int $eaptype for eap-specific attributes (only used where $object is a ProfileRADIUS instance)
402
     * @param string $device for device-specific attributes (only used where $object is a ProfileRADIUS instance)
403
     * @return string text to be displayed in UI with the summary of attributes added
404
     * @throws Exception
405
     */
406
    public function processSubmittedFields($object, array $postArray, array $filesArray, int $eaptype = NULL, string $device = NULL) {
407
408
        // construct new array with all non-empty options for later feeding into DB
409
        // $multilangAttrsWithC is a helper array to keep track of multilang 
410
        // options that were set in a specific language but are not 
411
        // accompanied by a "default" language setting
412
        // if there are some without C by the end of processing, we need to warn
413
        // the admin that this attribute is "invisible" in certain languages
414
        // attrib_name -> boolean
415
416
        $multilangAttrsWithC = [];
417
418
        // these two variables store which attributes were processed 
419
        // successfully vs. which were discarded because in some way malformed
420
421
        $good = [];
422
        $bad = [];
423
424
        // Step 1: collate option names, option values and uploaded files (by 
425
        // filename reference) into one array for later handling
426
427
        $iterator = $this->collateOptionArrays($postArray, $filesArray);
428
429
        // Step 2: sieve out malformed input
430
431
        $cleanData = $this->sanitiseInputs($iterator, $multilangAttrsWithC, $bad);
432
433
        // Step 3: now we have clean input data. Some attributes need special care:
434
        // URL-based attributes need to be downloaded to get their actual content
435
        // CA files may need to be split (PEM can contain multiple CAs 
436
437
        $optionsStep2 = $this->postProcessValidAttributes($cleanData, $good, $bad);
438
439
        // Step 4: coordinates do not follow the usual POST array as they are 
440
        // two values forming one attribute; extract those two as an extra step
441
442
        $options = array_merge($optionsStep2, $this->postProcessCoordinates($postArray, $good));
443
444
        // Step 5: push all the received options to the database. Keep mind of 
445
        // the list of existing database entries that are to be deleted.
446
        // 5a: first deletion step: purge all old content except file-based attributes;
447
        //     then take note of which file-based attributes are now stale
448
        if ($device === NULL && $eaptype === NULL) {
449
            $remaining = $object->beginflushAttributes();
450
            $killlist = $this->sendOptionsToDatabase($object, $options, $remaining);
451
        } elseif ($device !== NULL) {
452
            $remaining = $object->beginFlushMethodLevelAttributes(0, $device);
453
            $killlist = $this->sendOptionsToDatabase($object, $options, $remaining, $device);
454
        } else {
455
            $remaining = $object->beginFlushMethodLevelAttributes($eaptype, "");
456
            $killlist = $this->sendOptionsToDatabase($object, $options, $remaining, "", $eaptype);
457
        }
458
        // 5b: finally, kill the stale file-based attributes which are not wanted any more.
459
        $object->commitFlushAttributes($killlist);
460
461
        // finally: return HTML code that gives feedback about what we did. 
462
        // In some cases, callers won't actually want to display it; so simply
463
        // do not echo the return value. Reasons not to do this is if we working
464
        // e.g. from inside an overlay
465
466
        return $this->displaySummaryInUI($good, $bad, $multilangAttrsWithC);
467
    }
468
469
}
470