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