Proyectos de Subversion Moodle

Rev

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