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.

Differences Between: [Versions 402 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  namespace core_external;
  18  
  19  use context;
  20  use context_course;
  21  use context_helper;
  22  use context_system;
  23  use core_user;
  24  use moodle_exception;
  25  use moodle_url;
  26  use stdClass;
  27  
  28  /**
  29   * Utility functions for the external API.
  30   *
  31   * @package    core_webservice
  32   * @copyright  2015 Juan Leyva
  33   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  34   */
  35  class util {
  36      /**
  37       * Validate a list of courses, returning the complete course objects for valid courses.
  38       *
  39       * Each course has an additional 'contextvalidated' field, this will be set to true unless
  40       * you set $keepfails, in which case it will be false if validation fails for a course.
  41       *
  42       * @param  array $courseids A list of course ids
  43       * @param  array $courses   An array of courses already pre-fetched, indexed by course id.
  44       * @param  bool $addcontext True if the returned course object should include the full context object.
  45       * @param  bool $keepfails  True to keep all the course objects even if validation fails
  46       * @return array            An array of courses and the validation warnings
  47       */
  48      public static function validate_courses(
  49          $courseids,
  50          $courses = [],
  51          $addcontext = false,
  52          $keepfails = false
  53      ) {
  54          global $DB;
  55  
  56          // Delete duplicates.
  57          $courseids = array_unique($courseids);
  58          $warnings = [];
  59  
  60          // Remove courses which are not even requested.
  61          $courses = array_intersect_key($courses, array_flip($courseids));
  62  
  63          // For any courses NOT loaded already, get them in a single query (and preload contexts)
  64          // for performance. Preserve ordering because some tests depend on it.
  65          $newcourseids = [];
  66          foreach ($courseids as $cid) {
  67              if (!array_key_exists($cid, $courses)) {
  68                  $newcourseids[] = $cid;
  69              }
  70          }
  71          if ($newcourseids) {
  72              [$listsql, $listparams] = $DB->get_in_or_equal($newcourseids);
  73  
  74              // Load list of courses, and preload associated contexts.
  75              $contextselect = context_helper::get_preload_record_columns_sql('x');
  76              $newcourses = $DB->get_records_sql(
  77                  "
  78                              SELECT c.*, $contextselect
  79                                FROM {course} c
  80                                JOIN {context} x ON x.instanceid = c.id
  81                               WHERE x.contextlevel = ? AND c.id $listsql",
  82                  array_merge([CONTEXT_COURSE], $listparams)
  83              );
  84              foreach ($newcourseids as $cid) {
  85                  if (array_key_exists($cid, $newcourses)) {
  86                      $course = $newcourses[$cid];
  87                      context_helper::preload_from_record($course);
  88                      $courses[$course->id] = $course;
  89                  }
  90              }
  91          }
  92  
  93          foreach ($courseids as $cid) {
  94              // Check the user can function in this context.
  95              try {
  96                  $context = context_course::instance($cid);
  97                  external_api::validate_context($context);
  98  
  99                  if ($addcontext) {
 100                      $courses[$cid]->context = $context;
 101                  }
 102                  $courses[$cid]->contextvalidated = true;
 103              } catch (\Exception $e) {
 104                  if ($keepfails) {
 105                      $courses[$cid]->contextvalidated = false;
 106                  } else {
 107                      unset($courses[$cid]);
 108                  }
 109                  $warnings[] = [
 110                      'item' => 'course',
 111                      'itemid' => $cid,
 112                      'warningcode' => '1',
 113                      'message' => 'No access rights in course context',
 114                  ];
 115              }
 116          }
 117  
 118          return [$courses, $warnings];
 119      }
 120  
 121      /**
 122       * Returns all area files (optionally limited by itemid).
 123       *
 124       * @param int $contextid context ID
 125       * @param string $component component
 126       * @param string $filearea file area
 127       * @param int $itemid item ID or all files if not specified
 128       * @param bool $useitemidinurl wether to use the item id in the file URL (modules intro don't use it)
 129       * @return array of files, compatible with the external_files structure.
 130       * @since Moodle 3.2
 131       */
 132      public static function get_area_files($contextid, $component, $filearea, $itemid = false, $useitemidinurl = true) {
 133          $files = [];
 134          $fs = get_file_storage();
 135  
 136          if ($areafiles = $fs->get_area_files($contextid, $component, $filearea, $itemid, 'itemid, filepath, filename', false)) {
 137              foreach ($areafiles as $areafile) {
 138                  $file = [];
 139                  $file['filename'] = $areafile->get_filename();
 140                  $file['filepath'] = $areafile->get_filepath();
 141                  $file['mimetype'] = $areafile->get_mimetype();
 142                  $file['filesize'] = $areafile->get_filesize();
 143                  $file['timemodified'] = $areafile->get_timemodified();
 144                  $file['isexternalfile'] = $areafile->is_external_file();
 145                  if ($file['isexternalfile']) {
 146                      $file['repositorytype'] = $areafile->get_repository_type();
 147                  }
 148                  $fileitemid = $useitemidinurl ? $areafile->get_itemid() : null;
 149                  $file['fileurl'] = moodle_url::make_webservice_pluginfile_url(
 150                      $contextid,
 151                      $component,
 152                      $filearea,
 153                      $fileitemid,
 154                      $areafile->get_filepath(),
 155                      $areafile->get_filename()
 156                  )->out(false);
 157                  $files[] = $file;
 158              }
 159          }
 160          return $files;
 161      }
 162  
 163  
 164      /**
 165       * Create and return a session linked token. Token to be used for html embedded client apps that want to communicate
 166       * with the Moodle server through web services. The token is linked to the current session for the current page request.
 167       * It is expected this will be called in the script generating the html page that is embedding the client app and that the
 168       * returned token will be somehow passed into the client app being embedded in the page.
 169       *
 170       * @param int $tokentype EXTERNAL_TOKEN_EMBEDDED|EXTERNAL_TOKEN_PERMANENT
 171       * @param stdClass $service service linked to the token
 172       * @param int $userid user linked to the token
 173       * @param context $context
 174       * @param int $validuntil date when the token expired
 175       * @param string $iprestriction allowed ip - if 0 or empty then all ips are allowed
 176       * @return string generated token
 177       */
 178      public static function generate_token(
 179          int $tokentype,
 180          stdClass $service,
 181          int $userid,
 182          context $context,
 183          int $validuntil = 0,
 184          string $iprestriction = ''
 185      ): string {
 186          global $DB, $USER;
 187  
 188          // Make sure the token doesn't exist (even if it should be almost impossible with the random generation).
 189          $numtries = 0;
 190          do {
 191              $numtries++;
 192              $generatedtoken = md5(uniqid((string) rand(), true));
 193              if ($numtries > 5) {
 194                  throw new moodle_exception('tokengenerationfailed');
 195              }
 196          } while ($DB->record_exists('external_tokens', ['token' => $generatedtoken]));
 197          $newtoken = (object) [
 198              'token' => $generatedtoken,
 199          ];
 200  
 201          if (empty($service->requiredcapability) || has_capability($service->requiredcapability, $context, $userid)) {
 202              $newtoken->externalserviceid = $service->id;
 203          } else {
 204              throw new moodle_exception('nocapabilitytousethisservice');
 205          }
 206  
 207          $newtoken->tokentype = $tokentype;
 208          $newtoken->userid = $userid;
 209          if ($tokentype == EXTERNAL_TOKEN_EMBEDDED) {
 210              $newtoken->sid = session_id();
 211          }
 212  
 213          $newtoken->contextid = $context->id;
 214          $newtoken->creatorid = $USER->id;
 215          $newtoken->timecreated = time();
 216          $newtoken->validuntil = $validuntil;
 217          if (!empty($iprestriction)) {
 218              $newtoken->iprestriction = $iprestriction;
 219          }
 220  
 221          // Generate the private token, it must be transmitted only via https.
 222          $newtoken->privatetoken = random_string(64);
 223          $DB->insert_record('external_tokens', $newtoken);
 224          return $newtoken->token;
 225      }
 226  
 227      /**
 228       * Get a service by its id.
 229       *
 230       * @param int $serviceid
 231       * @return stdClass
 232       */
 233      public static function get_service_by_id(int $serviceid): stdClass {
 234          global $DB;
 235  
 236          return $DB->get_record('external_services', ['id' => $serviceid], '*', MUST_EXIST);
 237      }
 238  
 239      /**
 240       * Get a service by its name.
 241       *
 242       * @param string $name The service name.
 243       * @return stdClass
 244       */
 245      public static function get_service_by_name(string $name): stdClass {
 246          global $DB;
 247  
 248          return $DB->get_record('external_services', ['name' => $name], '*', MUST_EXIST);
 249      }
 250  
 251      /**
 252       * Set the last time a token was sent and trigger the \core\event\webservice_token_sent event.
 253       *
 254       * This function is used when a token is generated by the user via login/token.php or admin/tool/mobile/launch.php.
 255       * In order to protect the privatetoken, we remove it from the event params.
 256       *
 257       * @param  stdClass $token token object
 258       */
 259      public static function log_token_request(stdClass $token): void {
 260          global $DB, $USER;
 261  
 262          $token->privatetoken = null;
 263  
 264          // Log token access.
 265          $DB->set_field('external_tokens', 'lastaccess', time(), ['id' => $token->id]);
 266  
 267          $event = \core\event\webservice_token_sent::create([
 268              'objectid' => $token->id,
 269          ]);
 270          $event->add_record_snapshot('external_tokens', $token);
 271          $event->trigger();
 272  
 273          // Check if we need to notify the user about the new login via token.
 274          $loginip = getremoteaddr();
 275          if ($USER->lastip === $loginip) {
 276              return;
 277          }
 278  
 279          $shouldskip = WS_SERVER || CLI_SCRIPT || !NO_MOODLE_COOKIES;
 280          if ($shouldskip && !PHPUNIT_TEST) {
 281              return;
 282          }
 283  
 284          // Schedule adhoc task to sent a login notification to the user.
 285          $task = new \core\task\send_login_notifications();
 286          $task->set_userid($USER->id);
 287          $logintime = time();
 288          $task->set_custom_data([
 289              'useragent' => \core_useragent::get_user_agent_string(),
 290              'ismoodleapp' => \core_useragent::is_moodle_app(),
 291              'loginip' => $loginip,
 292              'logintime' => $logintime,
 293          ]);
 294          $task->set_component('core');
 295          // We need sometime so the mobile app will send to Moodle the device information after login.
 296          $task->set_next_run_time(time() + (2 * MINSECS));
 297          \core\task\manager::reschedule_or_queue_adhoc_task($task);
 298      }
 299  
 300      /**
 301       * Generate or return an existing token for the current authenticated user.
 302       * This function is used for creating a valid token for users authenticathing via places, including:
 303       * - login/token.php
 304       * - admin/tool/mobile/launch.php.
 305       *
 306       * @param stdClass $service external service object
 307       * @return stdClass token object
 308       * @throws moodle_exception
 309       */
 310      public static function generate_token_for_current_user(stdClass $service) {
 311          global $DB, $USER, $CFG;
 312  
 313          core_user::require_active_user($USER, true, true);
 314  
 315          // Check if there is any required system capability.
 316          if ($service->requiredcapability && !has_capability($service->requiredcapability, context_system::instance())) {
 317              throw new moodle_exception('missingrequiredcapability', 'webservice', '', $service->requiredcapability);
 318          }
 319  
 320          // Specific checks related to user restricted service.
 321          if ($service->restrictedusers) {
 322              $authoriseduser = $DB->get_record('external_services_users', [
 323                  'externalserviceid' => $service->id,
 324                  'userid' => $USER->id,
 325              ]);
 326  
 327              if (empty($authoriseduser)) {
 328                  throw new moodle_exception('usernotallowed', 'webservice', '', $service->shortname);
 329              }
 330  
 331              if (!empty($authoriseduser->validuntil) && $authoriseduser->validuntil < time()) {
 332                  throw new moodle_exception('invalidtimedtoken', 'webservice');
 333              }
 334  
 335              if (!empty($authoriseduser->iprestriction) && !address_in_subnet(getremoteaddr(), $authoriseduser->iprestriction)) {
 336                  throw new moodle_exception('invalidiptoken', 'webservice');
 337              }
 338          }
 339  
 340          // Check if a token has already been created for this user and this service.
 341          $conditions = [
 342              'userid' => $USER->id,
 343              'externalserviceid' => $service->id,
 344              'tokentype' => EXTERNAL_TOKEN_PERMANENT,
 345          ];
 346          $tokens = $DB->get_records('external_tokens', $conditions, 'timecreated ASC');
 347  
 348          // A bit of sanity checks.
 349          foreach ($tokens as $key => $token) {
 350              // Checks related to a specific token. (script execution continue).
 351              $unsettoken = false;
 352              // If sid is set then there must be a valid associated session no matter the token type.
 353              if (!empty($token->sid)) {
 354                  if (!\core\session\manager::session_exists($token->sid)) {
 355                      // This token will never be valid anymore, delete it.
 356                      $DB->delete_records('external_tokens', ['sid' => $token->sid]);
 357                      $unsettoken = true;
 358                  }
 359              }
 360  
 361              // Remove token is not valid anymore.
 362              if (!empty($token->validuntil) && $token->validuntil < time()) {
 363                  $DB->delete_records('external_tokens', ['token' => $token->token, 'tokentype' => EXTERNAL_TOKEN_PERMANENT]);
 364                  $unsettoken = true;
 365              }
 366  
 367              // Remove token if its IP is restricted.
 368              if (isset($token->iprestriction) && !address_in_subnet(getremoteaddr(), $token->iprestriction)) {
 369                  $unsettoken = true;
 370              }
 371  
 372              if ($unsettoken) {
 373                  unset($tokens[$key]);
 374              }
 375          }
 376  
 377          // If some valid tokens exist then use the most recent.
 378          if (count($tokens) > 0) {
 379              $token = array_pop($tokens);
 380          } else {
 381              $context = context_system::instance();
 382              $isofficialservice = $service->shortname == MOODLE_OFFICIAL_MOBILE_SERVICE;
 383  
 384              if (
 385                  ($isofficialservice && has_capability('moodle/webservice:createmobiletoken', $context)) ||
 386                  (!is_siteadmin($USER) && has_capability('moodle/webservice:createtoken', $context))
 387              ) {
 388                  // Create a new token.
 389                  $token = new stdClass();
 390                  $token->token = md5(uniqid((string) rand(), true));
 391                  $token->userid = $USER->id;
 392                  $token->tokentype = EXTERNAL_TOKEN_PERMANENT;
 393                  $token->contextid = context_system::instance()->id;
 394                  $token->creatorid = $USER->id;
 395                  $token->timecreated = time();
 396                  $token->externalserviceid = $service->id;
 397                  // By default tokens are valid for 12 weeks.
 398                  $token->validuntil = $token->timecreated + $CFG->tokenduration;
 399                  $token->iprestriction = null;
 400                  $token->sid = null;
 401                  $token->lastaccess = null;
 402                  // Generate the private token, it must be transmitted only via https.
 403                  $token->privatetoken = random_string(64);
 404                  $token->id = $DB->insert_record('external_tokens', $token);
 405  
 406                  $eventtoken = clone $token;
 407                  $eventtoken->privatetoken = null;
 408                  $params = [
 409                      'objectid' => $eventtoken->id,
 410                      'relateduserid' => $USER->id,
 411                      'other' => [
 412                          'auto' => true,
 413                      ],
 414                  ];
 415                  $event = \core\event\webservice_token_created::create($params);
 416                  $event->add_record_snapshot('external_tokens', $eventtoken);
 417                  $event->trigger();
 418              } else {
 419                  throw new moodle_exception('cannotcreatetoken', 'webservice', '', $service->shortname);
 420              }
 421          }
 422          return $token;
 423      }
 424  
 425      /**
 426       * Format the string to be returned properly as requested by the either the web service server,
 427       * either by an internally call.
 428       * The caller can change the format (raw) with the settings singleton
 429       * All web service servers must set this singleton when parsing the $_GET and $_POST.
 430       *
 431       * <pre>
 432       * Options are the same that in {@see format_string()} with some changes:
 433       *      filter      : Can be set to false to force filters off, else observes {@see settings}.
 434       * </pre>
 435       *
 436       * @param string|null $content The string to be filtered. Should be plain text, expect
 437       * possibly for multilang tags.
 438       * @param context $context The id of the context for the string or the context (affects filters).
 439       * @param boolean $striplinks To strip any link in the result text. Moodle 1.8 default changed from false to true! MDL-8713
 440       * @param array $options options array/object or courseid
 441       * @return string text
 442       */
 443      public static function format_string(
 444          $content,
 445          $context,
 446          $striplinks = true,
 447          $options = []
 448      ) {
 449          if ($content === null || $content === '') {
 450              // Nothing to return.
 451              // Note: It's common for the DB to return null, so we allow format_string to take a null,
 452              // even though it is counter-intuitive.
 453              return '';
 454          }
 455  
 456          // Get settings (singleton).
 457          $settings = external_settings::get_instance();
 458  
 459          if (!$settings->get_raw()) {
 460              $options['context'] = $context;
 461              $options['filter'] = isset($options['filter']) && !$options['filter'] ? false : $settings->get_filter();
 462              return format_string($content, $striplinks, $options);
 463          }
 464  
 465          return $content;
 466      }
 467  
 468      /**
 469       * Format the text to be returned properly as requested by the either the web service server,
 470       * either by an internally call.
 471       * The caller can change the format (raw, filter, file, fileurl) with the \core_external\settings singleton
 472       * All web service servers must set this singleton when parsing the $_GET and $_POST.
 473       *
 474       * <pre>
 475       * Options are the same that in {@see format_text()} with some changes in defaults to provide backwards compatibility:
 476       *      trusted     :   If true the string won't be cleaned. Default false.
 477       *      noclean     :   If true the string won't be cleaned only if trusted is also true. Default false.
 478       *      nocache     :   If true the string will not be cached and will be formatted every call. Default false.
 479       *      filter      :   Can be set to false to force filters off, else observes {@see \core_external\settings}.
 480       *      para        :   If true then the returned string will be wrapped in div tags.
 481       *                      Default (different from format_text) false.
 482       *                      Default changed because div tags are not commonly needed.
 483       *      newlines    :   If true then lines newline breaks will be converted to HTML newline breaks. Default true.
 484       *      context     :   Not used! Using contextid parameter instead.
 485       *      overflowdiv :   If set to true the formatted text will be encased in a div with the class no-overflow before being
 486       *                      returned. Default false.
 487       *      allowid     :   If true then id attributes will not be removed, even when using htmlpurifier. Default (different from
 488       *                      format_text) true. Default changed id attributes are commonly needed.
 489       *      blanktarget :   If true all <a> tags will have target="_blank" added unless target is explicitly specified.
 490       * </pre>
 491       *
 492       * @param string|null $text The content that may contain ULRs in need of rewriting.
 493       * @param string|int|null $textformat The text format.
 494       * @param context $context This parameter and the next two identify the file area to use.
 495       * @param string|null $component
 496       * @param string|null $filearea helps identify the file area.
 497       * @param int|string|null $itemid helps identify the file area.
 498       * @param array|stdClass|null $options text formatting options
 499       * @return array text + textformat
 500       */
 501      public static function format_text(
 502          $text,
 503          $textformat,
 504          $context,
 505          $component = null,
 506          $filearea = null,
 507          $itemid = null,
 508          $options = null
 509      ) {
 510          global $CFG;
 511  
 512          if ($text === null || $text === '') {
 513              // Nothing to return.
 514              // Note: It's common for the DB to return null, so we allow format_string to take nulls,
 515              // even though it is counter-intuitive.
 516              return ['', $textformat ?? FORMAT_MOODLE];
 517          }
 518  
 519          if (empty($itemid)) {
 520              $itemid = null;
 521          }
 522  
 523          // Get settings (singleton).
 524          $settings = external_settings::get_instance();
 525  
 526          if ($component && $filearea && $settings->get_fileurl()) {
 527              require_once($CFG->libdir . "/filelib.php");
 528              $text = file_rewrite_pluginfile_urls($text, $settings->get_file(), $context->id, $component, $filearea, $itemid);
 529          }
 530  
 531          // Note that $CFG->forceclean does not apply here if the client requests for the raw database content.
 532          // This is consistent with web clients that are still able to load non-cleaned text into editors, too.
 533  
 534          if (!$settings->get_raw()) {
 535              $options = (array) $options;
 536  
 537              // If context is passed in options, check that is the same to show a debug message.
 538              if (isset($options['context'])) {
 539                  if (is_int($options['context'])) {
 540                      if ($options['context'] != $context->id) {
 541                          debugging(
 542                              'Different contexts found in external_format_text parameters. $options[\'context\'] not allowed. ' .
 543                              'Using $contextid parameter...',
 544                              DEBUG_DEVELOPER
 545                          );
 546                      }
 547                  } else if ($options['context'] instanceof context) {
 548                      if ($options['context']->id != $context->id) {
 549                          debugging(
 550                              'Different contexts found in external_format_text parameters. $options[\'context\'] not allowed. ' .
 551                              'Using $contextid parameter...',
 552                              DEBUG_DEVELOPER
 553                          );
 554                      }
 555                  }
 556              }
 557  
 558              $options['filter'] = isset($options['filter']) && !$options['filter'] ? false : $settings->get_filter();
 559              $options['para'] = isset($options['para']) ? $options['para'] : false;
 560              $options['context'] = $context;
 561              $options['allowid'] = isset($options['allowid']) ? $options['allowid'] : true;
 562  
 563              $text = format_text($text, $textformat, $options);
 564              // Once converted to html (from markdown, plain... lets inform consumer this is already HTML).
 565              $textformat = FORMAT_HTML;
 566          }
 567  
 568          // Note: The formats defined in weblib are strings.
 569          return [$text, $textformat];
 570      }
 571  
 572      /**
 573       * Validate text field format against known FORMAT_XXX
 574       *
 575       * @param string $format the format to validate
 576       * @return string the validated format
 577       * @throws \moodle_exception
 578       * @since Moodle 2.3
 579       */
 580      public static function validate_format($format) {
 581          $allowedformats = [FORMAT_HTML, FORMAT_MOODLE, FORMAT_PLAIN, FORMAT_MARKDOWN];
 582          if (!in_array($format, $allowedformats)) {
 583              throw new moodle_exception(
 584                  'formatnotsupported',
 585                  'webservice',
 586                  '',
 587                  null,
 588                  "The format with value={$format} is not supported by this Moodle site"
 589              );
 590          }
 591          return $format;
 592      }
 593  
 594      /**
 595       * Delete all pre-built services, related tokens, and external functions information defined for the specified component.
 596       *
 597       * @param string $component The frankenstyle component name
 598       */
 599      public static function delete_service_descriptions(string $component): void {
 600          global $DB;
 601  
 602          $params = [$component];
 603  
 604          $DB->delete_records_select(
 605              'external_tokens',
 606              "externalserviceid IN (SELECT id FROM {external_services} WHERE component = ?)",
 607              $params
 608          );
 609          $DB->delete_records_select(
 610              'external_services_users',
 611              "externalserviceid IN (SELECT id FROM {external_services} WHERE component = ?)",
 612              $params
 613          );
 614          $DB->delete_records_select(
 615              'external_services_functions',
 616              "functionname IN (SELECT name FROM {external_functions} WHERE component = ?)",
 617              $params
 618          );
 619          $DB->delete_records('external_services', ['component' => $component]);
 620          $DB->delete_records('external_functions', ['component' => $component]);
 621      }
 622  }