Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   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   */
  24  namespace core\update;
  25  
  26  use html_writer, coding_exception, core_component;
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  
  30  /**
  31   * Singleton class that handles checking for available updates
  32   */
  33  class 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  }