Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.29% covered (warning)
61.29%
57 / 93
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActionFactory
61.29% covered (warning)
61.29%
57 / 93
20.00% covered (danger)
20.00%
1 / 5
61.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getActionSpec
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getAction
86.05% covered (warning)
86.05%
37 / 43
0.00% covered (danger)
0.00%
0 / 1
11.33
 getActionInfo
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getActionName
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 getArticle
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program; if not, write to the Free Software
15 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
16 *
17 * @file
18 */
19
20namespace MediaWiki\Actions;
21
22use Action;
23use Article;
24use CreditsAction;
25use InfoAction;
26use MarkpatrolledAction;
27use McrRestoreAction;
28use McrUndoAction;
29use MediaWiki\Context\IContextSource;
30use MediaWiki\Context\RequestContext;
31use MediaWiki\HookContainer\HookContainer;
32use MediaWiki\HookContainer\HookRunner;
33use MediaWiki\Page\PageIdentity;
34use MediaWiki\Title\Title;
35use Psr\Log\LoggerInterface;
36use RawAction;
37use RevertAction;
38use RollbackAction;
39use UnwatchAction;
40use WatchAction;
41use Wikimedia\ObjectFactory\ObjectFactory;
42
43/**
44 * @since 1.37
45 * @author DannyS712
46 */
47class ActionFactory {
48
49    /**
50     * @var array
51     * Configured actions (eg those added by extensions to $wgActions) that overrides CORE_ACTIONS
52     */
53    private $actionsConfig;
54
55    private LoggerInterface $logger;
56    private ObjectFactory $objectFactory;
57    private HookContainer $hookContainer;
58    private HookRunner $hookRunner;
59
60    /**
61     * Core default action specifications
62     *
63     *     'foo' => 'ClassName'    Load the specified class which subclasses Action
64     *     'foo' => a callable     Load the class returned by the callable
65     *     'foo' => true           Load the class FooAction which subclasses Action
66     *     'foo' => false          The action is disabled; show an error message
67     *     'foo' => an object      Use the specified object, which subclasses Action, useful for tests.
68     *     'foo' => an array       Slowly being used to replace the first three. The array
69     *                               is treated as a specification for an ObjectFactory.
70     */
71    private const CORE_ACTIONS = [
72        'delete' => true,
73        'edit' => true,
74        'history' => true,
75        'protect' => true,
76        'purge' => true,
77        'render' => true,
78        'submit' => true,
79        'unprotect' => true,
80        'view' => true,
81
82        // Beginning of actions switched to using DI with an ObjectFactory spec
83        'credits' => [
84            'class' => CreditsAction::class,
85            'services' => [
86                'LinkRenderer',
87                'UserFactory',
88            ],
89        ],
90        'info' => [
91            'class' => InfoAction::class,
92            'services' => [
93                'ContentLanguage',
94                'LanguageNameUtils',
95                'LinkBatchFactory',
96                'LinkRenderer',
97                'DBLoadBalancerFactory',
98                'MagicWordFactory',
99                'NamespaceInfo',
100                'PageProps',
101                'RepoGroup',
102                'RevisionLookup',
103                'MainWANObjectCache',
104                'WatchedItemStore',
105                'RedirectLookup',
106                'RestrictionStore',
107                'LinksMigration',
108                'UserFactory',
109            ],
110        ],
111        'markpatrolled' => [
112            'class' => MarkpatrolledAction::class,
113            'services' => [
114                'LinkRenderer',
115            ],
116        ],
117        'mcrundo' => [
118            'class' => McrUndoAction::class,
119            'services' => [
120                // Same as for McrRestoreAction
121                'ReadOnlyMode',
122                'RevisionLookup',
123                'RevisionRenderer',
124                'CommentFormatter',
125                'MainConfig',
126            ],
127        ],
128        'mcrrestore' => [
129            'class' => McrRestoreAction::class,
130            'services' => [
131                // Same as for McrUndoAction
132                'ReadOnlyMode',
133                'RevisionLookup',
134                'RevisionRenderer',
135                'CommentFormatter',
136                'MainConfig',
137            ],
138        ],
139        'raw' => [
140            'class' => RawAction::class,
141            'services' => [
142                'Parser',
143                'PermissionManager',
144                'RevisionLookup',
145                'RestrictionStore',
146                'UserFactory',
147            ],
148        ],
149        'revert' => [
150            'class' => RevertAction::class,
151            'services' => [
152                'ContentLanguage',
153                'RepoGroup',
154            ],
155        ],
156        'rollback' => [
157            'class' => RollbackAction::class,
158            'services' => [
159                'ContentHandlerFactory',
160                'RollbackPageFactory',
161                'UserOptionsLookup',
162                'WatchlistManager',
163                'CommentFormatter'
164            ],
165        ],
166        'unwatch' => [
167            'class' => UnwatchAction::class,
168            'services' => [
169                'WatchlistManager',
170                'WatchedItemStore',
171            ],
172        ],
173        'watch' => [
174            'class' => WatchAction::class,
175            'services' => [
176                'WatchlistManager',
177                'WatchedItemStore',
178            ],
179        ],
180    ];
181
182    /**
183     * @param array $actionsConfig Configured actions (eg those added by extensions to $wgActions)
184     * @param LoggerInterface $logger
185     * @param ObjectFactory $objectFactory
186     * @param HookContainer $hookContainer
187     */
188    public function __construct(
189        array $actionsConfig,
190        LoggerInterface $logger,
191        ObjectFactory $objectFactory,
192        HookContainer $hookContainer
193    ) {
194        $this->actionsConfig = $actionsConfig;
195        $this->logger = $logger;
196        $this->objectFactory = $objectFactory;
197        $this->hookContainer = $hookContainer;
198        $this->hookRunner = new HookRunner( $hookContainer );
199    }
200
201    /**
202     * @param string $actionName should already be in all lowercase
203     * @return class-string|callable|false|Action|array|null The spec for the action, in any valid form,
204     *   based on $this->actionsConfig, or if not included there, CORE_ACTIONS, or null if the
205     *   action does not exist.
206     */
207    private function getActionSpec( string $actionName ) {
208        if ( isset( $this->actionsConfig[ $actionName ] ) ) {
209            $this->logger->debug(
210                '{actionName} is being set in configuration rather than CORE_ACTIONS',
211                [
212                    'actionName' => $actionName
213                ]
214            );
215            return $this->actionsConfig[ $actionName ];
216        }
217        return ( self::CORE_ACTIONS[ $actionName ] ?? null );
218    }
219
220    /**
221     * Get an appropriate Action subclass for the given action,
222     * taking into account Article-specific overrides
223     *
224     * @param string $actionName
225     * @param Article|PageIdentity $article The target on which the action is to be performed.
226     * @param IContextSource $context
227     * @return Action|false|null False if the action is disabled, null if not recognized
228     */
229    public function getAction(
230        string $actionName,
231        $article,
232        IContextSource $context
233    ) {
234        // Normalize to lowercase
235        $actionName = strtolower( $actionName );
236
237        $spec = $this->getActionSpec( $actionName );
238        if ( $spec === false ) {
239            // The action is disabled
240            return $spec;
241        }
242
243        if ( $article instanceof PageIdentity ) {
244            if ( !$article->canExist() ) {
245                // Encountered a non-proper PageIdentity (e.g. a special page).
246                // We can't construct an Article object for a SpecialPage,
247                // so give up here. Actions are only defined for proper pages anyway.
248                // See T348451.
249                return null;
250            }
251
252            $article = Article::newFromTitle(
253                Title::newFromPageIdentity( $article ),
254                $context
255            );
256        }
257
258        // Check action overrides even for nonexistent actions, so that actions
259        // can exist just for a single content type. For Flow's convenience.
260        $overrides = $article->getActionOverrides();
261        if ( isset( $overrides[ $actionName ] ) ) {
262            // The Article class wants to override the action
263            $spec = $overrides[ $actionName ];
264            $this->logger->debug(
265                'Overriding normal handler for {actionName}',
266                [ 'actionName' => $actionName ]
267            );
268        }
269
270        if ( !$spec ) {
271            // Either no such action exists (null) or the action is disabled
272            // based on the article overrides (false)
273            return $spec;
274        }
275
276        if ( $spec === true ) {
277            // Old-style: use Action subclass based on name
278            $spec = ucfirst( $actionName ) . 'Action';
279        }
280
281        // $spec is either a class name, a callable, a specific object to use, or an
282        // ObjectFactory spec. Convert to ObjectFactory spec, or return the specific object.
283        if ( is_string( $spec ) ) {
284            if ( !class_exists( $spec ) ) {
285                $this->logger->info(
286                    'Missing action class {actionClass}, treating as disabled',
287                    [ 'actionClass' => $spec ]
288                );
289                return false;
290            }
291            // Class exists, can be used by ObjectFactory
292            $spec = [ 'class' => $spec ];
293        } elseif ( is_callable( $spec ) ) {
294            $spec = [ 'factory' => $spec ];
295        } elseif ( !is_array( $spec ) ) {
296            // $spec is an object to use directly
297            return $spec;
298        }
299
300        // ObjectFactory::createObject accepts an array, not just a callable (phan bug)
301        // @phan-suppress-next-line PhanTypeInvalidCallableArrayKey
302        $actionObj = $this->objectFactory->createObject(
303            $spec,
304            [
305                'extraArgs' => [ $article, $context ],
306                'assertClass' => Action::class
307            ]
308        );
309        $actionObj->setHookContainer( $this->hookContainer );
310        return $actionObj;
311    }
312
313    /**
314     * Returns an object containing information about the given action, or null if the action is not
315     * known. Currently, this will also return null if the action is known but disabled. This may
316     * change in the future.
317     *
318     * @note If $target refers to a non-proper page (such as a special page), this method will
319     * currently return null due to limitations in the way it is implemented (T346036). This
320     * will also happen when $target is null if the wiki's main page is not a proper page
321     * (e.g. Special:MyLanguage/Main_Page, see T348451).
322     *
323     * @param string $name
324     * @param Article|PageIdentity|null $target The target on which the action is to be performed,
325     *     if known. This is used to apply page-specific action overrides.
326     *
327     * @return ?ActionInfo
328     * @since 1.41
329     */
330    public function getActionInfo( string $name, $target = null ): ?ActionInfo {
331        $context = RequestContext::getMain();
332
333        if ( !$target ) {
334            // If no target is given, check if the action is even defined before
335            // falling back to the main page. If $target is given, we can't
336            // exit early, since there may be action overrides defined for the page.
337            $spec = $this->getActionSpec( $name );
338            if ( !$spec ) {
339                return null;
340            }
341
342            $target = Title::newMainPage();
343        }
344
345        // TODO: In the future, this information should be taken directly from the action spec,
346        // without the need to instantiate an action object. However, action overrides will have
347        // to be taken into account if a target is given. (T346036)
348        $actionObj = $this->getAction( $name, $target, $context );
349
350        // TODO: When we no longer need to instantiate the action in order to determine the info,
351        // we will be able to return info for disabled actions as well.
352        if ( !$actionObj ) {
353            return null;
354        }
355
356        return new ActionInfo( [
357            'name' => $actionObj->getName(),
358            'restriction' => $actionObj->getRestriction(),
359            'needsReadRights' => $actionObj->needsReadRights(),
360            'requiresWrite' => $actionObj->requiresWrite(),
361            'requiresUnblock' => $actionObj->requiresUnblock(),
362        ] );
363    }
364
365    /**
366     * Get the name of the action that will be executed, not necessarily the one
367     * passed through the "action" request parameter. Actions disabled in
368     * $wgActions will be replaced by "nosuchaction".
369     *
370     * @param IContextSource $context
371     * @return string Action name
372     */
373    public function getActionName( IContextSource $context ): string {
374        // Trying to get a WikiPage for NS_SPECIAL etc. will result
375        // in WikiPageFactory::newFromTitle throwing "Invalid or virtual namespace -1 given."
376        // For SpecialPages et al, default to action=view.
377        if ( !$context->canUseWikiPage() ) {
378            return 'view';
379        }
380
381        $request = $context->getRequest();
382        $actionName = $request->getRawVal( 'action' ) ?? 'view';
383
384        // Normalize to lowercase
385        $actionName = strtolower( $actionName );
386
387        // Check for disabled actions
388        if ( $this->getActionSpec( $actionName ) === false ) {
389            // We could just set the action to 'nosuchaction' here and proceed,
390            // but there should never be an action with the name 'nosuchaction'
391            // and so getAction will return null, and then we would return
392            // 'nosuchaction' anyway, so lets just return now
393            return 'nosuchaction';
394        }
395
396        if ( $actionName === 'historysubmit' ) {
397            // Compatibility with old URLs for no-JS form submissions from action=history (T323338, T22966).
398            // (This is needed to handle diff links; other uses of 'historysubmit' are handled in MediaWiki.php.)
399            $actionName = 'view';
400        } elseif ( $actionName === 'editredlink' ) {
401            $actionName = 'edit';
402        }
403
404        $this->hookRunner->onGetActionName( $context, $actionName );
405
406        $action = $this->getAction(
407            $actionName,
408            $this->getArticle( $context ),
409            $context
410        );
411
412        // Might not be an Action object if the action is not recognized (so $action could
413        // be null) but should never be false because we already handled disabled actions
414        // above.
415        if ( $action instanceof Action ) {
416            return $action->getName();
417        }
418
419        return 'nosuchaction';
420    }
421
422    /**
423     * Protected to allow overriding with a partial mock in unit tests
424     *
425     * @codeCoverageIgnore
426     *
427     * @param IContextSource $context
428     * @return Article
429     */
430    protected function getArticle( IContextSource $context ): Article {
431        return Article::newFromWikiPage( $context->getWikiPage(), $context );
432    }
433
434}