Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   1  <?php
   2  // This file is part of Moodle - https://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 <https://www.gnu.org/licenses/>.
  16  
  17  namespace core\hook;
  18  
  19  use Psr\EventDispatcher\EventDispatcherInterface;
  20  use Psr\EventDispatcher\ListenerProviderInterface;
  21  use Psr\EventDispatcher\StoppableEventInterface;
  22  
  23  /**
  24   * Hook manager implementing "Dispatcher" and "Event Provider" from PSR-14.
  25   *
  26   * Due to class/method naming restrictions and collision with
  27   * Moodle events the definitions from PSR-14 should be interpreted as:
  28   *
  29   *  1. Event --> Hook
  30   *  2. Listener --> Hook callback
  31   *  3. Emitter --> Hook emitter
  32   *  4. Dispatcher --> Hook dispatcher - implemented in manager::dispatch()
  33   *  5. Listener Provider --> Hook callback provider - implemented in manager::get_callbacks_for_hook()
  34   *
  35   * Note that technically any object can be a hook, but it is recommended
  36   * to put all hook classes into \component_name\hook namespaces and
  37   * each hook should implement \core\hook\described_hook interface.
  38   *
  39   * @package   core
  40   * @author    Petr Skoda
  41   * @copyright 2022 Open LMS
  42   * @license   https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  43   */
  44  final class manager implements
  45      EventDispatcherInterface,
  46      ListenerProviderInterface {
  47  
  48      /** @var ?manager the one instance of listener provider and dispatcher */
  49      private static $instance = null;
  50  
  51      /** @var array list of callback definitions for each hook class. */
  52      private $allcallbacks = [];
  53  
  54      /** @var array list of all deprecated lib.php plugin callbacks. */
  55      private $alldeprecations = [];
  56  
  57      /** @var array list of redirected callbacks in PHPUnit tests */
  58      private $redirectedcallbacks = [];
  59  
  60      /**
  61       * Constructor can be used only from factory methods.
  62       */
  63      private function __construct() {
  64      }
  65  
  66      /**
  67       * Factory method, returns instance of manager that serves
  68       * as hook dispatcher and callback provider.
  69       *
  70       * @return self
  71       */
  72      public static function get_instance(): manager {
  73          if (!self::$instance) {
  74              self::$instance = new self();
  75              self::$instance->init_standard_callbacks();
  76          }
  77          return self::$instance;
  78      }
  79  
  80      /**
  81       * Factory method for testing of hook manager in PHPUnit tests.
  82       *
  83       * @param array $componentfiles list of hook callback files for each component.
  84       * @return self
  85       */
  86      public static function phpunit_get_instance(array $componentfiles): manager {
  87          if (!PHPUNIT_TEST) {
  88              throw new \coding_exception('Invalid call of manager::phpunit_get_instance() outside of tests');
  89          }
  90          $instance = new self();
  91          $instance->load_callbacks($componentfiles);
  92          return $instance;
  93      }
  94  
  95      /**
  96       * Override hook callbacks for testing purposes.
  97       *
  98       * @param string $hookname
  99       * @param callable $callback
 100       * @return void
 101       */
 102      public function phpunit_redirect_hook(string $hookname, callable $callback): void {
 103          if (!PHPUNIT_TEST) {
 104              throw new \coding_exception('Invalid call of manager::phpunit_redirect_hook() outside of tests');
 105          }
 106          $this->redirectedcallbacks[$hookname] = $callback;
 107      }
 108  
 109      /**
 110       * Cancel all redirections of hook callbacks.
 111       *
 112       * @return void
 113       */
 114      public function phpunit_stop_redirections(): void {
 115          if (!PHPUNIT_TEST) {
 116              throw new \coding_exception('Invalid call of manager::phpunit_stop_redirections() outside of tests');
 117          }
 118          $this->redirectedcallbacks = [];
 119      }
 120  
 121      /**
 122       * Returns list of callbacks for given hook name.
 123       *
 124       * NOTE: this is the "Listener Provider" described in PSR-14,
 125       * instead of instance parameter it uses real PHP class names.
 126       *
 127       * @param string $hookclassname PHP class name of hook
 128       * @return array list of callback definitions
 129       */
 130      public function get_callbacks_for_hook(string $hookclassname): array {
 131          return $this->allcallbacks[$hookclassname] ?? [];
 132      }
 133  
 134      /**
 135       * Returns list of all callbacks found in db/hooks.php files.
 136       *
 137       * @return iterable
 138       */
 139      public function get_all_callbacks(): iterable {
 140          return $this->allcallbacks;
 141      }
 142  
 143      /**
 144       * Get the list of listeners for the specified event.
 145       *
 146       * @param object $event The object being listened to (aka hook).
 147       * @return iterable<callable>
 148       *   An iterable (array, iterator, or generator) of callables.  Each
 149       *   callable MUST be type-compatible with $event.
 150       *   Please note that in Moodle the callable must be a string.
 151       */
 152      public function getListenersForEvent(object $event): iterable {
 153          // Callbacks are sorted by priority, highest first at load-time.
 154          $hookclassname = get_class($event);
 155          $callbacks = $this->get_callbacks_for_hook($hookclassname);
 156  
 157          if (count($callbacks) === 0) {
 158              // Nothing is interested in this hook.
 159              return new \EmptyIterator();
 160          }
 161  
 162          foreach ($callbacks as $definition) {
 163              if ($definition['disabled']) {
 164                  continue;
 165              }
 166              $callback = $definition['callback'];
 167  
 168              if ($this->is_callback_valid($definition['component'], $callback)) {
 169                  yield $callback;
 170              }
 171          }
 172      }
 173  
 174      /**
 175       * Verify that callback is valid.
 176       *
 177       * @param string $component
 178       * @param string $callback
 179       * @return bool
 180       */
 181      private function is_callback_valid(string $component, string $callback): bool {
 182          [$callbackclass, $callbackmethod] = explode('::', $callback, 2);
 183          if (!class_exists($callbackclass)) {
 184              debugging(
 185                  "Hook callback definition contains invalid 'callback' class name in '$component'. " .
 186                      "Callback class '{$callbackclass}' not found.",
 187                  DEBUG_DEVELOPER,
 188              );
 189              return false;
 190          }
 191          $rc = new \ReflectionClass($callbackclass);
 192          if (!$rc->hasMethod($callbackmethod)) {
 193              debugging(
 194                  "Hook callback definition contains invalid 'callback' method name in '$component'. " .
 195                      "Callback method not found.",
 196                  DEBUG_DEVELOPER,
 197              );
 198              return false;
 199          }
 200  
 201          $rcm = $rc->getMethod($callbackmethod);
 202          if (!$rcm->isStatic()) {
 203              debugging(
 204                  "Hook callback definition contains invalid 'callback' method name in '$component'. " .
 205                      "Callback method not a static method.",
 206                  DEBUG_DEVELOPER,
 207              );
 208              return false;
 209          }
 210  
 211          if (!is_callable($callback, false, $callablename)) {
 212              debugging(
 213                  "Cannot execute callback '$callablename' from '$component'" .
 214                      "Callback method not callable.",
 215                  DEBUG_DEVELOPER
 216              );
 217              return false;
 218          }
 219  
 220          return true;
 221      }
 222  
 223      /**
 224       * Returns the list of Hook class names that have registered callbacks.
 225       *
 226       * @return array
 227       */
 228      public function get_hooks_with_callbacks(): array {
 229          return array_keys($this->allcallbacks);
 230      }
 231  
 232      /**
 233       * Provide all relevant listeners with an event to process.
 234       *
 235       * @param object $event The object to process (aka hook).
 236       * @return object The Event that was passed, now modified by listeners.
 237       */
 238      public function dispatch(object $event): object {
 239          // We can dispatch only after the lib/setup.php includes,
 240          // that is right before the database connection is made,
 241          // the MUC caches need to be working already.
 242          if (!function_exists('setup_DB')) {
 243              debugging('Hooks cannot be dispatched yet', DEBUG_DEVELOPER);
 244              return $event;
 245          }
 246  
 247          if (PHPUNIT_TEST) {
 248              $hookclassname = get_class($event);
 249              if (isset($this->redirectedcallbacks[$hookclassname])) {
 250                  call_user_func($this->redirectedcallbacks[$hookclassname], $event);
 251                  return $event;
 252              }
 253          }
 254  
 255          $callbacks = $this->getListenersForEvent($event);
 256  
 257          if (empty($callbacks)) {
 258              // Nothing is interested in this hook.
 259              return $event;
 260          }
 261  
 262          foreach ($callbacks as $callback) {
 263              // Note: PSR-14 states:
 264              // If passed a Stoppable Event, a Dispatcher
 265              // MUST call isPropagationStopped() on the Event before each Listener has been called.
 266              // If that method returns true it MUST return the Event to the Emitter immediately and
 267              // MUST NOT call any further Listeners. This implies that if an Event is passed to the
 268              // Dispatcher that always returns true from isPropagationStopped(), zero listeners will be called.
 269              // Ergo, we check for a stopped event before calling each listener, not afterwards.
 270              if ($event instanceof StoppableEventInterface) {
 271                  if ($event->isPropagationStopped()) {
 272                      return $event;
 273                  }
 274              }
 275  
 276              call_user_func($callback, $event);
 277          }
 278  
 279          // Developers need to be careful to not create infinite loops in hook callbacks.
 280          return $event;
 281      }
 282  
 283      /**
 284       * Initialise list of all callbacks for each hook.
 285       *
 286       * @return void
 287       */
 288      private function init_standard_callbacks(): void {
 289          global $CFG;
 290  
 291          $this->allcallbacks = [];
 292          $this->alldeprecations = [];
 293  
 294          $cache = null;
 295          // @codeCoverageIgnoreStart
 296          if (!PHPUNIT_TEST && !CACHE_DISABLE_ALL) {
 297              $cache = \cache::make('core', 'hookcallbacks');
 298              $callbacks = $cache->get('callbacks');
 299              $deprecations = $cache->get('deprecations');
 300              $overrideshash = $cache->get('overrideshash');
 301  
 302              $usecache = is_array($callbacks);
 303              $usecache = $usecache && is_array($deprecations);
 304              $usecache = $usecache && $this->calculate_overrides_hash() === $overrideshash;
 305              if ($usecache) {
 306                  $this->allcallbacks = $callbacks;
 307                  $this->alldeprecations = $deprecations;
 308                  return;
 309              }
 310          }
 311          // @codeCoverageIgnoreEnd
 312  
 313          // Get list of all files with callbacks, one per component.
 314          $components = ['core' => "{$CFG->dirroot}/lib/db/hooks.php"];
 315          $plugintypes = \core_component::get_plugin_types();
 316          foreach ($plugintypes as $plugintype => $plugintypedir) {
 317              $plugins = \core_component::get_plugin_list($plugintype);
 318              foreach ($plugins as $pluginname => $plugindir) {
 319                  if (!$plugindir) {
 320                      continue;
 321                  }
 322  
 323                  $components["{$plugintype}_{$pluginname}"] = "{$plugindir}/db/hooks.php";
 324              }
 325          }
 326  
 327          // Load the callbacks and apply overrides.
 328          $this->load_callbacks($components);
 329  
 330          if ($cache) {
 331              $cache->set('callbacks', $this->allcallbacks);
 332              $cache->set('deprecations', $this->alldeprecations);
 333              $cache->set('overrideshash', $this->calculate_overrides_hash());
 334          }
 335      }
 336  
 337      /**
 338       * Load callbacks from component db/hooks.php files.
 339       *
 340       * @param array $componentfiles list of all components with their callback files
 341       * @return void
 342       */
 343      private function load_callbacks(array $componentfiles): void {
 344          $this->allcallbacks = [];
 345          $this->alldeprecations = [];
 346  
 347          array_map(
 348              [$this, 'add_component_callbacks'],
 349              array_keys($componentfiles),
 350              $componentfiles,
 351          );
 352          $this->load_callback_overrides();
 353          $this->prioritise_callbacks();
 354          $this->fetch_deprecated_callbacks();
 355      }
 356  
 357      /**
 358       * In extremely special cases admins may decide to override callbacks via config.php setting.
 359       */
 360      private function load_callback_overrides(): void {
 361          global $CFG;
 362  
 363          if (!property_exists($CFG, 'hooks_callback_overrides')) {
 364              return;
 365          }
 366  
 367          if (!is_iterable($CFG->hooks_callback_overrides)) {
 368              debugging('hooks_callback_overrides must be an array', DEBUG_DEVELOPER);
 369              return;
 370          }
 371  
 372          foreach ($CFG->hooks_callback_overrides as $hookclassname => $overrides) {
 373              if (!is_iterable($overrides)) {
 374                  debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER);
 375                  continue;
 376              }
 377  
 378              if (!array_key_exists($hookclassname, $this->allcallbacks)) {
 379                  debugging('hooks_callback_overrides must be an array of arrays with existing hook classnames', DEBUG_DEVELOPER);
 380                  continue;
 381              }
 382  
 383              foreach ($overrides as $callback => $override) {
 384                  if (!is_array($override)) {
 385                      debugging('hooks_callback_overrides must be an array of arrays', DEBUG_DEVELOPER);
 386                      continue;
 387                  }
 388  
 389                  $found = false;
 390                  foreach ($this->allcallbacks[$hookclassname] as $index => $definition) {
 391                      if ($definition['callback'] === $callback) {
 392                          if (isset($override['priority'])) {
 393                              $definition['defaultpriority'] = $definition['priority'];
 394                              $definition['priority'] = (int) $override['priority'];
 395                          }
 396  
 397                          if (!empty($override['disabled'])) {
 398                              $definition['disabled'] = true;
 399                          }
 400  
 401                          $this->allcallbacks[$hookclassname][$index] = $definition;
 402                          $found = true;
 403                          break;
 404                      }
 405                  }
 406                  if (!$found) {
 407                      debugging("Unable to find callback '{$callback}' for '{$hookclassname}'", DEBUG_DEVELOPER);
 408                  }
 409              }
 410          }
 411      }
 412  
 413      /**
 414       * Calculate a hash of the overrides.
 415       * This is used to inform if the overrides have changed, which invalidates the cache.
 416       *
 417       * Overrides are only configured in config.php where there is no other mechanism to invalidate the cache.
 418       *
 419       * @return null|string
 420       */
 421      private function calculate_overrides_hash(): ?string {
 422          global $CFG;
 423  
 424          if (!property_exists($CFG, 'hooks_callback_overrides')) {
 425              return null;
 426          }
 427  
 428          if (!is_iterable($CFG->hooks_callback_overrides)) {
 429              return null;
 430          }
 431  
 432          return sha1(json_encode($CFG->hooks_callback_overrides));
 433      }
 434  
 435      /**
 436       * Prioritise the callbacks.
 437       */
 438      private function prioritise_callbacks(): void {
 439          // Prioritise callbacks.
 440          foreach ($this->allcallbacks as $hookclassname => $hookcallbacks) {
 441              \core_collator::asort_array_of_arrays_by_key($hookcallbacks, 'priority', \core_collator::SORT_NUMERIC);
 442              $hookcallbacks = array_reverse($hookcallbacks);
 443              $this->allcallbacks[$hookclassname] = $hookcallbacks;
 444          }
 445      }
 446  
 447      /**
 448       * Fetch the list of callbacks that this hook replaces.
 449       */
 450      private function fetch_deprecated_callbacks(): void {
 451          $candidates = self::discover_known_hooks();
 452  
 453          /** @var class-string<deprecated_callback_replacement> $hookclassname */
 454          foreach (array_keys($candidates) as $hookclassname) {
 455              if (!class_exists($hookclassname)) {
 456                  continue;
 457              }
 458              if (!is_subclass_of($hookclassname, \core\hook\deprecated_callback_replacement::class)) {
 459                  continue;
 460              }
 461              $deprecations = $hookclassname::get_deprecated_plugin_callbacks();
 462              if (!$deprecations) {
 463                  continue;
 464              }
 465              foreach ($deprecations as $deprecation) {
 466                  $this->alldeprecations[$deprecation][] = $hookclassname;
 467              }
 468          }
 469      }
 470  
 471      /**
 472       * Add hook callbacks from file.
 473       *
 474       * @param string $component component where hook callbacks are defined
 475       * @param string $hookfile file with list of all callbacks for component
 476       * @return void
 477       */
 478      private function add_component_callbacks(string $component, string $hookfile): void {
 479          if (!file_exists($hookfile)) {
 480              return;
 481          }
 482  
 483          $parsecallbacks = function($hookfile) {
 484              $callbacks = [];
 485              include($hookfile);
 486              return $callbacks;
 487          };
 488  
 489          $callbacks = $parsecallbacks($hookfile);
 490  
 491          if (!is_array($callbacks) || !$callbacks) {
 492              return;
 493          }
 494  
 495          foreach ($callbacks as $callbackdata) {
 496              if (empty($callbackdata['hook'])) {
 497                  debugging("Hook callback definition requires 'hook' name in '$component'", DEBUG_DEVELOPER);
 498                  continue;
 499              }
 500  
 501              $callbackmethod = $this->normalise_callback($component, $callbackdata);
 502              if ($callbackmethod === null) {
 503                  continue;
 504              }
 505  
 506              $callback = [
 507                  'callback' => $callbackmethod,
 508                  'component' => $component,
 509                  'disabled' => false,
 510                  'priority' => 100,
 511              ];
 512  
 513              if (isset($callbackdata['priority'])) {
 514                  $callback['priority'] = (int) $callbackdata['priority'];
 515              }
 516  
 517              $hook = ltrim($callbackdata['hook'], '\\'); // Normalise hook class name.
 518              $this->allcallbacks[$hook][] = $callback;
 519          }
 520      }
 521  
 522      /**
 523       * Normalise the callback class::method value.
 524       *
 525       * @param string $component
 526       * @param array $callback
 527       * @return null|string
 528       */
 529      private function normalise_callback(string $component, array $callback): ?string {
 530          if (empty($callback['callback'])) {
 531              debugging("Hook callback definition requires 'callback' callable in '$component'", DEBUG_DEVELOPER);
 532              return null;
 533          }
 534          $classmethod = $callback['callback'];
 535          if (!is_string($classmethod)) {
 536              debugging("Hook callback definition contains invalid 'callback' string in '$component'", DEBUG_DEVELOPER);
 537              return null;
 538          }
 539          if (!str_contains($classmethod, '::')) {
 540              debugging(
 541                  "Hook callback definition contains invalid 'callback' static class method string in '$component'",
 542                  DEBUG_DEVELOPER
 543              );
 544              return null;
 545          }
 546  
 547          // Normalise the callback class::method name, we use it later as an identifier.
 548          $classmethod = ltrim($classmethod, '\\');
 549  
 550          return $classmethod;
 551      }
 552  
 553      /**
 554       * Is the plugin callback from lib.php deprecated by any hook?
 555       *
 556       * @param string $plugincallback short callback name without the component prefix
 557       * @return bool
 558       */
 559      public function is_deprecated_plugin_callback(string $plugincallback): bool {
 560          return isset($this->alldeprecations[$plugincallback]);
 561      }
 562  
 563      /**
 564       * Is there a hook callback in component that deprecates given lib.php plugin callback?
 565       *
 566       * NOTE: if there is both hook and deprecated callback then we ignore the old callback
 567       * to allow compatibility of contrib plugins with multiple Moodle branches.
 568       *
 569       * @param string $component
 570       * @param string $plugincallback short callback name without the component prefix
 571       * @return bool
 572       */
 573      public function is_deprecating_hook_present(string $component, string $plugincallback): bool {
 574          if (!isset($this->alldeprecations[$plugincallback])) {
 575              return false;
 576          }
 577  
 578          foreach ($this->alldeprecations[$plugincallback] as $hookclassname) {
 579              if (!isset($this->allcallbacks[$hookclassname])) {
 580                  continue;
 581              }
 582              foreach ($this->allcallbacks[$hookclassname] as $definition) {
 583                  if ($definition['component'] === $component) {
 584                      return true;
 585                  }
 586              }
 587          }
 588  
 589          return false;
 590      }
 591  
 592      /**
 593       * Returns list of hooks discovered through hook namespaces or discovery agents.
 594       *
 595       * The hooks overview page includes also all other classes that are
 596       * referenced in callback registrations in db/hooks.php files, those
 597       * are not included here.
 598       *
 599       * @return array hook class names
 600       */
 601      public static function discover_known_hooks(): array {
 602          // All classes in hook namespace of core and plugins, unless plugin has a discovery agent.
 603          $hooks = \core\hooks::discover_hooks();
 604  
 605          // Look for hooks classes in all plugins that implement discovery agent interface.
 606          foreach (\core_component::get_component_names() as $component) {
 607              $classname = "{$component}\\hooks";
 608  
 609              if (!class_exists($classname)) {
 610                  continue;
 611              }
 612  
 613              if (!is_subclass_of($classname, discovery_agent::class)) {
 614                  continue;
 615              }
 616  
 617              $hooks = array_merge($hooks, $classname::discover_hooks());
 618          }
 619  
 620          return $hooks;
 621      }
 622  }