MDL-49329 admin: Start using API 1.3 for fetching available updates
[moodle.git] / lib / classes / update / checker.php
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/>.
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  */
24 namespace core\update;
26 use html_writer, coding_exception, core_component;
28 defined('MOODLE_INTERNAL') || die();
30 /**
31  * Singleton class that handles checking for available updates
32  */
33 class checker {
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();
50     /**
51      * Direct initiation not allowed, use the factory method {@link self::instance()}
52      */
53     protected function __construct() {
54     }
56     /**
57      * Sorry, this is singleton
58      */
59     protected function __clone() {
60     }
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     }
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     }
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;
97         return empty($CFG->disableupdatenotifications);
98     }
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() {
107         $this->restore_response();
109         if (!empty($this->recentfetch)) {
110             return $this->recentfetch;
112         } else {
113             return null;
114         }
115     }
117     /**
118      * Fetches the available update status from the remote site
119      *
120      * @throws checker_exception
121      */
122     public function fetch() {
124         $response = $this->get_response();
125         $this->validate_response($response);
126         $this->store_response($response);
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     }
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()) {
151         if (!isset($options['minmaturity'])) {
152             $options['minmaturity'] = 0;
153         }
155         if (!isset($options['notifybuilds'])) {
156             $options['notifybuilds'] = false;
157         }
159         if ($component === 'core') {
160             $this->load_current_environment();
161         }
163         $this->restore_response();
165         if (empty($this->recentresponse['updates'][$component])) {
166             return null;
167         }
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         }
186         if (empty($updates)) {
187             return null;
188         }
190         return $updates;
191     }
193     /**
194      * The method being run via cron.php
195      */
196     public function cron() {
197         global $CFG;
199         if (!$this->enabled() or !$this->cron_autocheck_enabled()) {
200             $this->cron_mtrace('Automatic check for available updates not enabled, skipping.');
201             return;
202         }
204         $now = $this->cron_current_timestamp();
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         }
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         }
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     }
226     /* === End of public API === */
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');
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     }
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) {
259         $response = $this->decode_response($response);
261         if (empty($response)) {
262             throw new checker_exception('err_response_empty');
263         }
265         if (empty($response['status']) or $response['status'] !== 'OK') {
266             throw new checker_exception('err_response_status', $response['status']);
267         }
269         if (empty($response['apiver']) or $response['apiver'] !== '1.3') {
270             throw new checker_exception('err_response_format_version', $response['apiver']);
271         }
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     }
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     }
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) {
297         set_config('recentfetch', time(), 'core_plugin');
298         set_config('recentresponse', $response, 'core_plugin');
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         }
306         $this->restore_response(true);
307     }
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) {
323         if (!$forcereload and !is_null($this->recentresponse)) {
324             // We already have it, nothing to do.
325             return;
326         }
328         $config = get_config('core_plugin');
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             }
343         } else {
344             $this->recentresponse = array();
345         }
346     }
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) {
360         if (empty($new)) {
361             return array();
362         }
364         if (!array_key_exists('updates', $new)) {
365             throw new checker_exception('err_response_format');
366         }
368         if (empty($old)) {
369             return $new['updates'];
370         }
372         if (!array_key_exists('updates', $old)) {
373             throw new checker_exception('err_response_format');
374         }
376         $changes = array();
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         }
399         return $changes;
400     }
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;
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     }
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;
428         if (!is_null($this->currentversion) and !$forcereload) {
429             // Nothing to do.
430             return;
431         }
433         $version = null;
434         $release = null;
436         require($CFG->dirroot.'/version.php');
437         $this->currentversion = $version;
438         $this->currentrelease = $release;
439         $this->currentbranch = moodle_major_version(true);
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     }
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;
459         $this->load_current_environment();
460         $this->restore_response();
462         $params = array();
463         $params['format'] = 'json';
465         if (isset($this->recentresponse['ticket'])) {
466             $params['ticket'] = $this->recentresponse['ticket'];
467         }
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         }
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         }
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         }
489         return $params;
490     }
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         );
503         return $options;
504     }
506     /**
507      * Returns the current timestamp
508      *
509      * @return int the timestamp
510      */
511     protected function cron_current_timestamp() {
512         return time();
513     }
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     }
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;
534         if (empty($CFG->updateautocheck)) {
535             return false;
536         } else {
537             return true;
538         }
539     }
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();
550         if (empty($recent)) {
551             return false;
552         }
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         }
559         if ($now - $recent > 24 * HOURSECS) {
560             return false;
561         }
563         return true;
564     }
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();
575         if (empty($recent)) {
576             return true;
577         }
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         }
584         if ($now - $recent > 48 * HOURSECS) {
585             return true;
586         }
588         return false;
589     }
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;
605         if (empty($CFG->updatecronoffset)) {
606             set_config('updatecronoffset', rand(1, 5 * HOURSECS));
607         }
609         return $CFG->updatecronoffset;
610     }
612     /**
613      * Fetch available updates info and eventually send notification to site admins
614      */
615     protected function cron_execute() {
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     }
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;
641         if (empty($changes)) {
642             return array();
643         }
645         $notifications = array();
646         $pluginman = \core_plugin_manager::instance();
647         $plugins = $pluginman->get_plugins();
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         }
695         return $notifications;
696     }
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;
706         if (empty($notifications)) {
707             $this->cron_mtrace('nothing to notify about. ', '');
708             return;
709         }
711         $admins = get_admins();
713         if (empty($admins)) {
714             return;
715         }
717         $this->cron_mtrace('sending notifications ... ', '');
719         $text = get_string('updatenotifications', 'core_admin') . PHP_EOL;
720         $html = html_writer::tag('h1', get_string('updatenotifications', 'core_admin')) . PHP_EOL;
722         $coreupdates = array();
723         $pluginupdates = array();
725         foreach ($notifications as $notification) {
726             if ($notification->component == 'core') {
727                 $coreupdates[] = $notification;
728             } else {
729                 $pluginupdates[] = $notification;
730             }
731         }
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;
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;
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         }
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;
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));
776                 $text .= ' ('.$pluginupdate->component.')';
777                 $html .= ' ('.$pluginupdate->component.')';
779                 $text .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
780                 $html .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
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;
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         }
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;')));
800         foreach ($admins as $admin) {
801             $message = new \stdClass();
802             $message->component         = 'moodle';
803             $message->name              = 'availableupdate';
804             $message->userfrom          = get_admin();
805             $message->userto            = $admin;
806             $message->subject           = get_string('updatenotificationsubject', 'core_admin', array('siteurl' => $CFG->wwwroot));
807             $message->fullmessage       = $text;
808             $message->fullmessageformat = FORMAT_PLAIN;
809             $message->fullmessagehtml   = $html;
810             $message->smallmessage      = get_string('updatenotifications', 'core_admin');
811             $message->notification      = 1;
812             message_send($message);
813         }
814     }
816     /**
817      * Compare two release labels and decide if they are the same
818      *
819      * @param string $remote release info of the available update
820      * @param null|string $local release info of the local code, defaults to $release defined in version.php
821      * @return boolean true if the releases declare the same minor+major version
822      */
823     protected function is_same_release($remote, $local=null) {
825         if (is_null($local)) {
826             $this->load_current_environment();
827             $local = $this->currentrelease;
828         }
830         $pattern = '/^([0-9\.\+]+)([^(]*)/';
832         preg_match($pattern, $remote, $remotematches);
833         preg_match($pattern, $local, $localmatches);
835         $remotematches[1] = str_replace('+', '', $remotematches[1]);
836         $localmatches[1] = str_replace('+', '', $localmatches[1]);
838         if ($remotematches[1] === $localmatches[1] and rtrim($remotematches[2]) === rtrim($localmatches[2])) {
839             return true;
840         } else {
841             return false;
842         }
843     }