1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Defines classes used for updates.
19 *
20 * @package    core
21 * @copyright  2011 David Mudrak <david@moodle.com>
22 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24namespace core\update;
25
26use html_writer, coding_exception, core_component;
27
28defined('MOODLE_INTERNAL') || die();
29
30/**
31 * Singleton class that handles checking for available updates
32 */
33class checker {
34
35    /** @var \core\update\checker holds the singleton instance */
36    protected static $singletoninstance;
37    /** @var null|int the timestamp of when the most recent response was fetched */
38    protected $recentfetch = null;
39    /** @var null|array the recent response from the update notification provider */
40    protected $recentresponse = null;
41    /** @var null|string the numerical version of the local Moodle code */
42    protected $currentversion = null;
43    /** @var null|string the release info of the local Moodle code */
44    protected $currentrelease = null;
45    /** @var null|string branch of the local Moodle code */
46    protected $currentbranch = null;
47    /** @var array of (string)frankestyle => (string)version list of additional plugins deployed at this site */
48    protected $currentplugins = array();
49
50    /**
51     * Direct initiation not allowed, use the factory method {@link self::instance()}
52     */
53    protected function __construct() {
54    }
55
56    /**
57     * Sorry, this is singleton
58     */
59    protected function __clone() {
60    }
61
62    /**
63     * Factory method for this class
64     *
65     * @return \core\update\checker the singleton instance
66     */
67    public static function instance() {
68        if (is_null(self::$singletoninstance)) {
69            self::$singletoninstance = new self();
70        }
71        return self::$singletoninstance;
72    }
73
74    /**
75     * Reset any caches
76     * @param bool $phpunitreset
77     */
78    public static function reset_caches($phpunitreset = false) {
79        if ($phpunitreset) {
80            self::$singletoninstance = null;
81        }
82    }
83
84    /**
85     * Is checking for available updates enabled?
86     *
87     * The feature is enabled unless it is prohibited via config.php.
88     * If enabled, the button for manual checking for available updates is
89     * displayed at admin screens. To perform scheduled checks for updates
90     * automatically, the admin setting $CFG->updateautocheck has to be enabled.
91     *
92     * @return bool
93     */
94    public function enabled() {
95        global $CFG;
96
97        return empty($CFG->disableupdatenotifications);
98    }
99
100    /**
101     * Returns the timestamp of the last execution of {@link fetch()}
102     *
103     * @return int|null null if it has never been executed or we don't known
104     */
105    public function get_last_timefetched() {
106
107        $this->restore_response();
108
109        if (!empty($this->recentfetch)) {
110            return $this->recentfetch;
111
112        } else {
113            return null;
114        }
115    }
116
117    /**
118     * Fetches the available update status from the remote site
119     *
120     * @throws checker_exception
121     */
122    public function fetch() {
123
124        $response = $this->get_response();
125        $this->validate_response($response);
126        $this->store_response($response);
127
128        // We need to reset plugin manager's caches - the currently existing
129        // singleton is not aware of eventually available updates we just fetched.
130        \core_plugin_manager::reset_caches();
131    }
132
133    /**
134     * Returns the available update information for the given component
135     *
136     * This method returns null if the most recent response does not contain any information
137     * about it. The returned structure is an array of available updates for the given
138     * component. Each update info is an object with at least one property called
139     * 'version'. Other possible properties are 'release', 'maturity', 'url' and 'downloadurl'.
140     *
141     * For the 'core' component, the method returns real updates only (those with higher version).
142     * For all other components, the list of all known remote updates is returned and the caller
143     * (usually the {@link core_plugin_manager}) is supposed to make the actual comparison of versions.
144     *
145     * @param string $component frankenstyle
146     * @param array $options with supported keys 'minmaturity' and/or 'notifybuilds'
147     * @return null|array null or array of \core\update\info objects
148     */
149    public function get_update_info($component, array $options = array()) {
150
151        if (!isset($options['minmaturity'])) {
152            $options['minmaturity'] = 0;
153        }
154
155        if (!isset($options['notifybuilds'])) {
156            $options['notifybuilds'] = false;
157        }
158
159        if ($component === 'core') {
160            $this->load_current_environment();
161        }
162
163        $this->restore_response();
164
165        if (empty($this->recentresponse['updates'][$component])) {
166            return null;
167        }
168
169        $updates = array();
170        foreach ($this->recentresponse['updates'][$component] as $info) {
171            $update = new info($component, $info);
172            if (isset($update->maturity) and ($update->maturity < $options['minmaturity'])) {
173                continue;
174            }
175            if ($component === 'core') {
176                if ($update->version <= $this->currentversion) {
177                    continue;
178                }
179                if (empty($options['notifybuilds']) and $this->is_same_release($update->release)) {
180                    continue;
181                }
182            }
183            $updates[] = $update;
184        }
185
186        if (empty($updates)) {
187            return null;
188        }
189
190        return $updates;
191    }
192
193    /**
194     * The method being run via cron.php
195     */
196    public function cron() {
197        global $CFG;
198
199        if (!$this->enabled() or !$this->cron_autocheck_enabled()) {
200            $this->cron_mtrace('Automatic check for available updates not enabled, skipping.');
201            return;
202        }
203
204        $now = $this->cron_current_timestamp();
205
206        if ($this->cron_has_fresh_fetch($now)) {
207            $this->cron_mtrace('Recently fetched info about available updates is still fresh enough, skipping.');
208            return;
209        }
210
211        if ($this->cron_has_outdated_fetch($now)) {
212            $this->cron_mtrace('Outdated or missing info about available updates, forced fetching ... ', '');
213            $this->cron_execute();
214            return;
215        }
216
217        $offset = $this->cron_execution_offset();
218        $start = mktime(1, 0, 0, date('n', $now), date('j', $now), date('Y', $now)); // 01:00 AM today local time
219        if ($now > $start + $offset) {
220            $this->cron_mtrace('Regular daily check for available updates ... ', '');
221            $this->cron_execute();
222            return;
223        }
224    }
225
226    /* === End of public API === */
227
228    /**
229     * Makes cURL request to get data from the remote site
230     *
231     * @return string raw request result
232     * @throws checker_exception
233     */
234    protected function get_response() {
235        global $CFG;
236        require_once($CFG->libdir.'/filelib.php');
237
238        $curl = new \curl(array('proxy' => true));
239        $response = $curl->post($this->prepare_request_url(), $this->prepare_request_params(), $this->prepare_request_options());
240        $curlerrno = $curl->get_errno();
241        if (!empty($curlerrno)) {
242            throw new checker_exception('err_response_curl', 'cURL error '.$curlerrno.': '.$curl->error);
243        }
244        $curlinfo = $curl->get_info();
245        if ($curlinfo['http_code'] != 200) {
246            throw new checker_exception('err_response_http_code', $curlinfo['http_code']);
247        }
248        return $response;
249    }
250
251    /**
252     * Makes sure the response is valid, has correct API format etc.
253     *
254     * @param string $response raw response as returned by the {@link self::get_response()}
255     * @throws checker_exception
256     */
257    protected function validate_response($response) {
258
259        $response = $this->decode_response($response);
260
261        if (empty($response)) {
262            throw new checker_exception('err_response_empty');
263        }
264
265        if (empty($response['status']) or $response['status'] !== 'OK') {
266            throw new checker_exception('err_response_status', $response['status']);
267        }
268
269        if (empty($response['apiver']) or $response['apiver'] !== '1.3') {
270            throw new checker_exception('err_response_format_version', $response['apiver']);
271        }
272
273        if (empty($response['forbranch']) or $response['forbranch'] !== moodle_major_version(true)) {
274            throw new checker_exception('err_response_target_version', $response['forbranch']);
275        }
276    }
277
278    /**
279     * Decodes the raw string response from the update notifications provider
280     *
281     * @param string $response as returned by {@link self::get_response()}
282     * @return array decoded response structure
283     */
284    protected function decode_response($response) {
285        return json_decode($response, true);
286    }
287
288    /**
289     * Stores the valid fetched response for later usage
290     *
291     * This implementation uses the config_plugins table as the permanent storage.
292     *
293     * @param string $response raw valid data returned by {@link self::get_response()}
294     */
295    protected function store_response($response) {
296
297        set_config('recentfetch', time(), 'core_plugin');
298        set_config('recentresponse', $response, 'core_plugin');
299
300        if (defined('CACHE_DISABLE_ALL') and CACHE_DISABLE_ALL) {
301            // Very nasty hack to work around cache coherency issues on admin/index.php?cache=0 page,
302            // we definitely need to keep caches in sync when writing into DB at all times!
303            \cache_helper::purge_all(true);
304        }
305
306        $this->restore_response(true);
307    }
308
309    /**
310     * Loads the most recent raw response record we have fetched
311     *
312     * After this method is called, $this->recentresponse is set to an array. If the
313     * array is empty, then either no data have been fetched yet or the fetched data
314     * do not have expected format (and thence they are ignored and a debugging
315     * message is displayed).
316     *
317     * This implementation uses the config_plugins table as the permanent storage.
318     *
319     * @param bool $forcereload reload even if it was already loaded
320     */
321    protected function restore_response($forcereload = false) {
322
323        if (!$forcereload and !is_null($this->recentresponse)) {
324            // We already have it, nothing to do.
325            return;
326        }
327
328        $config = get_config('core_plugin');
329
330        if (!empty($config->recentresponse) and !empty($config->recentfetch)) {
331            try {
332                $this->validate_response($config->recentresponse);
333                $this->recentfetch = $config->recentfetch;
334                $this->recentresponse = $this->decode_response($config->recentresponse);
335            } catch (checker_exception $e) {
336                // The server response is not valid. Behave as if no data were fetched yet.
337                // This may happen when the most recent update info (cached locally) has been
338                // fetched with the previous branch of Moodle (like during an upgrade from 2.x
339                // to 2.y) or when the API of the response has changed.
340                $this->recentresponse = array();
341            }
342
343        } else {
344            $this->recentresponse = array();
345        }
346    }
347
348    /**
349     * Compares two raw {@link $recentresponse} records and returns the list of changed updates
350     *
351     * This method is used to populate potential update info to be sent to site admins.
352     *
353     * @param array $old
354     * @param array $new
355     * @throws checker_exception
356     * @return array parts of $new['updates'] that have changed
357     */
358    protected function compare_responses(array $old, array $new) {
359
360        if (empty($new)) {
361            return array();
362        }
363
364        if (!array_key_exists('updates', $new)) {
365            throw new checker_exception('err_response_format');
366        }
367
368        if (empty($old)) {
369            return $new['updates'];
370        }
371
372        if (!array_key_exists('updates', $old)) {
373            throw new checker_exception('err_response_format');
374        }
375
376        $changes = array();
377
378        foreach ($new['updates'] as $newcomponent => $newcomponentupdates) {
379            if (empty($old['updates'][$newcomponent])) {
380                $changes[$newcomponent] = $newcomponentupdates;
381                continue;
382            }
383            foreach ($newcomponentupdates as $newcomponentupdate) {
384                $inold = false;
385                foreach ($old['updates'][$newcomponent] as $oldcomponentupdate) {
386                    if ($newcomponentupdate['version'] == $oldcomponentupdate['version']) {
387                        $inold = true;
388                    }
389                }
390                if (!$inold) {
391                    if (!isset($changes[$newcomponent])) {
392                        $changes[$newcomponent] = array();
393                    }
394                    $changes[$newcomponent][] = $newcomponentupdate;
395                }
396            }
397        }
398
399        return $changes;
400    }
401
402    /**
403     * Returns the URL to send update requests to
404     *
405     * During the development or testing, you can set $CFG->alternativeupdateproviderurl
406     * to a custom URL that will be used. Otherwise the standard URL will be returned.
407     *
408     * @return string URL
409     */
410    protected function prepare_request_url() {
411        global $CFG;
412
413        if (!empty($CFG->config_php_settings['alternativeupdateproviderurl'])) {
414            return $CFG->config_php_settings['alternativeupdateproviderurl'];
415        } else {
416            return 'https://download.moodle.org/api/1.3/updates.php';
417        }
418    }
419
420    /**
421     * Sets the properties currentversion, currentrelease, currentbranch and currentplugins
422     *
423     * @param bool $forcereload
424     */
425    protected function load_current_environment($forcereload=false) {
426        global $CFG;
427
428        if (!is_null($this->currentversion) and !$forcereload) {
429            // Nothing to do.
430            return;
431        }
432
433        $version = null;
434        $release = null;
435
436        require($CFG->dirroot.'/version.php');
437        $this->currentversion = $version;
438        $this->currentrelease = $release;
439        $this->currentbranch = moodle_major_version(true);
440
441        $pluginman = \core_plugin_manager::instance();
442        foreach ($pluginman->get_plugins() as $type => $plugins) {
443            foreach ($plugins as $plugin) {
444                if (!$plugin->is_standard()) {
445                    $this->currentplugins[$plugin->component] = $plugin->versiondisk;
446                }
447            }
448        }
449    }
450
451    /**
452     * Returns the list of HTTP params to be sent to the updates provider URL
453     *
454     * @return array of (string)param => (string)value
455     */
456    protected function prepare_request_params() {
457        global $CFG;
458
459        $this->load_current_environment();
460        $this->restore_response();
461
462        $params = array();
463        $params['format'] = 'json';
464
465        if (isset($this->recentresponse['ticket'])) {
466            $params['ticket'] = $this->recentresponse['ticket'];
467        }
468
469        if (isset($this->currentversion)) {
470            $params['version'] = $this->currentversion;
471        } else {
472            throw new coding_exception('Main Moodle version must be already known here');
473        }
474
475        if (isset($this->currentbranch)) {
476            $params['branch'] = $this->currentbranch;
477        } else {
478            throw new coding_exception('Moodle release must be already known here');
479        }
480
481        $plugins = array();
482        foreach ($this->currentplugins as $plugin => $version) {
483            $plugins[] = $plugin.'@'.$version;
484        }
485        if (!empty($plugins)) {
486            $params['plugins'] = implode(',', $plugins);
487        }
488
489        return $params;
490    }
491
492    /**
493     * Returns the list of cURL options to use when fetching available updates data
494     *
495     * @return array of (string)param => (string)value
496     */
497    protected function prepare_request_options() {
498        $options = array(
499            'CURLOPT_SSL_VERIFYHOST' => 2,      // This is the default in {@link curl} class but just in case.
500            'CURLOPT_SSL_VERIFYPEER' => true,
501        );
502
503        return $options;
504    }
505
506    /**
507     * Returns the current timestamp
508     *
509     * @return int the timestamp
510     */
511    protected function cron_current_timestamp() {
512        return time();
513    }
514
515    /**
516     * Output cron debugging info
517     *
518     * @see mtrace()
519     * @param string $msg output message
520     * @param string $eol end of line
521     */
522    protected function cron_mtrace($msg, $eol = PHP_EOL) {
523        mtrace($msg, $eol);
524    }
525
526    /**
527     * Decide if the autocheck feature is disabled in the server setting
528     *
529     * @return bool true if autocheck enabled, false if disabled
530     */
531    protected function cron_autocheck_enabled() {
532        global $CFG;
533
534        if (empty($CFG->updateautocheck)) {
535            return false;
536        } else {
537            return true;
538        }
539    }
540
541    /**
542     * Decide if the recently fetched data are still fresh enough
543     *
544     * @param int $now current timestamp
545     * @return bool true if no need to re-fetch, false otherwise
546     */
547    protected function cron_has_fresh_fetch($now) {
548        $recent = $this->get_last_timefetched();
549
550        if (empty($recent)) {
551            return false;
552        }
553
554        if ($now < $recent) {
555            $this->cron_mtrace('The most recent fetch is reported to be in the future, this is weird!');
556            return true;
557        }
558
559        if ($now - $recent > 24 * HOURSECS) {
560            return false;
561        }
562
563        return true;
564    }
565
566    /**
567     * Decide if the fetch is outadated or even missing
568     *
569     * @param int $now current timestamp
570     * @return bool false if no need to re-fetch, true otherwise
571     */
572    protected function cron_has_outdated_fetch($now) {
573        $recent = $this->get_last_timefetched();
574
575        if (empty($recent)) {
576            return true;
577        }
578
579        if ($now < $recent) {
580            $this->cron_mtrace('The most recent fetch is reported to be in the future, this is weird!');
581            return false;
582        }
583
584        if ($now - $recent > 48 * HOURSECS) {
585            return true;
586        }
587
588        return false;
589    }
590
591    /**
592     * Returns the cron execution offset for this site
593     *
594     * The main {@link self::cron()} is supposed to run every night in some random time
595     * between 01:00 and 06:00 AM (local time). The exact moment is defined by so called
596     * execution offset, that is the amount of time after 01:00 AM. The offset value is
597     * initially generated randomly and then used consistently at the site. This way, the
598     * regular checks against the download.moodle.org server are spread in time.
599     *
600     * @return int the offset number of seconds from range 1 sec to 5 hours
601     */
602    protected function cron_execution_offset() {
603        global $CFG;
604
605        if (empty($CFG->updatecronoffset)) {
606            set_config('updatecronoffset', rand(1, 5 * HOURSECS));
607        }
608
609        return $CFG->updatecronoffset;
610    }
611
612    /**
613     * Fetch available updates info and eventually send notification to site admins
614     */
615    protected function cron_execute() {
616
617        try {
618            $this->restore_response();
619            $previous = $this->recentresponse;
620            $this->fetch();
621            $this->restore_response(true);
622            $current = $this->recentresponse;
623            $changes = $this->compare_responses($previous, $current);
624            $notifications = $this->cron_notifications($changes);
625            $this->cron_notify($notifications);
626            $this->cron_mtrace('done');
627        } catch (checker_exception $e) {
628            $this->cron_mtrace('FAILED!');
629        }
630    }
631
632    /**
633     * Given the list of changes in available updates, pick those to send to site admins
634     *
635     * @param array $changes as returned by {@link self::compare_responses()}
636     * @return array of \core\update\info objects to send to site admins
637     */
638    protected function cron_notifications(array $changes) {
639        global $CFG;
640
641        if (empty($changes)) {
642            return array();
643        }
644
645        $notifications = array();
646        $pluginman = \core_plugin_manager::instance();
647        $plugins = $pluginman->get_plugins();
648
649        foreach ($changes as $component => $componentchanges) {
650            if (empty($componentchanges)) {
651                continue;
652            }
653            $componentupdates = $this->get_update_info($component,
654                array('minmaturity' => $CFG->updateminmaturity, 'notifybuilds' => $CFG->updatenotifybuilds));
655            if (empty($componentupdates)) {
656                continue;
657            }
658            // Notify only about those $componentchanges that are present in $componentupdates
659            // to respect the preferences.
660            foreach ($componentchanges as $componentchange) {
661                foreach ($componentupdates as $componentupdate) {
662                    if ($componentupdate->version == $componentchange['version']) {
663                        if ($component == 'core') {
664                            // In case of 'core', we already know that the $componentupdate
665                            // is a real update with higher version ({@see self::get_update_info()}).
666                            // We just perform additional check for the release property as there
667                            // can be two Moodle releases having the same version (e.g. 2.4.0 and 2.5dev shortly
668                            // after the release). We can do that because we have the release info
669                            // always available for the core.
670                            if ((string)$componentupdate->release === (string)$componentchange['release']) {
671                                $notifications[] = $componentupdate;
672                            }
673                        } else {
674                            // Use the core_plugin_manager to check if the detected $componentchange
675                            // is a real update with higher version. That is, the $componentchange
676                            // is present in the array of {@link \core\update\info} objects
677                            // returned by the plugin's available_updates() method.
678                            list($plugintype, $pluginname) = core_component::normalize_component($component);
679                            if (!empty($plugins[$plugintype][$pluginname])) {
680                                $availableupdates = $plugins[$plugintype][$pluginname]->available_updates();
681                                if (!empty($availableupdates)) {
682                                    foreach ($availableupdates as $availableupdate) {
683                                        if ($availableupdate->version == $componentchange['version']) {
684                                            $notifications[] = $componentupdate;
685                                        }
686                                    }
687                                }
688                            }
689                        }
690                    }
691                }
692            }
693        }
694
695        return $notifications;
696    }
697
698    /**
699     * Sends the given notifications to site admins via messaging API
700     *
701     * @param array $notifications array of \core\update\info objects to send
702     */
703    protected function cron_notify(array $notifications) {
704        global $CFG;
705
706        if (empty($notifications)) {
707            $this->cron_mtrace('nothing to notify about. ', '');
708            return;
709        }
710
711        $admins = get_admins();
712
713        if (empty($admins)) {
714            return;
715        }
716
717        $this->cron_mtrace('sending notifications ... ', '');
718
719        $text = get_string('updatenotifications', 'core_admin') . PHP_EOL;
720        $html = html_writer::tag('h1', get_string('updatenotifications', 'core_admin')) . PHP_EOL;
721
722        $coreupdates = array();
723        $pluginupdates = array();
724
725        foreach ($notifications as $notification) {
726            if ($notification->component == 'core') {
727                $coreupdates[] = $notification;
728            } else {
729                $pluginupdates[] = $notification;
730            }
731        }
732
733        if (!empty($coreupdates)) {
734            $text .= PHP_EOL . get_string('updateavailable', 'core_admin') . PHP_EOL;
735            $html .= html_writer::tag('h2', get_string('updateavailable', 'core_admin')) . PHP_EOL;
736            $html .= html_writer::start_tag('ul') . PHP_EOL;
737            foreach ($coreupdates as $coreupdate) {
738                $html .= html_writer::start_tag('li');
739                if (isset($coreupdate->release)) {
740                    $text .= get_string('updateavailable_release', 'core_admin', $coreupdate->release);
741                    $html .= html_writer::tag('strong', get_string('updateavailable_release', 'core_admin', $coreupdate->release));
742                }
743                if (isset($coreupdate->version)) {
744                    $text .= ' '.get_string('updateavailable_version', 'core_admin', $coreupdate->version);
745                    $html .= ' '.get_string('updateavailable_version', 'core_admin', $coreupdate->version);
746                }
747                if (isset($coreupdate->maturity)) {
748                    $text .= ' ('.get_string('maturity'.$coreupdate->maturity, 'core_admin').')';
749                    $html .= ' ('.get_string('maturity'.$coreupdate->maturity, 'core_admin').')';
750                }
751                $text .= PHP_EOL;
752                $html .= html_writer::end_tag('li') . PHP_EOL;
753            }
754            $text .= PHP_EOL;
755            $html .= html_writer::end_tag('ul') . PHP_EOL;
756
757            $a = array('url' => $CFG->wwwroot.'/'.$CFG->admin.'/index.php');
758            $text .= get_string('updateavailabledetailslink', 'core_admin', $a) . PHP_EOL;
759            $a = array('url' => html_writer::link($CFG->wwwroot.'/'.$CFG->admin.'/index.php', $CFG->wwwroot.'/'.$CFG->admin.'/index.php'));
760            $html .= html_writer::tag('p', get_string('updateavailabledetailslink', 'core_admin', $a)) . PHP_EOL;
761
762            $text .= PHP_EOL . get_string('updateavailablerecommendation', 'core_admin') . PHP_EOL;
763            $html .= html_writer::tag('p', get_string('updateavailablerecommendation', 'core_admin')) . PHP_EOL;
764        }
765
766        if (!empty($pluginupdates)) {
767            $text .= PHP_EOL . get_string('updateavailableforplugin', 'core_admin') . PHP_EOL;
768            $html .= html_writer::tag('h2', get_string('updateavailableforplugin', 'core_admin')) . PHP_EOL;
769
770            $html .= html_writer::start_tag('ul') . PHP_EOL;
771            foreach ($pluginupdates as $pluginupdate) {
772                $html .= html_writer::start_tag('li');
773                $text .= get_string('pluginname', $pluginupdate->component);
774                $html .= html_writer::tag('strong', get_string('pluginname', $pluginupdate->component));
775
776                $text .= ' ('.$pluginupdate->component.')';
777                $html .= ' ('.$pluginupdate->component.')';
778
779                $text .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
780                $html .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
781
782                $text .= PHP_EOL;
783                $html .= html_writer::end_tag('li') . PHP_EOL;
784            }
785            $text .= PHP_EOL;
786            $html .= html_writer::end_tag('ul') . PHP_EOL;
787
788            $a = array('url' => $CFG->wwwroot.'/'.$CFG->admin.'/plugins.php');
789            $text .= get_string('updateavailabledetailslink', 'core_admin', $a) . PHP_EOL;
790            $a = array('url' => html_writer::link($CFG->wwwroot.'/'.$CFG->admin.'/plugins.php', $CFG->wwwroot.'/'.$CFG->admin.'/plugins.php'));
791            $html .= html_writer::tag('p', get_string('updateavailabledetailslink', 'core_admin', $a)) . PHP_EOL;
792        }
793
794        $a = array('siteurl' => $CFG->wwwroot);
795        $text .= PHP_EOL . get_string('updatenotificationfooter', 'core_admin', $a) . PHP_EOL;
796        $a = array('siteurl' => html_writer::link($CFG->wwwroot, $CFG->wwwroot));
797        $html .= html_writer::tag('footer', html_writer::tag('p', get_string('updatenotificationfooter', 'core_admin', $a),
798            array('style' => 'font-size:smaller; color:#333;')));
799
800        foreach ($admins as $admin) {
801            $message = new \core\message\message();
802            $message->courseid          = SITEID;
803            $message->component         = 'moodle';
804            $message->name              = 'availableupdate';
805            $message->userfrom          = get_admin();
806            $message->userto            = $admin;
807            $message->subject           = get_string('updatenotificationsubject', 'core_admin', array('siteurl' => $CFG->wwwroot));
808            $message->fullmessage       = $text;
809            $message->fullmessageformat = FORMAT_PLAIN;
810            $message->fullmessagehtml   = $html;
811            $message->smallmessage      = get_string('updatenotifications', 'core_admin');
812            $message->notification      = 1;
813            message_send($message);
814        }
815    }
816
817    /**
818     * Compare two release labels and decide if they are the same
819     *
820     * @param string $remote release info of the available update
821     * @param null|string $local release info of the local code, defaults to $release defined in version.php
822     * @return boolean true if the releases declare the same minor+major version
823     */
824    protected function is_same_release($remote, $local=null) {
825
826        if (is_null($local)) {
827            $this->load_current_environment();
828            $local = $this->currentrelease;
829        }
830
831        $pattern = '/^([0-9\.\+]+)([^(]*)/';
832
833        preg_match($pattern, $remote, $remotematches);
834        preg_match($pattern, $local, $localmatches);
835
836        $remotematches[1] = str_replace('+', '', $remotematches[1]);
837        $localmatches[1] = str_replace('+', '', $localmatches[1]);
838
839        if ($remotematches[1] === $localmatches[1] and rtrim($remotematches[2]) === rtrim($localmatches[2])) {
840            return true;
841        } else {
842            return false;
843        }
844    }
845}
846