IncidentsTable::createIncidentFromBugReport()   C
last analyzed

Complexity

Conditions 13
Paths 13

Size

Total Lines 84
Code Lines 55

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 13.169

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 13
eloc 55
c 4
b 0
f 0
nc 13
nop 1
dl 0
loc 84
ccs 45
cts 50
cp 0.9
crap 13.169
rs 6.6166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * An incident a representing a single incident of a submited bug.
5
 *
6
 * phpMyAdmin Error reporting server
7
 * Copyright (c) phpMyAdmin project (https://www.phpmyadmin.net/)
8
 *
9
 * Licensed under The MIT License
10
 * For full copyright and license information, please see the LICENSE.txt
11
 * Redistributions of files must retain the above copyright notice.
12
 *
13
 * @copyright Copyright (c) phpMyAdmin project (https://www.phpmyadmin.net/)
14
 * @license   https://opensource.org/licenses/mit-license.php MIT License
15
 *
16
 * @see      https://www.phpmyadmin.net/
17
 */
18
19
namespace App\Model\Table;
20
21
use Cake\Log\Log;
22
use Cake\Model\Model;
23
use Cake\ORM\Entity;
24
use Cake\ORM\Table;
25
use Cake\ORM\TableRegistry;
26
27
use function array_merge;
28
use function array_push;
29
use function array_slice;
30
use function count;
31
use function date;
32
use function hash_final;
33
use function hash_init;
34
use function hash_update;
35
use function in_array;
36
use function json_encode;
37
use function mb_strimwidth;
38
use function mb_strlen;
39
use function preg_match;
40
use function strtotime;
41
use function time;
42
43
/**
44
 * An incident a representing a single incident of a submited bug.
45
 */
46
class IncidentsTable extends Table
47
{
48
    /**
49
     * @var array
50
     *
51
     * @see http://book.cakephp.org/2.0/en/models/behaviors.html#using-behaviors
52
     * @see Model::$actsAs
53
     */
54
    public $actsAs = ['Summarizable'];
55
56
    /**
57
     * @var array
58
     *
59
     * @see http://book.cakephp.org/2.0/en/models/model-attributes.html#validate
60
     * @see http://book.cakephp.org/2.0/en/models/data-validation.html
61
     * @see Model::$validate
62
     */
63
    public $validate = [
64
        'pma_version' => [
65
            'rule' => 'notEmpty',
66
            'required' => true,
67
        ],
68
        'php_version' => [
69
            'rule' => 'notEmpty',
70
            'required' => true,
71
        ],
72
        'full_report' => [
73
            'rule' => 'notEmpty',
74
            'required' => true,
75
        ],
76
        'stacktrace' => [
77
            'rule' => 'notEmpty',
78
            'required' => true,
79
        ],
80
        'browser' => [
81
            'rule' => 'notEmpty',
82
            'required' => true,
83
        ],
84
        'stackhash' => [
85
            'rule' => 'notEmpty',
86
            'required' => true,
87
        ],
88
        'user_os' => [
89
            'rule' => 'notEmpty',
90
            'required' => true,
91
        ],
92
        'locale' => [
93
            'rule' => 'notEmpty',
94
            'required' => true,
95
        ],
96
        'script_name' => [
97
            'rule' => 'notEmpty',
98
            'required' => true,
99
        ],
100
        'server_software' => [
101
            'rule' => 'notEmpty',
102
            'required' => true,
103
        ],
104
        'configuration_storage' => [
105
            'rule' => 'notEmpty',
106
            'required' => true,
107
        ],
108
    ];
109
110
    /**
111
     * @var array
112
     *
113
     * @see http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#belongsto
114
     * @see Model::$belongsTo
115
     */
116
117
    /**
118
     * The fields which are summarized in the report page with charts and are also
119
     * used in the overall stats and charts for the website.
120
     *
121
     * @var array
122
     */
123
    public $summarizableFields = [
124
        'browser',
125
        'pma_version',
126
        'php_version',
127
        'locale',
128
        'server_software',
129
        'user_os',
130
        'script_name',
131
        'configuration_storage',
132
    ];
133
134
    /** @var array[] */
135
    public $filterTimes = [];
136
137 315
    public function __construct(array $data)
138
    {
139 315
        parent::__construct($data);
140
141 315
        $this->filterTimes = [
142 45
            'all_time' => [
143 270
                'label' => 'All Time',
144
                'limit' => null,
145
                'group' => "DATE_FORMAT(Incidents.created, '%m %Y')",
146
            ],
147
            'day' => [
148 315
                'label' => 'Last Day',
149 315
                'limit' => date('Y-m-d', strtotime('-1 day')),
150 315
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y %H')",
151
            ],
152
            'week' => [
153 315
                'label' => 'Last Week',
154 315
                'limit' => date('Y-m-d', strtotime('-1 week')),
155 315
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y')",
156
            ],
157
            'month' => [
158 315
                'label' => 'Last Month',
159 315
                'limit' => date('Y-m-d', strtotime('-1 month')),
160 315
                'group' => "DATE_FORMAT(Incidents.created, '%a %b %d %Y')",
161
            ],
162
            'year' => [
163 315
                'label' => 'Last Year',
164 315
                'limit' => date('Y-m-d', strtotime('-1 year')),
165 315
                'group' => "DATE_FORMAT(Incidents.created, '%b %u %Y')",
166
            ],
167
        ];
168 315
    }
169
170
    /**
171
     * creates an incident/report record given a raw bug report object.
172
     *
173
     * This gets a decoded bug report from the submitted json body. This has not
174
     * yet been santized. It either adds it as an incident to another report or
175
     * creates a new report if nothing matches.
176
     *
177
     * @param array|null $bugReport the bug report being submitted
178
     *
179
     * @return array of:
180
     *          1. array of inserted incident ids. If the report/incident was not
181
     *               correctly saved, false is put in it place.
182
     *          2. array of newly created report ids. If no new report was created,
183
     *               an empty array is returned
184
     */
185 14
    public function createIncidentFromBugReport(?array $bugReport): array
186
    {
187 14
        if ($bugReport === null) {
188
            return [
189 7
                'incidents' => [false],
190
                'reports' => [],
191
            ];
192
        }
193
194 14
        $incident_ids = [];    // array to hold ids of all the inserted incidents
195 14
        $new_report_ids = []; // array to hold ids of all newly created reports
196
197
        // Avoid storing too many errors from single report
198 14
        if (isset($bugReport['errors']) && count($bugReport['errors']) > 40) {
199 7
            $bugReport['errors'] = array_slice($bugReport['errors'], 0, 40);
200
        }
201
202 14
        if (isset($bugReport['exception']['stack']) && count($bugReport['exception']['stack']) > 40) {
203 7
            $bugReport['exception']['stack'] = array_slice($bugReport['exception']['stack'], 0, 40);
204
        }
205
206
        // Also sanitizes the bug report
207 14
        $schematizedIncidents = $this->getSchematizedIncidents($bugReport);
208 14
        $incidentsTable = TableRegistry::getTableLocator()->get('Incidents');
209 14
        $reportsTable = TableRegistry::getTableLocator()->get('Reports');
210 14
        foreach ($schematizedIncidents as $index => $si) {
211
            // find closest report. If not found, create a new report.
212 14
            $closestReport = $this->getClosestReport($bugReport, $index);
213 14
            if ($closestReport) {
214 14
                $si['report_id'] = $closestReport['id'];
215 14
                $si['error_message'] = mb_strimwidth($si['error_message'], 0, 200, '...');
216 14
                $si = $incidentsTable->newEntity($si);
217 14
                $si->created = date('Y-m-d H:i:s', time());
218 14
                $si->modified = date('Y-m-d H:i:s', time());
219
220 14
                $this->logLongIncidentSubmissions($si, $incident_ids);
221 14
                if (in_array(false, $incident_ids)) {
222 14
                    break;
223
                }
224
            } else {
225
                // no close report. Create a new report.
226 14
                $report = $this->getReportDetails($bugReport, $index);
227
228 14
                $this->logLongIncidentSubmissions($si, $incident_ids);
229 14
                if (in_array(false, $incident_ids)) {
230
                    break;
231
                }
232
233 14
                $report = $reportsTable->newEntity($report);
234 14
                $report->created = date('Y-m-d H:i:s', time());
235 14
                $report->modified = date('Y-m-d H:i:s', time());
236 14
                $reportsTable->save($report);
237
238 14
                $si['report_id'] = $report->id;
239 14
                $si['error_message'] = mb_strimwidth($si['error_message'], 0, 200, '...');
240 14
                $new_report_ids[] = $report->id;
241 14
                $si = $incidentsTable->newEntity($si);
242 14
                $si->created = date('Y-m-d H:i:s', time());
243 14
                $si->modified = date('Y-m-d H:i:s', time());
244
            }
245
246 14
            $isSaved = $incidentsTable->save($si);
247 14
            if ($isSaved) {
248 14
                array_push($incident_ids, $si->id);
249 14
                if (! $closestReport) {
250
                    // add notifications entry
251 14
                    $tmpIncident = $incidentsTable->findById($si->id)->all()->first();
252 14
                    if (! TableRegistry::getTableLocator()->get('Notifications')->addNotifications((int) $tmpIncident['report_id'])) {
253
                        Log::write(
254
                            'error',
255
                            'ERRORED: Notification::addNotifications() failed on Report#'
256
                                . $tmpIncident['report_id'],
257 14
                            'alert'
258
                        );
259
                    }
260
                }
261
            } else {
262
                array_push($incident_ids, false);
263
            }
264
        }
265
266
        return [
267 14
            'incidents' => $incident_ids,
268 14
            'reports' => $new_report_ids,
269
        ];
270
    }
271
272
    /**
273
     * retrieves the closest report to a given bug report.
274
     *
275
     * it checks for another report with the same line number, filename and
276
     * pma_version
277
     *
278
     * @param array $bugReport the bug report being checked
279
     *                         Integer $index: for php exception type
280
     * @param int   $index     The report index
281
     *
282
     * @return Entity|null the first similar report or null
283
     */
284 21
    protected function getClosestReport(array $bugReport, int $index = 0): ?Entity
285
    {
286
        if (
287 21
            isset($bugReport['exception_type'])
288 21
            && $bugReport['exception_type'] === 'php'
289
        ) {
290 14
            $location = $bugReport['errors'][$index]['file'];
291 14
            $linenumber = $bugReport['errors'][$index]['lineNum'];
292
        } else {
293 6
            [$location, $linenumber] =
294 21
                    $this->getIdentifyingLocation($bugReport['exception']['stack'] ?? []);
295
        }
296
297 21
        return TableRegistry::getTableLocator()->get('Reports')->findByLocationAndLinenumberAndPmaVersion(
298 21
            $location,
299 3
            $linenumber,
300 21
            self::getStrippedPmaVersion($bugReport['pma_version'])
301 21
        )->all()->first();
302
    }
303
304
    /**
305
     * creates the report data from an incident that has no related report.
306
     *
307
     * @param array $bugReport the bug report the report record is being created for
308
     *                         Integer $index: for php exception type
309
     * @param int   $index     The report index
310
     *
311
     * @return array an array with the report fields can be used with Report->save
312
     */
313 21
    protected function getReportDetails(array $bugReport, int $index = 0): array
314
    {
315
        if (
316 21
            isset($bugReport['exception_type'])
317 21
            && $bugReport['exception_type'] === 'php'
318
        ) {
319 21
            $location = $bugReport['errors'][$index]['file'];
320 21
            $linenumber = $bugReport['errors'][$index]['lineNum'];
321 6
            $reportDetails = [
322 21
                'error_message' => $bugReport['errors'][$index]['msg'],
323 21
                'error_name' => $bugReport['errors'][$index]['type'],
324
            ];
325 21
            $exception_type = 1;
326
        } else {
327 21
            $exception = $bugReport['exception'] ?? [];
328 6
            [$location, $linenumber] =
329 21
                $this->getIdentifyingLocation($exception['stack'] ?? []);
330
331 6
            $reportDetails = [
332 21
                'error_message' => $exception['message'] ?? '',
333 21
                'error_name' => $exception['name'] ?? '',
334
            ];
335 21
            $exception_type = 0;
336
        }
337
338 21
        $reportDetails = array_merge(
339 21
            $reportDetails,
340
            [
341 21
                'status' => 'new',
342 21
                'location' => $location,
343 21
                'linenumber' => $linenumber ?? 0,
344 21
                'pma_version' => self::getStrippedPmaVersion($bugReport['pma_version']),
345 21
                'exception_type' => $exception_type,
346
            ]
347
        );
348
349 21
        return $reportDetails;
350
    }
351
352
    /**
353
     * creates the incident data from the submitted bug report.
354
     *
355
     * @param array $bugReport the bug report the report record is being created for
356
     *
357
     * @return array an array of schematized incident.
358
     *               Can be used with Incident->save
359
     */
360 21
    protected function getSchematizedIncidents(array $bugReport): array
361
    {
362
        //$bugReport = Sanitize::clean($bugReport, array('escape' => false));
363 21
        $schematizedReports = [];
364 6
        $schematizedCommonReport = [
365 21
            'pma_version' => self::getStrippedPmaVersion($bugReport['pma_version']),
366 21
            'php_version' => $this->getSimpleVersion($bugReport['php_version'] ?? '', 2),
367 21
            'browser' => ($bugReport['browser_name'] ?? '') . ' '
368 21
                    . $this->getSimpleVersion($bugReport['browser_version'] ?? '', 1),
369 21
            'user_os' => $bugReport['user_os'] ?? '',
370 21
            'locale' => $bugReport['locale'] ?? '',
371 21
            'configuration_storage' => $bugReport['configuration_storage'] ?? '',
372 21
            'server_software' => $this->getServer($bugReport['server_software'] ?? ''),
373 21
            'full_report' => json_encode($bugReport),
374
        ];
375
376
        if (
377 21
            isset($bugReport['exception_type'])
378 21
            && $bugReport['exception_type'] === 'php'
379
        ) {
380
            // for each "errors"
381 21
            foreach ($bugReport['errors'] as $error) {
382 21
                $tmpReport = array_merge(
383 21
                    $schematizedCommonReport,
384
                    [
385 21
                        'error_name' => $error['type'],
386 21
                        'error_message' => $error['msg'],
387 21
                        'script_name' => $error['file'],
388 21
                        'stacktrace' => json_encode($error['stackTrace'] ?? []),
389 21
                        'stackhash' => $error['stackhash'],
390 21
                        'exception_type' => 1,         // 'php'
391
                    ]
392
                );
393 21
                array_push($schematizedReports, $tmpReport);
394
            }
395
        } else {
396 21
            $exception = $bugReport['exception'] ?? [
397 15
                'name' => '',
398
                'message' => '',
399
                'stack' => [],
400
            ];
401 21
            $tmpReport = array_merge(
402 21
                $schematizedCommonReport,
403
                [
404 21
                    'error_name' => $exception['name'] ?? '',
405 21
                    'error_message' => $exception['message'] ?? '',
406 21
                    'script_name' => $bugReport['script_name'] ?? '',
407 21
                    'stacktrace' => json_encode($exception['stack'] ?? []),
408 21
                    'stackhash' => self::getStackHash($exception['stack'] ?? []),
409 21
                    'exception_type' => 0,     //'js'
410
                ]
411
            );
412
413 21
            if (isset($bugReport['steps'])) {
414 21
                $tmpReport['steps'] = $bugReport['steps'];
415
            }
416
417 21
            array_push($schematizedReports, $tmpReport);
418
        }
419
420 21
        return $schematizedReports;
421
    }
422
423
    /**
424
     * Gets the identifiying location info from a stacktrace.
425
     *
426
     * This is used to skip stacktrace levels that are within the error reporting js
427
     * files that sometimes appear in the stacktrace but are not related to the bug
428
     * report
429
     *
430
     * returns two things in an array:
431
     * - the first element is the filename/scriptname of the error
432
     * - the second element is the linenumber of the error
433
     *
434
     * @param array $stacktrace the stacktrace being examined
435
     *
436
     * @return array an array with the filename/scriptname and linenumber of the
437
     *               error
438
     */
439 35
    protected function getIdentifyingLocation(array $stacktrace): array
440
    {
441 10
        $fallback = [
442 25
            'UNKNOWN',
443
            0,
444
        ];
445 35
        foreach ($stacktrace as $level) {
446 35
            if (isset($level['filename'])) {
447
                // ignore unrelated files that sometimes appear in the error report
448 35
                if ($level['filename'] === 'tracekit/tracekit.js') {
449 7
                    continue;
450
                }
451
452 35
                if ($level['filename'] === 'error_report.js') {
453
                    // in case the error really is in the error_report.js file save it for
454
                    // later
455 7
                    if ($fallback[0] === 'UNKNOWN') {
456 2
                        $fallback = [
457 7
                            $level['filename'],
458 7
                            $level['line'],
459
                        ];
460
                    }
461
462 7
                    continue;
463
                }
464
465
                return [
466 35
                    $level['filename'],
467 35
                    $level['line'],
468
                ];
469
            }
470
471 7
            if (isset($level['scriptname'])) {
472
                return [
473 7
                    $level['scriptname'],
474 7
                    $level['line'],
475
                ];
476
            }
477
478 7
            continue;
479
        }
480
481 7
        return $fallback;
482
    }
483
484
    /**
485
     * Gets a part of a version string according to the specified version Length.
486
     *
487
     * @param string $versionString the version string
488
     * @param string $versionLength the number of version components to return. eg
489
     *                              1 for major version only and 2 for major and
490
     *                              minor version
491
     *
492
     * @return string the major and minor version part
493
     */
494 28
    protected function getSimpleVersion(string $versionString, string $versionLength): string
495
    {
496 28
        $versionLength = (int) $versionLength;
497 28
        if ($versionLength < 1) {
498 7
            $versionLength = 1;
499
        }
500
501
        /* modify the re to accept a variable number of version components. I
502
         * atleast take one component and optionally get more components if need be.
503
         * previous code makes sure that the $versionLength variable is a positive
504
         * int
505
         */
506 28
        $result = preg_match(
507 28
            '/^(\d+\.){' . ($versionLength - 1) . '}\d+/',
508 28
            $versionString,
509 28
            $matches
510
        );
511 28
        if ($result) {
512 28
            return $matches[0];
513
        }
514
515
        return $versionString;
516
    }
517
518
    /**
519
     * Returns the version string stripped of
520
     * 'deb', 'ubuntu' and other suffixes
521
     *
522
     * @param string $versionString phpMyAdmin version
523
     *
524
     * @return string stripped phpMyAdmin version
525
     */
526 196
    public static function getStrippedPmaVersion(string $versionString): string
527
    {
528
        // This regex contains a negative lookahead to avoid fedora version suffix
529
        // Use: https://regex101.com/
530
        // See tests: IncidentsTableTest::versionsStripping
531 196
        $allowedRegexp = '/^((\d+)(\.\d+){0,3}(\-[a-z0-9]+){0,1})(?!\.[a-z]{1,}[0-9]{1,})/';
532 196
        $matches = [];
533
534
        // Check if $versionString matches the regexp
535
        // and store the matched strings
536 196
        if (preg_match($allowedRegexp, $versionString, $matches)) {
537 182
            return $matches[0];
538
        }
539
540
        // If $versionString does not match the regexp at all,
541
        // leave it as it is
542 14
        return $versionString;
543
    }
544
545
    /**
546
     * Gets the server name and version from the server signature.
547
     *
548
     * @param string $signature the server signature
549
     *
550
     * @return string the server name and version or UNKNOWN
551
     */
552 28
    protected function getServer(string $signature): string
553
    {
554
        if (
555 28
            preg_match(
556
                '/(apache\/\d+\.\d+)|(nginx\/\d+\.\d+)|(iis\/\d+\.\d+)'
557 28
                . '|(lighttpd\/\d+\.\d+)/i',
558 28
                $signature,
559 28
                $matches
560
            )
561
        ) {
562 28
            return $matches[0];
563
        }
564
565 7
        return 'UNKNOWN';
566
    }
567
568
    /**
569
     * returns the hash pertaining to a stacktrace.
570
     *
571
     * @param array $stacktrace the stacktrace in question
572
     *
573
     * @return string the hash string of the stacktrace
574
     */
575 28
    public function getStackHash(array $stacktrace): string
576
    {
577 28
        $handle = hash_init('md5');
578 28
        foreach ($stacktrace as $level) {
579 8
            $elements = [
580 20
                'filename',
581
                'scriptname',
582
                'line',
583
                'func',
584
                'column',
585
            ];
586 28
            foreach ($elements as $element) {
587 28
                if (! isset($level[$element])) {
588 28
                    continue;
589
                }
590
591 28
                hash_update($handle, $level[$element]);
592
            }
593
        }
594
595 28
        return hash_final($handle);
596
    }
597
598
    /**
599
     * Checks the length of stacktrace and full_report
600
     * and logs if it is greater than what it can hold
601
     *
602
     * @param object $si           submitted incident
603
     * @param array  $incident_ids incident IDs
604
     * @return void Nothing
605
     */
606 14
    private function logLongIncidentSubmissions($si, array &$incident_ids): void// phpcs:ignore SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingParameterTypeHint
607
    {
608 14
        $stacktraceLength = mb_strlen($si['stacktrace']);
609 14
        $fullReportLength = mb_strlen($si['full_report']);
610
611
        if (
612 14
            $stacktraceLength <= 65535
613 14
            && $fullReportLength <= 65535
614
        ) {
615 14
            return;
616
        }
617
618
        // If length of report is longer than
619
        // what can fit in the table field,
620
        // we log it and don't save it in the database
621
        Log::error(
622
            'Too long data submitted in the incident. The length of stacktrace: '
623
            . $stacktraceLength . ', the length of bug report: '
624
            . $fullReportLength . '. The full incident reported was as follows: '
625
            . json_encode($si)
626
        );
627
628
        // add a 'false' to the return array
629
        array_push($incident_ids, false);
630
    }
631
}
632