Completed
Push — master ( c5d53f...d2b8ac )
by Sebastien
02:26
created

ReleaseHistory::prepare()   C

Complexity

Conditions 9
Paths 12

Size

Total Lines 48
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 9
Bugs 1 Features 1
Metric Value
c 9
b 1
f 1
dl 0
loc 48
rs 5.5102
cc 9
eloc 27
nc 12
nop 2
1
<?php
2
3
namespace Cerbere\Model;
4
5
use Doctrine\Common\Cache\CacheProvider;
6
7
/**
8
 * Class ReleaseHistory
9
 *
10
 * @package Cerbere\Action
11
 */
12
class ReleaseHistory
13
{
14
    /**
15
     * Project is up to date.
16
     */
17
    const UPDATE_CURRENT = 5;
18
19
    /**
20
     * Project has a new release available, but it is not a security release.
21
     */
22
    const UPDATE_NOT_CURRENT = 4;
23
24
    /**
25
     * Current release is no longer supported by the project maintainer.
26
     */
27
    const UPDATE_NOT_SUPPORTED = 3;
28
    /**
29
     * Current release has been unpublished and is no longer available.
30
     */
31
    const UPDATE_REVOKED = 2;
32
33
    /**
34
     * Project is missing security update(s).
35
     */
36
    const UPDATE_NOT_SECURE = 1;
37
38
    /**
39
     * Project's status cannot be checked.
40
     */
41
    const UPDATE_NOT_CHECKED = -1;
42
43
    /**
44
     * No available update data was found for project.
45
     */
46
    const UPDATE_UNKNOWN = -2;
47
48
    /**
49
     * There was a failure fetching available update data for this project.
50
     */
51
    const UPDATE_NOT_FETCHED = -3;
52
53
    /**
54
     * We need to (re)fetch available update data for this project.
55
     */
56
    const UPDATE_FETCH_PENDING = -4;
57
58
    /**
59
     * @var string
60
     */
61
    protected $url;
62
63
    /**
64
     * @var array
65
     */
66
    protected $data;
67
68
    /**
69
     * @var CacheProvider
70
     */
71
    protected $cache;
72
73
    /**
74
     * @param CacheProvider $cache
75
     * @param string $url
76
     */
77
    public function __construct(CacheProvider $cache = null, $url = null)
78
    {
79
        $this->cache = $cache;
80
        $this->url = $url;
81
    }
82
83
    /**
84
     * @param Project $project
85
     */
86
    public function compare(Project $project)
87
    {
88
        // If the project status is marked as something bad, there's nothing else
89
        // to consider.
90
        if ($this->getProjectStatus()) {
91
            switch ($this->getProjectStatus()) {
92
                case 'insecure':
93
                    $project->setStatus(self::UPDATE_NOT_SECURE);
94
                    break;
95
                case 'unpublished':
96
                case 'revoked':
97
                    $project->setStatus(self::UPDATE_REVOKED);
98
                    break;
99
                case 'unsupported':
100
                    $project->setStatus(self::UPDATE_NOT_SUPPORTED);
101
                    break;
102
                case 'not-fetched':
103
                    $project->setStatus(self::UPDATE_NOT_FETCHED);
104
                    break;
105
106
                default:
107
                    // Assume anything else (e.g. 'published') is valid and we should
108
                    // perform the rest of the logic in this function.
109
                    break;
110
            }
111
        }
112
113
        if ($project->getStatus()) {
114
            // We already know the status for this project, so there's nothing else to
115
            // compute. Record the project status into $project_data and we're done.
116
            $project->setProjectStatus($this->getProjectStatus());
117
118
            return;
119
        }
120
121
        // Figure out the target major version.
122
        $existing_major = $project->getExistingMajor();
123
        $supported_majors = array();
124
        if ($this->getSupportedMajors()) {
125
            $supported_majors = explode(',', $this->getSupportedMajors());
126
        } elseif ($this->getDefaultMajor()) {
127
            // Older release history XML file without supported or recommended.
128
            $supported_majors[] = $this->getDefaultMajor();
129
        }
130
131
        if (in_array($existing_major, $supported_majors)) {
132
            // Still supported, stay at the current major version.
133
            $target_major = $existing_major;
134
        } elseif ($this->getRecommendedMajor()) {
135
            // Since 'recommended_major' is defined, we know this is the new XML
136
            // format. Therefore, we know the current release is unsupported since
137
            // its major version was not in the 'supported_majors' list. We should
138
            // find the best release from the recommended major version.
139
            $target_major = $this->getRecommendedMajor();
140
            $project->setStatus(self::UPDATE_NOT_SUPPORTED);
141
        } elseif ($this->getDefaultMajor()) {
142
            // Older release history XML file without recommended, so recommend
143
            // the currently defined "default_major" version.
144
            $target_major = $this->getDefaultMajor();
145
        } else {
146
            // Malformed XML file? Stick with the current version.
147
            $target_major = $existing_major;
148
        }
149
150
        // Make sure we never tell the admin to downgrade. If we recommended an
151
        // earlier version than the one they're running, they'd face an
152
        // impossible data migration problem, since Drupal never supports a DB
153
        // downgrade path. In the unfortunate case that what they're running is
154
        // unsupported, and there's nothing newer for them to upgrade to, we
155
        // can't print out a "Recommended version", but just have to tell them
156
        // what they have is unsupported and let them figure it out.
157
        $target_major = max($existing_major, $target_major);
158
159
        $release_patch_changed = null;
160
        $patch = '';
161
162
        // If the project is marked as UPDATE_FETCH_PENDING, it means that the
163
        // data we currently have (if any) is stale, and we've got a task queued
164
        // up to (re)fetch the data. In that case, we mark it as such, merge in
165
        // whatever data we have (e.g. project title and link), and move on.
166
        if ($this->getFetchStatus() == self::UPDATE_FETCH_PENDING) {
167
            $project->setStatus(self::UPDATE_FETCH_PENDING);
168
            $project->setReason('No available update data');
169
            $project->setFetchStatus($this->getFetchStatus());
170
171
            return;
172
        }
173
174
        // Defend ourselves from XML history files that contain no releases.
175
        if (!$this->getReleases()) {
176
            $project->setStatus(self::UPDATE_UNKNOWN);
177
            $project->setReason('No available releases found');
178
179
            return;
180
        }
181
182
        foreach ($this->getReleases() as $release => $release) {
183
            // First, if this is the existing release, check a few conditions.
184
            if ($project->getExistingVersion() == $release) {
185
                if ($release->hasTerm('Release type') &&
186
                  in_array('Insecure', $release->getTerm('Release type'))
187
                ) {
188
                    $project->setStatus(self::UPDATE_NOT_SECURE);
189
                } elseif ($release->getStatus() == 'unpublished') {
190
                    $project->setStatus(self::UPDATE_REVOKED);
191
                } elseif ($release->hasTerm('Release type') &&
192
                  in_array('Unsupported', $release->getTerm('Release type'))
193
                ) {
194
                    $project->setStatus(self::UPDATE_NOT_SUPPORTED);
195
                }
196
            }
197
198
            // Otherwise, ignore unpublished, insecure, or unsupported releases.
199
            if ($release->getStatus() == 'unpublished' ||
200
              ($release->hasTerm('Release type') &&
201
                (in_array('Insecure', $release->getTerm('Release type')) ||
202
                  in_array('Unsupported', $release->getTerm('Release type'))))
203
            ) {
204
                continue;
205
            }
206
207
            // Look for the 'latest version' if we haven't found it yet. Latest is
208
            // defined as the most recent version for the target major version.
209
            if (!$project->getLatestVersion() && $release->getVersionMajor() == $target_major) {
210
                $project->setLatestVersion($release);
211
                $project->setRelease($release, $release);
0 ignored issues
show
Documentation introduced by
$release is of type object<Cerbere\Model\Release>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
212
            }
213
214
            // Look for the development snapshot release for this branch.
215
            if (!$project->getDevVersion()
216
              && $release->getVersionMajor() == $target_major
217
              && $release->getVersionExtra() == Project::INSTALL_TYPE_DEV
218
            ) {
219
                $project->setDevVersion($release);
220
                $project->setRelease($release, $release);
0 ignored issues
show
Documentation introduced by
$release is of type object<Cerbere\Model\Release>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
221
            }
222
223
            // Look for the 'recommended' version if we haven't found it yet (see
224
            // phpdoc at the top of this function for the definition).
225
            if (!$project->getRecommended()
226
              && $release->getVersionMajor() == $target_major
227
              && $release->getVersionPatch()
228
            ) {
229
                if ($patch != $release->getVersionPatch()) {
230
                    $patch = $release->getVersionPatch();
231
                    $release_patch_changed = $release;
232
                }
233
                if (!$release->getVersionExtra() && $patch == $release->getVersionPatch()) {
234
                    $project->setRecommended($release_patch_changed->getVersion());
235
                    if ($release_patch_changed instanceof Release) {
236
                        $project->setRelease($release_patch_changed->getVersion(), $release_patch_changed);
237
                    }
238
                }
239
            }
240
241
            // Stop searching once we hit the currently installed version.
242
            if ($project->getExistingVersion() == $release) {
243
                break;
244
            }
245
246
            // If we're running a dev snapshot and have a timestamp, stop
247
            // searching for security updates once we hit an official release
248
            // older than what we've got. Allow 100 seconds of leeway to handle
249
            // differences between the datestamp in the .info file and the
250
            // timestamp of the tarball itself (which are usually off by 1 or 2
251
            // seconds) so that we don't flag that as a new release.
252
            if ($project->getInstallType() == Project::INSTALL_TYPE_DEV) {
253
                if (!$project->getDatestamp()) {
254
                    // We don't have current timestamp info, so we can't know.
255
                    continue;
256
                } elseif ($release->getDate() && ($project->getDatestamp() + 100 > $release->getDate())) {
257
                    // We're newer than this, so we can skip it.
258
                    continue;
259
                }
260
            }
261
262
            // See if this release is a security update.
263
            if ($release->hasTerm('Release type') && in_array('Security update', $release->getTerm('Release type'))) {
264
                $project->addSecurityUpdate($release->getVersion(), $release);
265
            }
266
        }
267
268
        // If we were unable to find a recommended version, then make the latest
269
        // version the recommended version if possible.
270
        if (!$project->getRecommended() && $project->getLatestVersion()) {
271
            $project->setRecommended($project->getLatestVersion());
272
        }
273
274
        // Check to see if we need an update or not.
275
        if ($project->hasSecurityUpdates()) {
276
            // If we found security updates, that always trumps any other status.
277
            $project->setStatus(self::UPDATE_NOT_SECURE);
278
        }
279
280
        if ($project->getStatus()) {
281
            // If we already know the status, we're done.
282
            return;
283
        }
284
285
        // If we don't know what to recommend, there's nothing we can report.
286
        // Bail out early.
287
        if (!$project->getRecommended()) {
288
            $project->setStatus(self::UPDATE_UNKNOWN);
289
            $project->setReason('No available releases found');
290
291
            return;
292
        }
293
294
        // If we're running a dev snapshot, compare the date of the dev snapshot
295
        // with the latest official version, and record the absolute latest in
296
        // 'latest_dev' so we can correctly decide if there's a newer release
297
        // than our current snapshot.
298
        if ($project->getInstallType() == Project::INSTALL_TYPE_DEV) {
299
            if ($project->getDevVersion() && $this->getRelease($project->getDevVersion())->getDate(
300
              ) > $this->getRelease($project->getLatestVersion())->getDate()
301
            ) {
302
                $project->setLatestDev($project->getDevVersion());
303
            } else {
304
                $project->setLatestDev($project->getLatestVersion());
305
            }
306
        }
307
308
        // Figure out the status, based on what we've seen and the install type.
309
        switch ($project->getInstallType()) {
310
            case Project::INSTALL_TYPE_OFFICIAL:
311
                if ($project->getExistingVersion() == $project->getRecommended() ||
312
                  $project->getExistingVersion() == $project->getLatestVersion()
313
                ) {
314
                    $project->setStatus(self::UPDATE_CURRENT);
315
                } else {
316
                    $project->setStatus(self::UPDATE_NOT_CURRENT);
317
                }
318
                break;
319
320
            case Project::INSTALL_TYPE_DEV:
321
                $latest = $this->getRelease($project->getLatestDev());
322
323
                if (!$project->getDatestamp()) {
324
                    $project->setStatus(self::UPDATE_NOT_CHECKED);
325
                    $project->setReason('Unknown release date');
326
                } elseif (($project->getDatestamp() + 100 > $latest->getDate())) {
327
                    $project->setStatus(self::UPDATE_CURRENT);
328
                } else {
329
                    $project->setStatus(self::UPDATE_NOT_CURRENT);
330
                }
331
                break;
332
333
            default:
334
                $project->setStatus(self::UPDATE_UNKNOWN);
335
                $project->setReason('Invalid info');
336
        }
337
    }
338
339
    /**
340
     * @return string
341
     */
342
    public function getProjectStatus()
343
    {
344
        return $this->data['project_status'];
345
    }
346
347
    /**
348
     * @return string
349
     */
350
    public function getSupportedMajors()
351
    {
352
        return $this->data['supported_majors'];
353
    }
354
355
    /**
356
     * @return int
357
     */
358
    public function getDefaultMajor()
359
    {
360
        return $this->data['default_major'];
361
    }
362
363
    /**
364
     * @return int
365
     */
366
    public function getRecommendedMajor()
367
    {
368
        return $this->data['recommended_major'];
369
    }
370
371
    /**
372
     * @return mixed
373
     */
374
    public function getFetchStatus()
375
    {
376
        return isset($this->data['fetch_status']) ? $this->data['fetch_status'] : 0;
377
    }
378
379
    /**
380
     * @return Release[]
381
     */
382
    public function getReleases()
383
    {
384
        return $this->data['releases'];
385
    }
386
387
    /**
388
     * @param string $release
389
     *
390
     * @return Release|null
391
     */
392
    public function getRelease($release)
393
    {
394
        if (isset($this->data['releases'][$release])) {
395
            return $this->data['releases'][$release];
396
        }
397
398
        return null;
399
    }
400
401
    /**
402
     * @return string
403
     */
404
    public function getApiVersion()
405
    {
406
        return $this->data['api_version'];
407
    }
408
409
    /**
410
     * @return array
411
     */
412
    public function getData()
413
    {
414
        return $this->data;
415
    }
416
417
    /**
418
     * @return Release
419
     */
420
    public function getLastRelease()
421
    {
422
        $release = reset($this->data['releases']);
423
424
        return $release;
425
    }
426
427
    /**
428
     * @return string
429
     */
430
    public function getLink()
431
    {
432
        return $this->data['link'];
433
    }
434
435
    /**
436
     * @return mixed
437
     */
438
    public function getShortName()
439
    {
440
        return $this->data['short_name'];
441
    }
442
443
    /**
444
     * @param int $status
445
     *
446
     * @return string
447
     */
448
    public static function getStatusLabel($status)
449
    {
450
        switch ($status) {
451
            case self::UPDATE_NOT_SECURE:
452
                return 'Not secure';
453
            case self::UPDATE_REVOKED:
454
                return 'Revoked';
455
            case self::UPDATE_NOT_SUPPORTED:
456
                return 'Not supported';
457
            case self::UPDATE_NOT_CURRENT:
458
                return 'Not current';
459
            case self::UPDATE_CURRENT:
460
                return 'Update current';
461
            case self::UPDATE_NOT_CHECKED:
462
                return 'Not checked';
463
            case self::UPDATE_UNKNOWN:
464
                return 'Unknown';
465
            case self::UPDATE_NOT_FETCHED:
466
                return 'Not fetched';
467
            case self::UPDATE_FETCH_PENDING:
468
                return 'Fetch pending';
469
            default:
470
                return '';
471
        }
472
    }
473
474
    /**
475
     * @return string
476
     */
477
    public function getTerms()
478
    {
479
        return trim($this->data['terms']);
480
    }
481
482
    /**
483
     * @return string
484
     */
485
    public function getTitle()
486
    {
487
        return $this->data['title'];
488
    }
489
490
    /**
491
     * @return string
492
     */
493
    public function getType()
494
    {
495
        return $this->data['type'];
496
    }
497
498
    /**
499
     * @return string
500
     */
501
    public function getUrl()
502
    {
503
        return $this->url;
504
    }
505
506
    /**
507
     * @param string $url
508
     */
509
    public function setUrl($url)
510
    {
511
        $this->url = $url;
512
    }
513
514
    /**
515
     * @param Project $project
516
     * @param bool|false $reset
517
     */
518
    public function prepare(Project $project, $reset = false)
519
    {
520
        $cid_parts = array(
521
          'release_history',
522
          $project->getProject(),
523
          $project->getCore(),
524
        );
525
526
        $cid = implode(':', $cid_parts);
527
        $data = false;
528
529
        if ($this->cache && !$reset) {
530
            $data = $this->cache->fetch($cid);
531
        }
532
533
        // If not in cache, load from remote.
534
        if ($data === false) {
535
            $url = $project->getStatusUrl() . '/' .
536
              $project->getProject() . '/' .
537
              $project->getCore();
538
539
            // Todo: prefer guzzle library.
540
            $content = file_get_contents($url);
541
542
            // If data, store into cache.
543
            if ($this->cache && ($data = $this->parseUpdateXml($content))) {
544
                $this->cache->save($cid, $data, 1800);
545
            }
546
        }
547
548
        $data += array(
549
          'project_status'    => '',
550
          'default_major'     => '',
551
          'recommended_major' => '',
552
          'supported_majors'  => '',
553
        );
554
555
        // Hydrate release objects.
556
        if (isset($data['releases']) && is_array($data['releases'])) {
557
            foreach ($data['releases'] as $key => $value) {
558
                $data['releases'][$key] = new Release($value);
559
            }
560
        } else {
561
            $data['releases'] = array();
562
        }
563
564
        $this->data = (array) $data;
565
    }
566
567
    /**
568
     * Parses the XML of the Drupal release history info files.
569
     *
570
     * @param string $raw_xml
571
     *   A raw XML string of available release data for a given project.
572
     *
573
     * @return array
574
     *   Array of parsed data about releases for a given project, or NULL if there
575
     *   was an error parsing the string.
576
     */
577
    protected function parseUpdateXml($raw_xml)
578
    {
579
        try {
580
            $xml = new \SimpleXMLElement($raw_xml);
581
        } catch (\Exception $e) {
582
            // SimpleXMLElement::__construct produces an E_WARNING error message for
583
            // each error found in the XML data and throws an exception if errors
584
            // were detected. Catch any exception and return failure (NULL).
585
            return array();
586
        }
587
588
        // If there is no valid project data, the XML is invalid, so return failure.
589
        if (!isset($xml->short_name)) {
590
            return array();
591
        }
592
593
        $data = array();
594
        foreach ($xml as $k => $v) {
595
            $data[$k] = (string) $v;
596
        }
597
        $data['releases'] = array();
598
599
        if (isset($xml->releases)) {
600
            foreach ($xml->releases->children() as $release) {
601
                $release = (string) $release->version;
602
                $data['releases'][$release] = array();
603
                foreach ($release->children() as $k => $v) {
0 ignored issues
show
Bug introduced by
The method children cannot be called on $release (of type string).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
604
                    $data['releases'][$release][$k] = (string) $v;
605
                }
606
                $data['releases'][$release]['terms'] = array();
607
                if ($release->terms) {
608
                    foreach ($release->terms->children() as $term) {
609
                        if (!isset($data['releases'][$release]['terms'][(string) $term->name])) {
610
                            $data['releases'][$release]['terms'][(string) $term->name] = array();
611
                        }
612
                        $data['releases'][$release]['terms'][(string) $term->name][] = (string) $term->value;
613
                    }
614
                }
615
            }
616
        }
617
618
        return $data;
619
    }
620
}
621