Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

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