Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.51% covered (warning)
82.51%
151 / 183
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookContainer
82.51% covered (warning)
82.51%
151 / 183
55.56% covered (warning)
55.56%
10 / 18
101.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 salvage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 run
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
8.25
 clear
n/a
0 / 0
n/a
0 / 0
2
 scopedRegister
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 makeExtensionHandlerCallback
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 normalizeHandler
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
9.33
 isRegistered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getHandlerCallbacks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getHookNames
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getHandlers
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 getHandlerDescriptions
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 describeHandler
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 emitDeprecationWarnings
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 checkDeprecation
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 callableToString
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
9.00
 getHookMethodName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mayBeCallable
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @ingroup Hooks
6 * @defgroup Hooks Hooks
7 * Hooks allow custom code to be executed when an event occurs; this module
8 * includes all hooks provided by MediaWiki Core; for more information, see
9 * https://www.mediawiki.org/wiki/Manual:Hooks.
10 */
11
12namespace MediaWiki\HookContainer;
13
14use Closure;
15use Error;
16use InvalidArgumentException;
17use LogicException;
18use MediaWiki\Debug\MWDebug;
19use ReflectionFunction;
20use UnexpectedValueException;
21use Wikimedia\Assert\Assert;
22use Wikimedia\NonSerializable\NonSerializableTrait;
23use Wikimedia\ObjectFactory\ObjectFactory;
24use Wikimedia\ScopedCallback;
25use Wikimedia\Services\SalvageableService;
26use function array_filter;
27use function array_keys;
28use function array_merge;
29use function array_unique;
30use function is_array;
31use function is_object;
32use function is_string;
33use function strtr;
34
35/**
36 * HookContainer class.
37 *
38 * Main class for managing hooks
39 *
40 * @since 1.35
41 */
42class HookContainer implements SalvageableService {
43    use NonSerializableTrait;
44
45    public const NOOP = '*no-op*';
46
47    /**
48     * Normalized hook handlers, as a 3D array:
49     * - the first level maps hook names to lists of handlers
50     * - the second is a list of handlers
51     * - each handler is an associative array with some well known keys, as returned by normalizeHandler()
52     * @var array<array>
53     * @phan-var array<string,array<string|int,array{callback:callable,functionName:string}>>
54     */
55    private $handlers = [];
56
57    /** @var array<object> handler name and their handler objects */
58    private $handlerObjects = [];
59
60    /** @var HookRegistry */
61    private $registry;
62
63    /**
64     * Handlers registered by calling register().
65     * @var array
66     */
67    private $extraHandlers = [];
68
69    /** @var ObjectFactory */
70    private $objectFactory;
71
72    /** @var int The next ID to be used by scopedRegister() */
73    private $nextScopedRegisterId = 0;
74
75    public function __construct(
76        HookRegistry $hookRegistry,
77        ObjectFactory $objectFactory
78    ) {
79        $this->registry = $hookRegistry;
80        $this->objectFactory = $objectFactory;
81    }
82
83    /**
84     * Salvage the state of HookContainer by retaining existing handler objects
85     * and hooks registered via HookContainer::register(). Necessary in the event
86     * that MediaWikiServices::resetGlobalInstance() is called after hooks have already
87     * been registered.
88     *
89     * @param HookContainer|SalvageableService $other The object to salvage state from. $other be
90     * of type HookContainer
91     */
92    public function salvage( SalvageableService $other ) {
93        Assert::parameterType( self::class, $other, '$other' );
94        if ( $this->handlers || $this->handlerObjects || $this->extraHandlers ) {
95            throw new LogicException( 'salvage() must be called immediately after construction' );
96        }
97        $this->handlerObjects = $other->handlerObjects;
98        $this->handlers = $other->handlers;
99        $this->extraHandlers = $other->extraHandlers;
100    }
101
102    /**
103     * Call registered hook functions through either the legacy $wgHooks or extension.json
104     *
105     * For the given hook, fetch the array of handler objects and
106     * process them. Determine the proper callback for each hook and
107     * then call the actual hook using the appropriate arguments.
108     * Finally, process the return value and return/throw accordingly.
109     *
110     * @param string $hook Name of the hook
111     * @param array $args Arguments to pass to hook handler
112     * @param array $options options map:
113     *   - abortable: (bool) If false, handlers will not be allowed to abort the call sequence.
114     *     An exception will be raised if a handler returns anything other than true or null.
115     *   - deprecatedVersion: (string) Version of MediaWiki this hook was deprecated in. For supporting
116     *     Hooks::run() legacy $deprecatedVersion parameter. New core code should add deprecated
117     *     hooks to the DeprecatedHooks::$deprecatedHooks array literal. New extension code should
118     *     use the DeprecatedHooks attribute.
119     *   - silent: (bool) If true, do not raise a deprecation warning
120     *   - noServices: (bool) If true, do not allow hook handlers with service dependencies
121     * @return bool True if no handler aborted the hook
122     * @throws UnexpectedValueException if handlers return an invalid value
123     */
124    public function run( string $hook, array $args = [], array $options = [] ): bool {
125        $checkDeprecation = isset( $options['deprecatedVersion'] );
126
127        $abortable = $options['abortable'] ?? true;
128        foreach ( $this->getHandlers( $hook, $options ) as $handler ) {
129            if ( $checkDeprecation ) {
130                $this->checkDeprecation( $hook, $handler, $options );
131            }
132
133            // Call the handler.
134            $callback = $handler['callback'];
135            $return = $callback( ...$args );
136
137            // Handler returned false, signal abort to caller
138            if ( $return === false ) {
139                if ( !$abortable ) {
140                    throw new UnexpectedValueException( "Handler {$handler['functionName']}" .
141                        " return false for unabortable $hook." );
142                }
143
144                return false;
145            } elseif ( $return !== null && $return !== true ) {
146                throw new UnexpectedValueException(
147                    "Hook handlers can only return null or a boolean. Got an unexpected value from " .
148                    "handler {$handler['functionName']} for $hook" );
149            }
150        }
151
152        return true;
153    }
154
155    /**
156     * Clear handlers of the given hook.
157     * This is intended for use while testing and will fail if MW_PHPUNIT_TEST
158     * is not defined.
159     *
160     * @param string $hook Name of hook to clear
161     *
162     * @internal For testing only
163     * @codeCoverageIgnore
164     */
165    public function clear( string $hook ): void {
166        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
167            throw new LogicException( 'Cannot reset hooks in operation.' );
168        }
169
170        $this->handlers[$hook] = [];
171    }
172
173    /**
174     * Register hook and handler, allowing for easy removal.
175     * Intended for use in temporary registration e.g. testing
176     *
177     * @param string $hook Name of hook
178     * @param callable|string|array $handler Handler to attach
179     */
180    #[\NoDiscard]
181    public function scopedRegister( string $hook, $handler ): ScopedCallback {
182        $handler = $this->normalizeHandler( $hook, $handler );
183        if ( !$handler ) {
184            throw new InvalidArgumentException( 'Bad hook handler!' );
185        }
186
187        $this->checkDeprecation( $hook, $handler );
188
189        $id = 'TemporaryHook_' . $this->nextScopedRegisterId++;
190
191        $this->getHandlers( $hook );
192
193        $this->handlers[$hook][$id] = $handler;
194
195        return new ScopedCallback( function () use ( $hook, $id ) {
196            unset( $this->handlers[$hook][$id] );
197        } );
198    }
199
200    /**
201     * Returns a callable array based on the handler specification provided.
202     * This will find the appropriate handler object to call a method on,
203     * This will find the appropriate handler object to call a method on,
204     * instantiating it if it doesn't exist yet.
205     *
206     * @param string $hook The name of the hook the handler was registered for
207     * @param array $handler A hook handler specification as given in an extension.json file.
208     * @param array $options Options to apply. If the 'noServices' option is set and the
209     *              handler requires service injection, this method will throw an
210     *              UnexpectedValueException.
211     *
212     * @return callable-array
213     */
214    private function makeExtensionHandlerCallback( string $hook, array $handler, array $options = [] ): array {
215        $spec = $handler['handler'];
216        $name = $spec['name'];
217
218        if (
219            !empty( $options['noServices'] ) && (
220                !empty( $spec['services'] ) ||
221                !empty( $spec['optional_services'] )
222            )
223        ) {
224            throw new UnexpectedValueException(
225                "The handler for the hook $hook registered in " .
226                "{$handler['extensionPath']} has a service dependency, " .
227                "but this hook does not allow it." );
228        }
229
230        if ( !isset( $this->handlerObjects[$name] ) ) {
231            $this->handlerObjects[$name] = $this->objectFactory->createObject( $spec );
232        }
233
234        $obj = $this->handlerObjects[$name];
235        $method = $this->getHookMethodName( $hook );
236
237        return [ $obj, $method ];
238    }
239
240    /**
241     * Normalize/clean up format of argument passed as hook handler
242     *
243     * @param string $hook Hook name
244     * @param string|callable|array{handler:array} $handler Executable handler function. See {@link self::register()}
245     * for supported structures.
246     * @param array $options see makeExtensionHandlerCallback()
247     *
248     * @return array|false
249     *  - callback: (callable) Executable handler function
250     *  - functionName: (string) Handler name for passing to wfDeprecated() or Exceptions thrown
251     * @phan-return array{callback:callable,functionName:string}|false
252     */
253    private function normalizeHandler( string $hook, $handler, array $options = [] ) {
254        // 1 - Class instance with `on$hook` method.
255        if ( is_object( $handler ) && !$handler instanceof Closure ) {
256            $handler = [ $handler, $this->getHookMethodName( $hook ) ];
257        }
258
259        // 2 - No-op
260        if ( $handler === self::NOOP ) {
261            return [
262                'callback' => static function () {
263                    // no-op
264                },
265                'functionName' => self::NOOP,
266            ];
267        }
268
269        // 3 - Plain callback
270        if ( self::mayBeCallable( $handler ) ) {
271            return [
272                'callback' => $handler,
273                'functionName' => self::callableToString( $handler ),
274            ];
275        }
276
277        // 4 - ExtensionRegistry style handler
278        if ( is_array( $handler ) && !empty( $handler['handler'] ) ) {
279            // Skip hooks that both acknowledge deprecation and are deprecated in core
280            if ( $handler['deprecated'] ?? false ) {
281                $deprecatedHooks = $this->registry->getDeprecatedHooks();
282                $deprecated = $deprecatedHooks->isHookDeprecated( $hook );
283                if ( $deprecated ) {
284                    return false;
285                }
286            }
287
288            $callback = $this->makeExtensionHandlerCallback( $hook, $handler, $options );
289            return [
290                'callback' => $callback,
291                'functionName' => self::callableToString( $callback ),
292            ];
293        }
294
295        // Something invalid
296        return false;
297    }
298
299    /**
300     * Return whether hook has any handlers registered to it.
301     * The function may have been registered via Hooks::register or in extension.json
302     *
303     * @param string $hook Name of hook
304     * @return bool Whether the hook has a handler registered to it
305     */
306    public function isRegistered( string $hook ): bool {
307        return (bool)$this->getHandlers( $hook );
308    }
309
310    /**
311     * Attach an event handler to a given hook.
312     *
313     * The handler should be given in one of the following forms:
314     *
315     * 1) A callable (string, array, or closure)
316     * 2) An extension hook handler spec in the form returned by
317     *    HookRegistry::getExtensionHooks
318     * 3) A class instance with an `on$hook` method (see {@link self::getHookMethodName} for normalizations applied)
319     * 4) {@link self::NOOP} as a no-op handler
320     *
321     * Several other forms are supported for backwards compatibility, but
322     * should not be used when calling this method directly.
323     *
324     * @note This method accepts "broken callables", that is, callable
325     * structures that reference classes that could not be found or could
326     * not be loaded, e.g. because they implement an interface that cannot
327     * be loaded. This situation may legitimately arise when implementing
328     * hooks defined by extensions that are not present.
329     * In that case, the hook will never fire and registering the "broken"
330     * handlers is harmless. If a broken hook handler is registered for a
331     * hook that is indeed called, it will cause an error. This is
332     * intentional: we don't want to silently ignore mistakes like mistyped
333     * class names in a hook handler registration.
334     *
335     * @param string $hook Name of hook
336     * @param string|array|callable $handler handler
337     */
338    public function register( string $hook, $handler ) {
339        $this->checkDeprecation( $hook, $handler );
340
341        if ( !isset( $this->handlers[$hook] ) ) {
342            // Just remember the handler for later.
343            // NOTE: It would be nice to normalize immediately. But since some extensions make extensive
344            //       use of this method for registering hooks on every call, that could be a performance
345            //       issue. This is particularly true if the hook is declared in a way that would require
346            //       service objects to be instantiated.
347            $this->extraHandlers[$hook][] = $handler;
348            return;
349        }
350
351        $normalized = $this->normalizeHandler( $hook, $handler );
352        if ( !$normalized ) {
353            throw new InvalidArgumentException( 'Bad hook handler!' );
354        }
355
356        $this->getHandlers( $hook );
357        $this->handlers[$hook][] = $normalized;
358    }
359
360    /**
361     * Get handler callbacks.
362     *
363     * @deprecated since 1.41.
364     * @internal For use by HookContainerTest. Delete when no longer needed.
365     * @param string $hook Name of hook
366     * @return callable[]
367     */
368    public function getHandlerCallbacks( string $hook ): array {
369        wfDeprecated( __METHOD__, '1.41' );
370        $handlers = $this->getHandlers( $hook );
371        return array_column( $handlers, 'callback' );
372    }
373
374    /**
375     * Returns the names of all hooks that have at least one handler registered.
376     * @return string[]
377     */
378    public function getHookNames(): array {
379        $names = array_merge(
380            array_keys( array_filter( $this->handlers ) ),
381            array_keys( array_filter( $this->extraHandlers ) ),
382            array_keys( array_filter( $this->registry->getGlobalHooks() ) ),
383            array_keys( array_filter( $this->registry->getExtensionHooks() ) )
384        );
385
386        return array_unique( $names );
387    }
388
389    /**
390     * Return the array of handlers for the given hook.
391     *
392     * @param string $hook Name of the hook
393     * @param array $options Handler options, which may include:
394     *   - noServices: Do not allow hook handlers with service dependencies
395     * @return array[] A list of handler entries
396     * @phan-return array<string|int,array{callback:callable,functionName:string}>
397     */
398    private function getHandlers( string $hook, array $options = [] ): array {
399        if ( !isset( $this->handlers[$hook] ) ) {
400            $handlers = [];
401            $registeredHooks = $this->registry->getExtensionHooks();
402            $configuredHooks = $this->registry->getGlobalHooks();
403
404            $rawHandlers = array_merge(
405                $configuredHooks[ $hook ] ?? [],
406                $registeredHooks[ $hook ] ?? [],
407                $this->extraHandlers[ $hook ] ?? [],
408            );
409
410            foreach ( $rawHandlers as $raw ) {
411                $handler = $this->normalizeHandler( $hook, $raw, $options );
412                if ( !$handler ) {
413                    // XXX: log this?!
414                    // NOTE: also happens for deprecated hooks, which is fine!
415                    continue;
416                }
417
418                $handlers[] = $handler;
419            }
420
421            $this->handlers[ $hook ] = $handlers;
422        }
423
424        return $this->handlers[ $hook ];
425    }
426
427    /**
428     * Return the array of strings that describe the handler registered with the given hook.
429     *
430     * @internal Only public for use by ApiQuerySiteInfo.php and SpecialVersion.php
431     * @param string $hook Name of the hook
432     * @return string[] A list of handler descriptions
433     */
434    public function getHandlerDescriptions( string $hook ): array {
435        $descriptions = [];
436
437        if ( isset( $this->handlers[ $hook ] ) ) {
438            $rawHandlers = $this->handlers[ $hook ];
439        } else {
440            $registeredHooks = $this->registry->getExtensionHooks();
441            $configuredHooks = $this->registry->getGlobalHooks();
442
443            $rawHandlers = array_merge(
444                $configuredHooks[ $hook ] ?? [],
445                $registeredHooks[ $hook ] ?? [],
446                $this->extraHandlers[ $hook ] ?? [],
447            );
448        }
449
450        foreach ( $rawHandlers as $raw ) {
451            $descr = $this->describeHandler( $hook, $raw );
452
453            if ( $descr ) {
454                $descriptions[] = $descr;
455            }
456        }
457
458        return $descriptions;
459    }
460
461    /**
462     * Returns a human-readable description of the given handler.
463     *
464     * @param string $hook
465     * @param string|array|callable $handler
466     *
467     * @return ?string
468     */
469    private function describeHandler( string $hook, $handler ): ?string {
470        if ( is_array( $handler ) ) {
471            // already normalized
472            if ( isset( $handler['functionName'] ) ) {
473                return $handler['functionName'];
474            }
475
476            if ( isset( $handler['callback'] ) ) {
477                return self::callableToString( $handler['callback'] );
478            }
479
480            if ( isset( $handler['handler']['class'] ) ) {
481                // New style hook. Avoid instantiating the handler object
482                $method = $this->getHookMethodName( $hook );
483                return $handler['handler']['class'] . '::' . $method;
484            }
485        }
486
487        $handler = $this->normalizeHandler( $hook, $handler );
488        return $handler ? $handler['functionName'] : null;
489    }
490
491    /**
492     * For each hook handler of each hook, this will log a deprecation if:
493     * 1. the hook is marked deprecated and
494     * 2. the "silent" flag is absent or false, and
495     * 3. an extension registers a handler in the new way but does not acknowledge deprecation
496     */
497    public function emitDeprecationWarnings() {
498        $deprecatedHooks = $this->registry->getDeprecatedHooks();
499        $extensionHooks = $this->registry->getExtensionHooks();
500
501        foreach ( $extensionHooks as $name => $handlers ) {
502            if ( $deprecatedHooks->isHookDeprecated( $name ) ) {
503                $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $name );
504                if ( !empty( $deprecationInfo['silent'] ) ) {
505                    continue;
506                }
507                $version = $deprecationInfo['deprecatedVersion'] ?? '';
508                $component = $deprecationInfo['component'] ?? 'MediaWiki';
509                foreach ( $handlers as $handler ) {
510                    if ( !isset( $handler['deprecated'] ) || !$handler['deprecated'] ) {
511                        MWDebug::sendRawDeprecated(
512                            "Hook $name was deprecated in $component $version " .
513                            "but is registered in " . $handler['extensionPath']
514                        );
515                    }
516                }
517            }
518        }
519    }
520
521    /**
522     * Will trigger a deprecation warning if the given hook is deprecated and the deprecation
523     * is not marked as silent.
524     *
525     * @param string $hook The name of the hook.
526     * @param array|callable|string $handler A handler spec
527     * @param array|null $deprecationInfo Deprecation info if the caller already knows it.
528     *        If not given, it will be looked up from the hook registry.
529     *
530     * @return void
531     */
532    private function checkDeprecation( string $hook, $handler, ?array $deprecationInfo = null ): void {
533        if ( !$deprecationInfo ) {
534            $deprecatedHooks = $this->registry->getDeprecatedHooks();
535            $deprecationInfo = $deprecatedHooks->getDeprecationInfo( $hook );
536        }
537
538        if ( $deprecationInfo && empty( $deprecationInfo['silent'] ) ) {
539            $description = $this->describeHandler( $hook, $handler );
540            wfDeprecated(
541                "$hook hook (used in $description)",
542                $deprecationInfo['deprecatedVersion'] ?? false,
543                $deprecationInfo['component'] ?? false
544            );
545        }
546    }
547
548    /**
549     * Returns a human-readable representation of the given callable.
550     *
551     * @param callable $callable
552     *
553     * @return string
554     */
555    private static function callableToString( $callable ): string {
556        if ( is_string( $callable ) ) {
557            return $callable;
558        }
559
560        if ( $callable instanceof Closure ) {
561            $func = new ReflectionFunction( $callable );
562            $cls = $func->getClosureCalledClass();
563            if ( $func->getClosureThis() && $cls ) {
564                return "({$cls->getName()})->{$func->getName()}(...)";
565            } elseif ( $cls ) {
566                return "{$cls->getName()}::{$func->getName()}(...)";
567            } else {
568                return "{$func->getName()}(...)";
569            }
570        }
571
572        if ( is_array( $callable ) ) {
573            [ $on, $func ] = $callable;
574
575            if ( is_object( $on ) ) {
576                $on = get_class( $on );
577            }
578
579            return "$on::$func";
580        }
581
582        throw new InvalidArgumentException( 'Unexpected kind of callable' );
583    }
584
585    /**
586     * Returns the default handler method name for the given hook.
587     *
588     * @param string $hook
589     *
590     * @return string
591     */
592    private function getHookMethodName( string $hook ): string {
593        $hook = strtr( $hook, ':\\-', '___' );
594        return "on$hook";
595    }
596
597    /**
598     * Replacement for is_callable that will also return true when the callable uses a class
599     * that cannot be loaded.
600     *
601     * This may legitimately happen when a hook handler uses a hook interfaces that is defined
602     * in another extension. In that case, the hook itself is also defined in the other extension,
603     * so the hook will never be called and no problem arises.
604     *
605     * However, it is entirely possible to register broken handlers for hooks that will indeed
606     * be called, causing an error. This is intentional: we don't want to silently ignore
607     * mistakes like mistyped class names in a hook handler registration.
608     *
609     * @param mixed $v
610     *
611     * @return bool
612     */
613    private static function mayBeCallable( $v ): bool {
614        try {
615            return is_callable( $v );
616        } catch ( Error $error ) {
617            // If the callable uses a class that can't be loaded because it extends an unknown base class.
618            // Continue as if is_callable had returned true, to allow the handler to be registered.
619            if ( preg_match( '/Class.*not found/', $error->getMessage() ) ) {
620                return true;
621            }
622
623            throw $error;
624        }
625    }
626}