Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.85% covered (success)
90.85%
129 / 142
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
version_helper
90.85% covered (success)
90.85%
129 / 142
57.14% covered (warning)
57.14%
8 / 14
79.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 set_file_location
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 set_current_version
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 force_stability
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 compare
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_stable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_branch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_latest_on_current_branch
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 get_update_on_branch
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
12.03
 get_ext_update_on_branch
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 get_suggested_updates
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_versions_matching_stability
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 get_versions
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
11.20
 validate_versions
90.00% covered (success)
90.00%
36 / 40
0.00% covered (danger)
0.00%
0 / 1
21.44
1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb;
15
16use phpbb\exception\version_check_exception;
17use phpbb\json\sanitizer as json_sanitizer;
18
19/**
20 * Class to handle version checking and comparison
21 */
22class version_helper
23{
24    /**
25     * @var string Host
26     */
27    protected $host = 'version.phpbb.com';
28
29    /**
30     * @var string Path to file
31     */
32    protected $path = '/phpbb';
33
34    /**
35     * @var string File name
36     */
37    protected $file = 'versions.json';
38
39    /**
40     * @var bool Use SSL or not
41     */
42    protected $use_ssl = true;
43
44    /**
45     * @var string Current version installed
46     */
47    protected $current_version;
48
49    /**
50     * @var null|string Null to not force stability, 'unstable' or 'stable' to
51     *                    force the corresponding stability
52     */
53    protected $force_stability;
54
55    /** @var \phpbb\cache\service */
56    protected $cache;
57
58    /** @var \phpbb\config\config */
59    protected $config;
60
61    /** @var \phpbb\file_downloader */
62    protected $file_downloader;
63
64    protected $version_schema = array(
65        'stable' => array(
66            'current'        => 'version',
67            'download'        => 'url',
68            'announcement'    => 'url',
69            'eol'            => 'url',
70            'security'        => 'bool',
71        ),
72        'unstable' => array(
73            'current'        => 'version',
74            'download'        => 'url',
75            'announcement'    => 'url',
76            'eol'            => 'url',
77            'security'        => 'bool',
78        ),
79    );
80
81    /**
82     * Constructor
83     *
84     * @param \phpbb\cache\service $cache
85     * @param \phpbb\config\config $config
86     * @param \phpbb\file_downloader $file_downloader
87     */
88    public function __construct(\phpbb\cache\service $cache, \phpbb\config\config $config, \phpbb\file_downloader $file_downloader)
89    {
90        $this->cache = $cache;
91        $this->config = $config;
92        $this->file_downloader = $file_downloader;
93
94        if (defined('PHPBB_QA'))
95        {
96            $this->force_stability = 'unstable';
97        }
98
99        $this->current_version = $this->config['version'];
100    }
101
102    /**
103     * Set location to the file
104     *
105     * @param string $host Host (e.g. version.phpbb.com)
106     * @param string $path Path to file (e.g. /phpbb)
107     * @param string $file File name (Default: versions.json)
108     * @param bool $use_ssl Use SSL or not (Default: false)
109     * @return version_helper
110     */
111    public function set_file_location($host, $path, $file = 'versions.json', $use_ssl = false)
112    {
113        $this->host = $host;
114        $this->path = $path;
115        $this->file = $file;
116        $this->use_ssl = $use_ssl;
117
118        return $this;
119    }
120
121    /**
122     * Set current version
123     *
124     * @param string $version The current version
125     * @return version_helper
126     */
127    public function set_current_version($version)
128    {
129        $this->current_version = $version;
130
131        return $this;
132    }
133
134    /**
135     * Over-ride the stability to force check to include unstable versions
136     *
137     * @param null|string $stability Null to not force stability, 'unstable' or 'stable' to
138     *                         force the corresponding stability
139     * @return version_helper
140     */
141    public function force_stability($stability)
142    {
143        $this->force_stability = $stability;
144
145        return $this;
146    }
147
148    /**
149     * Wrapper for version_compare() that allows using uppercase A and B
150     * for alpha and beta releases.
151     *
152     * See http://www.php.net/manual/en/function.version-compare.php
153     *
154     * @param string $version1        First version number
155     * @param string $version2        Second version number
156     * @param string $operator        Comparison operator (optional)
157     *
158     * @return mixed                Boolean (true, false) if comparison operator is specified.
159     *                                Integer (-1, 0, 1) otherwise.
160     */
161    public function compare($version1, $version2, $operator = null)
162    {
163        return phpbb_version_compare($version1, $version2, $operator);
164    }
165
166    /**
167     * Check whether or not a version is "stable"
168     *
169     * Stable means only numbers OR a pl release
170     *
171     * @param string $version
172     * @return bool Bool true or false
173     */
174    public function is_stable(string $version): bool
175    {
176        $matches = false;
177        preg_match('/^[\d.]+/', $version, $matches);
178
179        if (empty($matches[0]))
180        {
181            return false;
182        }
183
184        return $this->compare($version, $matches[0], '>=');
185    }
186
187    /**
188     * Get branch for version, e.g. 3.2 for version 3.2.5
189     *
190     * @param string $version Version to get branch from
191     * @return string Branch for version or empty string if version is not of expected format
192     */
193    protected function get_branch(string $version): string
194    {
195        $matches = [];
196        preg_match('/^(\d+\.\d+).*$/', $version, $matches);
197
198        return $matches[1] ?? '';
199    }
200
201    /**
202    * Gets the latest version for the current branch the user is on
203    *
204    * @param bool $force_update Ignores cached data. Defaults to false.
205    * @param bool $force_cache Force the use of the cache. Override $force_update.
206    * @return string
207    * @throws version_check_exception
208    */
209    public function get_latest_on_current_branch(bool $force_update = false, bool $force_cache = false): string
210    {
211        $versions = $this->get_versions_matching_stability($force_update, $force_cache);
212
213        $current_version = $this->current_version;
214
215        // Get the branch information for the current version
216        $current_branch = $this->get_branch($current_version);
217        if (isset($versions[$current_branch]) && empty($versions[$current_branch]['eol']))
218        {
219            return $versions[$current_branch]['current'];
220        }
221
222        // Sort versions in version ascending order so we can loop from the lowest version to the highest version.
223        uksort($versions, function($version1, $version2) {
224            return $this->compare($version1, $version2, '>');
225        });
226
227        // Find next available version from versions info.
228        // Will suggest newer branches when EoL has been reached for the current branch, and/or version from newer branch
229        // is needed for having all known security issues fixed ('security' > latest on branch).
230        foreach ($versions as $branch => $data)
231        {
232            if ($this->compare($branch, $current_branch, '>=') && empty($data['eol'])
233                && (empty($data['security']) || $this->compare($data['security'], $data['current'], '<=')))
234            {
235                return $data['current'];
236            }
237        }
238
239        return '';
240    }
241
242    /**
243     * Gets the latest update for the current branch the user is on
244     * Will suggest versions from newer branches when EoL has been reached
245     * and/or version from newer branch is needed for having all known security
246     * issues fixed.
247     *
248     * @param bool $force_update Ignores cached data. Defaults to false.
249     * @param bool $force_cache Force the use of the cache. Override $force_update.
250     * @return array Version info or empty array if there are no updates
251     * @throws \RuntimeException
252     */
253    public function get_update_on_branch(bool $force_update = false, bool $force_cache = false): array
254    {
255        $versions = $this->get_versions_matching_stability($force_update, $force_cache);
256
257        $current_version = $this->current_version;
258
259        // Use current branch information if it exists
260        $current_branch = $this->get_branch($current_version);
261        $current_branch_data = $versions[$current_branch] ?? null;
262        if ($current_branch_data && empty($current_branch_data['eol'])
263            && (empty($current_branch_data['security']) || $this->compare($current_branch_data['security'], $current_version, '<=')))
264        {
265            return ($this->compare($current_branch_data['current'], $current_version, '>')) ? $current_branch_data : [];
266        }
267
268        // Sort versions in version ascending order so we can loop from the lowest version to the highest version.
269        uksort($versions, function($version1, $version2) {
270            return $this->compare($version1, $version2, '>');
271        });
272
273        foreach ($versions as $branch => $data)
274        {
275            if ($this->compare($branch, $current_branch, '>=') && empty($data['eol'])
276                && (empty($data['security']) || $this->compare($data['security'], $data['current'], '<=')))
277            {
278                if ($this->compare($data['current'], $current_version, '>'))
279                {
280                    return $data;
281                }
282                else
283                {
284                    break;
285                }
286            }
287        }
288
289        return [];
290    }
291
292    /**
293     * Gets the latest extension update for the current phpBB branch the user is on
294     * Will suggest versions from newer branches when EoL has been reached
295     * and/or version from newer branch is needed for having all known security
296     * issues fixed.
297     *
298     * @param bool $force_update Ignores cached data. Defaults to false.
299     * @param bool $force_cache Force the use of the cache. Override $force_update.
300     * @return array Version info or empty array if there are no updates
301     * @throws \RuntimeException
302     */
303    public function get_ext_update_on_branch($force_update = false, $force_cache = false)
304    {
305        $versions = $this->get_versions_matching_stability($force_update, $force_cache);
306
307        $self = $this;
308        $current_version = $this->current_version;
309
310        $current_branch = $this->get_branch($this->config['version']);
311
312        // Filter out any versions less than the current version
313        $versions = array_filter($versions, function($data) use ($self, $current_version) {
314            return $self->compare($data['current'], $current_version, '>=');
315        });
316
317        // Filter out any phpbb branches less than the current version
318        $branches = array_filter(array_keys($versions), function($branch) use ($self, $current_branch) {
319            return $self->compare($branch, $current_branch, '>=');
320        });
321        if (!empty($branches))
322        {
323            $versions = array_intersect_key($versions, array_flip($branches));
324        }
325        else
326        {
327            // If branches are empty, it means the current phpBB branch is newer than any branch the
328            // extension was validated against. Reverse sort the versions array so we get the newest
329            // validated release available.
330            krsort($versions);
331        }
332
333        // Get the first available version from the previous list.
334        $update_info = array_reduce($versions, function($value, $data) use ($self, $current_version) {
335            if ($value === null && $self->compare($data['current'], $current_version, '>='))
336            {
337                if (!$data['eol'] && (!$data['security'] || $self->compare($data['security'], $data['current'], '<=')))
338                {
339                    return $self->compare($data['current'], $current_version, '>') ? $data : array();
340                }
341                else
342                {
343                    return null;
344                }
345            }
346
347            return $value;
348        });
349
350        return $update_info === null ? array() : $update_info;
351    }
352
353    /**
354    * Obtains the latest version information
355    *
356    * @param bool $force_update Ignores cached data. Defaults to false.
357    * @param bool $force_cache Force the use of the cache. Override $force_update.
358    * @return array
359    * @throws version_check_exception
360    */
361    public function get_suggested_updates($force_update = false, $force_cache = false)
362    {
363        $versions = $this->get_versions_matching_stability($force_update, $force_cache);
364
365        $self = $this;
366        $current_version = $this->current_version;
367
368        // Filter out any versions less than or equal to the current version
369        return array_filter($versions, function($data) use ($self, $current_version) {
370            return $self->compare($data['current'], $current_version, '>');
371        });
372    }
373
374    /**
375    * Obtains the latest version information matching the stability of the current install
376    *
377    * @param bool $force_update Ignores cached data. Defaults to false.
378    * @param bool $force_cache Force the use of the cache. Override $force_update.
379    * @return array Version info
380    * @throws version_check_exception
381    */
382    public function get_versions_matching_stability($force_update = false, $force_cache = false)
383    {
384        $info = $this->get_versions($force_update, $force_cache);
385
386        if ($this->force_stability !== null)
387        {
388            return ($this->force_stability === 'unstable') ? $info['unstable'] : $info['stable'];
389        }
390
391        return ($this->is_stable($this->current_version)) ? $info['stable'] : $info['unstable'];
392    }
393
394    /**
395    * Obtains the latest version information
396    *
397    * @param bool $force_update Ignores cached data. Defaults to false.
398    * @param bool $force_cache Force the use of the cache. Override $force_update.
399    * @return array Version info, includes stable and unstable data
400    * @throws version_check_exception
401    */
402    public function get_versions($force_update = false, $force_cache = false)
403    {
404        $cache_file = '_versioncheck_' . $this->host . $this->path . $this->file . $this->use_ssl;
405
406        $info = $this->cache->get($cache_file);
407
408        if ($info === false && $force_cache)
409        {
410            throw new version_check_exception('VERSIONCHECK_FAIL');
411        }
412        else if ($info === false || $force_update)
413        {
414            $info = $this->file_downloader->get($this->host, $this->path, $this->file, $this->use_ssl ? 443 : 80, 30);
415            $error_string = $this->file_downloader->get_error_string();
416
417            if (!empty($error_string))
418            {
419                throw new version_check_exception($error_string);
420            }
421
422            // Sanitize any data we retrieve from a server
423            $info = json_sanitizer::decode($info);
424
425            if (empty($info['stable']) && empty($info['unstable']))
426            {
427                throw new version_check_exception('VERSIONCHECK_FAIL');
428            }
429
430            $info['stable'] = (empty($info['stable'])) ? array() : $info['stable'];
431            $info['unstable'] = (empty($info['unstable'])) ? $info['stable'] : $info['unstable'];
432
433            $info = $this->validate_versions($info);
434
435            $this->cache->put($cache_file, $info, 86400); // 24 hours
436        }
437
438        return $info;
439    }
440
441    /**
442     * Validate versions info input
443     *
444     * @param array $versions_info Decoded json data array. Will be modified
445     *        and cleaned by this method
446     *
447     * @return array Versions info array
448     * @throws version_check_exception
449     */
450    public function validate_versions($versions_info)
451    {
452        $array_diff = array_diff_key($versions_info, array($this->version_schema));
453
454        // Remove excessive data
455        if (count($array_diff) > 0)
456        {
457            $old_versions_info = $versions_info;
458            $versions_info = array(
459                'stable'    => !empty($old_versions_info['stable']) ? $old_versions_info['stable'] : array(),
460                'unstable'    => !empty($old_versions_info['unstable']) ? $old_versions_info['unstable'] : array(),
461            );
462            unset($old_versions_info);
463        }
464
465        foreach ($versions_info as $stability_type => &$versions_data)
466        {
467            foreach ($versions_data as $branch => &$version_data)
468            {
469                if (!preg_match('/^[0-9a-z\-\.]+$/i', $branch))
470                {
471                    unset($versions_data[$branch]);
472                    continue;
473                }
474
475                $stability_diff = array_diff_key($version_data, $this->version_schema[$stability_type]);
476
477                if (count($stability_diff) > 0)
478                {
479                    $old_version_data = $version_data;
480                    $version_data = array();
481                    foreach ($this->version_schema[$stability_type] as $key => $value)
482                    {
483                        if (isset($old_version_data[$key]))
484                        {
485                            $version_data[$key] = $old_version_data[$key];
486                        }
487                    }
488                    unset($old_version_data);
489                }
490
491                foreach ($version_data as $key => &$value)
492                {
493                    if (!isset($this->version_schema[$stability_type][$key]))
494                    {
495                        unset($version_data[$key]);
496                        throw new version_check_exception('VERSIONCHECK_INVALID_ENTRY');
497                    }
498
499                    switch ($this->version_schema[$stability_type][$key])
500                    {
501                        case 'bool':
502                            $value = (bool) $value;
503                        break;
504
505                        case 'url':
506                            if (!empty($value) && !preg_match('#^' . get_preg_expression('url') . '$#iu', $value) &&
507                                !preg_match('#^' . get_preg_expression('www_url') . '$#iu', $value))
508                            {
509                                throw new version_check_exception('VERSIONCHECK_INVALID_URL');
510                            }
511                        break;
512
513                        case 'version':
514                            if (!empty($value) && !preg_match(get_preg_expression('semantic_version'), $value))
515                            {
516                                throw new version_check_exception('VERSIONCHECK_INVALID_VERSION');
517                            }
518                        break;
519
520                        default:
521                            // Shouldn't be possible to trigger this
522                            throw new version_check_exception('VERSIONCHECK_INVALID_ENTRY');
523                    }
524                }
525            }
526        }
527
528        return $versions_info;
529    }
530}