Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

   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   * Incoming Message address manager.
  19   *
  20   * @package    core_message
  21   * @copyright  2014 Andrew Nicols <andrew@nicols.co.uk>
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core\message\inbound;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  /**
  30   * Incoming Message address manager.
  31   *
  32   * @copyright  2014 Andrew Nicols <andrew@nicols.co.uk>
  33   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   */
  35  class address_manager {
  36  
  37      /**
  38       * @var int The size of the hash component of the address.
  39       * Note: Increasing this value will invalidate all previous key values
  40       * and reduce the potential length of the e-mail address being checked.
  41       * Do not change this value.
  42       */
  43      const HASHSIZE = 24;
  44  
  45      /**
  46       * @var int A validation status indicating successful validation
  47       */
  48      const VALIDATION_SUCCESS = 0;
  49  
  50      /**
  51       * @var int A validation status indicating an invalid address format.
  52       * Typically this is an address which does not contain a subaddress or
  53       * all of the required data.
  54       */
  55      const VALIDATION_INVALID_ADDRESS_FORMAT = 1;
  56  
  57      /**
  58       * @var int A validation status indicating that a handler could not
  59       * be found for this address.
  60       */
  61      const VALIDATION_UNKNOWN_HANDLER = 2;
  62  
  63      /**
  64       * @var int A validation status indicating that an unknown user was specified.
  65       */
  66      const VALIDATION_UNKNOWN_USER = 4;
  67  
  68      /**
  69       * @var int A validation status indicating that the data key specified could not be found.
  70       */
  71      const VALIDATION_UNKNOWN_DATAKEY = 8;
  72  
  73      /**
  74       * @var int A validation status indicating that the mail processing handler was not enabled.
  75       */
  76      const VALIDATION_DISABLED_HANDLER = 16;
  77  
  78      /**
  79       * @var int A validation status indicating that the user specified was deleted or unconfirmed.
  80       */
  81      const VALIDATION_DISABLED_USER = 32;
  82  
  83      /**
  84       * @var int A validation status indicating that the datakey specified had reached it's expiration time.
  85       */
  86      const VALIDATION_EXPIRED_DATAKEY = 64;
  87  
  88      /**
  89       * @var int A validation status indicating that the hash could not be verified.
  90       */
  91      const VALIDATION_INVALID_HASH = 128;
  92  
  93      /**
  94       * @var int A validation status indicating that the originator address did not match the user on record.
  95       */
  96      const VALIDATION_ADDRESS_MISMATCH = 256;
  97  
  98      /**
  99       * The handler for the subsequent Inbound Message commands.
 100       * @var \core\message\inbound\handler
 101       */
 102      private $handler;
 103  
 104      /**
 105       * The ID of the data record
 106       * @var int
 107       */
 108      private $datavalue;
 109  
 110      /**
 111       * The ID of the data record
 112       * @var string
 113       */
 114      private $datakey;
 115  
 116      /**
 117       * The processed data record.
 118       * @var \stdClass
 119       */
 120      private $record;
 121  
 122      /**
 123       * The user.
 124       * @var \stdClass
 125       */
 126      private $user;
 127  
 128      /**
 129       * Set the handler to use for the subsequent Inbound Message commands.
 130       *
 131       * @param string $classname The name of the class for the handler.
 132       */
 133      public function set_handler($classname) {
 134          $this->handler = manager::get_handler($classname);
 135      }
 136  
 137      /**
 138       * Return the active handler.
 139       *
 140       * @return \core\message\inbound\handler|null;
 141       */
 142      public function get_handler() {
 143          return $this->handler;
 144      }
 145  
 146      /**
 147       * Specify an integer data item value for this record.
 148       *
 149       * @param int $datavalue The value of the data item.
 150       * @param string $datakey A hash to use for the datakey
 151       */
 152      public function set_data($datavalue, $datakey = null) {
 153          $this->datavalue = $datavalue;
 154  
 155          // We must clear the datakey when changing the datavalue.
 156          $this->set_data_key($datakey);
 157      }
 158  
 159      /**
 160       * Specify a known data key for this data item.
 161       *
 162       * If specified, the datakey must already exist in the messageinbound_datakeys
 163       * table, typically as a result of a previous Inbound Message setup.
 164       *
 165       * This is intended as a performance optimisation when sending many
 166       * e-mails with different data to many users.
 167       *
 168       * @param string $datakey A hash to use for the datakey
 169       */
 170      public function set_data_key($datakey = null) {
 171          $this->datakey = $datakey;
 172      }
 173  
 174      /**
 175       * Return the data key for the data item.
 176       *
 177       * If no data key has been defined yet, this will call generate_data_key() to generate a new key on the fly.
 178       * @return string The secret key for this data item.
 179       */
 180      public function fetch_data_key() {
 181          global $CFG, $DB;
 182  
 183          // Only generate a key if Inbound Message is actually enabled, and the handler is enabled.
 184          if (!isset($CFG->messageinbound_enabled) || !$this->handler || !$this->handler->enabled) {
 185              return null;
 186          }
 187  
 188          if (!isset($this->datakey)) {
 189              // Attempt to fetch an existing key first if one has not already been specified.
 190              $datakey = $DB->get_field('messageinbound_datakeys', 'datakey', array(
 191                      'handler' => $this->handler->id,
 192                      'datavalue' => $this->datavalue,
 193                  ));
 194              if (!$datakey) {
 195                  $datakey = $this->generate_data_key();
 196              }
 197              $this->datakey = $datakey;
 198          }
 199  
 200          return $this->datakey;
 201      }
 202  
 203      /**
 204       * Generate a new secret key for the current data item and handler combination.
 205       *
 206       * @return string The new generated secret key for this data item.
 207       */
 208      protected function generate_data_key() {
 209          global $DB;
 210  
 211          $key = new \stdClass();
 212          $key->handler = $this->handler->id;
 213          $key->datavalue = $this->datavalue;
 214          $key->datakey = md5($this->datavalue . '_' . time() . random_string(40));
 215          $key->timecreated = time();
 216  
 217          if ($this->handler->defaultexpiration) {
 218              // Apply the default expiration time to the datakey.
 219              $key->expires = $key->timecreated + $this->handler->defaultexpiration;
 220          }
 221          $DB->insert_record('messageinbound_datakeys', $key);
 222  
 223          return $key->datakey;
 224      }
 225  
 226      /**
 227       * Generate an e-mail address for the Inbound Message handler, storing a private
 228       * key for the data object if one was not specified.
 229       *
 230       * @param int $userid The ID of the user to generated an address for.
 231       * @param string $userkey The unique key for this user. If not specified this will be retrieved using
 232       * get_user_key(). This key must have been created using get_user_key(). This parameter is provided as a performance
 233       * optimisation for when generating multiple addresses for the same user.
 234       * @return string|null The generated address, or null if an address could not be generated.
 235       */
 236      public function generate($userid, $userkey = null) {
 237          global $CFG;
 238  
 239          // Ensure that Inbound Message is enabled and that there is enough information to proceed.
 240          if (!manager::is_enabled()) {
 241              return null;
 242          }
 243  
 244          if ($userkey == null) {
 245              $userkey = get_user_key('messageinbound_handler', $userid);
 246          }
 247  
 248          // Ensure that the minimum requirements are in place.
 249          if (!isset($this->handler) || !$this->handler) {
 250              throw new \coding_exception('Inbound Message handler not specified.');
 251          }
 252  
 253          // Ensure that the requested handler is actually enabled.
 254          if (!$this->handler->enabled) {
 255              return null;
 256          }
 257  
 258          if (!isset($this->datavalue)) {
 259              throw new \coding_exception('Inbound Message data item has not been specified.');
 260          }
 261  
 262          $data = array(
 263              self::pack_int($this->handler->id),
 264              self::pack_int($userid),
 265              self::pack_int($this->datavalue),
 266              pack('H*', substr(md5($this->fetch_data_key() . $userkey), 0, self::HASHSIZE)),
 267          );
 268          $subaddress = base64_encode(implode($data));
 269  
 270          return $CFG->messageinbound_mailbox . '+' . $subaddress . '@' . $CFG->messageinbound_domain;
 271      }
 272  
 273      /**
 274       * Determine whether the supplied address is of the correct format.
 275       *
 276       * @param string $address The address to test
 277       * @return bool Whether the address matches the correct format
 278       */
 279      public static function is_correct_format($address) {
 280          global $CFG;
 281          // Messages must match the format mailbox+[data]@domain.
 282          return preg_match('/' . $CFG->messageinbound_mailbox . '\+[^@]*@' . $CFG->messageinbound_domain . '/', $address);
 283      }
 284  
 285      /**
 286       * Process an inbound address to obtain the data stored within it.
 287       *
 288       * @param string $address The fully formed e-mail address to process.
 289       */
 290      protected function process($address) {
 291          global $DB;
 292  
 293          if (!self::is_correct_format($address)) {
 294              // This address does not contain a subaddress to parse.
 295              return;
 296          }
 297  
 298          // Ensure that the instance record is empty.
 299          $this->record = null;
 300  
 301          $record = new \stdClass();
 302          $record->address = $address;
 303  
 304          list($localpart) = explode('@', $address, 2);
 305          list($record->mailbox, $encodeddata) = explode('+', $localpart, 2);
 306          $data = base64_decode($encodeddata, true);
 307          if (!$data) {
 308              // This address has no valid data.
 309              return;
 310          }
 311  
 312          $content = @unpack('N2handlerid/N2userid/N2datavalue/H*datakey', $data);
 313  
 314          if (!$content) {
 315              // This address has no data.
 316              return;
 317          }
 318  
 319          if (PHP_INT_SIZE === 8) {
 320              // 64-bit machine.
 321              $content['handlerid'] = $content['handlerid1'] << 32 | $content['handlerid2'];
 322              $content['userid']    = $content['userid1'] << 32    | $content['userid2'];
 323              $content['datavalue'] = $content['datavalue1'] << 32 | $content['datavalue2'];
 324          } else {
 325              if ($content['handlerid1'] > 0 || $content['userid1'] > 0 || $content['datavalue1'] > 0) {
 326                  // Any 64-bit integer which is greater than the 32-bit integer size will have a non-zero value in the first
 327                  // half of the integer.
 328                  throw new \moodle_exception('Mixed environment.' .
 329                      ' Key generated with a 64-bit machine but received into a 32-bit machine.');
 330              }
 331              $content['handlerid'] = $content['handlerid2'];
 332              $content['userid']    = $content['userid2'];
 333              $content['datavalue'] = $content['datavalue2'];
 334          }
 335  
 336          // Clear the 32-bit to 64-bit variables away.
 337          unset($content['handlerid1']);
 338          unset($content['handlerid2']);
 339          unset($content['userid1']);
 340          unset($content['userid2']);
 341          unset($content['datavalue1']);
 342          unset($content['datavalue2']);
 343  
 344          $record = (object) array_merge((array) $record, $content);
 345  
 346          // Fetch the user record.
 347          $record->user = $DB->get_record('user', array('id' => $record->userid));
 348  
 349          // Fetch and set the handler.
 350          if ($handler = manager::get_handler_from_id($record->handlerid)) {
 351              $this->handler = $handler;
 352  
 353              // Retrieve the record for the data key.
 354              $record->data = $DB->get_record('messageinbound_datakeys',
 355                      array('handler' => $handler->id, 'datavalue' => $record->datavalue));
 356          }
 357  
 358          $this->record = $record;
 359      }
 360  
 361      /**
 362       * Retrieve the data parsed from the address.
 363       *
 364       * @return \stdClass the parsed data.
 365       */
 366      public function get_data() {
 367          return $this->record;
 368      }
 369  
 370      /**
 371       * Ensure that the parsed data is valid, and if the handler requires address validation, validate the sender against
 372       * the user record of identified user record.
 373       *
 374       * @param string $address The fully formed e-mail address to process.
 375       * @return int The validation status.
 376       */
 377      protected function validate($address) {
 378          if (!$this->record) {
 379              // The record does not exist, so there is nothing to validate against.
 380              return self::VALIDATION_INVALID_ADDRESS_FORMAT;
 381          }
 382  
 383          // Build the list of validation errors.
 384          $returnvalue = 0;
 385  
 386          if (!$this->handler) {
 387              $returnvalue += self::VALIDATION_UNKNOWN_HANDLER;
 388          } else if (!$this->handler->enabled) {
 389              $returnvalue += self::VALIDATION_DISABLED_HANDLER;
 390          }
 391  
 392          if (!isset($this->record->data) || !$this->record->data) {
 393              $returnvalue += self::VALIDATION_UNKNOWN_DATAKEY;
 394          } else if ($this->record->data->expires != 0 && $this->record->data->expires < time()) {
 395              $returnvalue += self::VALIDATION_EXPIRED_DATAKEY;
 396          } else {
 397  
 398              if (!$this->record->user) {
 399                  $returnvalue += self::VALIDATION_UNKNOWN_USER;
 400              } else {
 401                  if ($this->record->user->deleted || !$this->record->user->confirmed) {
 402                      $returnvalue += self::VALIDATION_DISABLED_USER;
 403                  }
 404  
 405                  $userkey = get_user_key('messageinbound_handler', $this->record->user->id);
 406                  $hashvalidation = substr(md5($this->record->data->datakey . $userkey), 0, self::HASHSIZE) == $this->record->datakey;
 407                  if (!$hashvalidation) {
 408                      // The address data did not check out, so the originator is deemed invalid.
 409                      $returnvalue += self::VALIDATION_INVALID_HASH;
 410                  }
 411  
 412                  if ($this->handler->validateaddress) {
 413                      // Validation of the sender's e-mail address is also required.
 414                      if ($address !== $this->record->user->email) {
 415                          // The e-mail address of the originator did not match the
 416                          // address held on record for this user.
 417                          $returnvalue += self::VALIDATION_ADDRESS_MISMATCH;
 418                      }
 419                  }
 420              }
 421          }
 422  
 423          return $returnvalue;
 424      }
 425  
 426      /**
 427       * Process the message recipient, load the handler, and then validate
 428       * the sender with the associated data record.
 429       *
 430       * @param string $recipient The recipient of the message
 431       * @param string $sender The sender of the message
 432       */
 433      public function process_envelope($recipient, $sender) {
 434          // Process the recipient address to retrieve the handler data.
 435          $this->process($recipient);
 436  
 437          // Validate the retrieved data against the e-mail address of the originator.
 438          $this->status = $this->validate($sender);
 439  
 440          return $this->status;
 441      }
 442  
 443      /**
 444       * Process the message against the relevant handler.
 445       *
 446       * @param \stdClass $messagedata The data for the current message being processed.
 447       * @return mixed The result of the handler's message processor. A truthy result suggests a successful send.
 448       */
 449      public function handle_message(\stdClass $messagedata) {
 450          $this->record = $this->get_data();
 451          return $this->handler->process_message($this->record, $messagedata);
 452      }
 453  
 454      /**
 455       * Pack an integer into a pair of 32-bit numbers.
 456       *
 457       * @param int $int The integer to pack
 458       * @return string The encoded binary data
 459       */
 460      protected function pack_int($int) {
 461          // If PHP environment is running on a 64-bit.
 462          if (PHP_INT_SIZE === 8) {
 463              // Will be used to ensures that the result remains as a 32-bit unsigned integer and
 464              // doesn't extend beyond 32 bits.
 465              $notation = 0xffffffff;
 466  
 467              if ($int < 0) {
 468                  // If the given integer is negative, set it to -1.
 469                  $l = -1;
 470              } else {
 471                  // Otherwise, calculate the upper 32 bits of the 64-bit integer.
 472                  $l = ($int >> 32) & $notation;
 473              }
 474  
 475              // Calculate the lower 32 bits of the 64-bit integer.
 476              $r = $int & $notation;
 477  
 478              // Pack the values of $l (upper 32 bits) and $r (lower 32 bits) into a binary string format.
 479              return pack('NN', $l, $r);
 480          } else {
 481              // Pack the values into a binary string format.
 482              return pack('NN', 0, $int);
 483          }
 484      }
 485  }