Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
61.29% |
57 / 93 |
|
20.00% |
1 / 5 |
CRAP | |
0.00% |
0 / 1 |
ActionFactory | |
61.29% |
57 / 93 |
|
20.00% |
1 / 5 |
61.25 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
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 |
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\Context\IContextSource; |
30 | use MediaWiki\Context\RequestContext; |
31 | use MediaWiki\HookContainer\HookContainer; |
32 | use MediaWiki\HookContainer\HookRunner; |
33 | use MediaWiki\Page\PageIdentity; |
34 | use MediaWiki\Title\Title; |
35 | use Psr\Log\LoggerInterface; |
36 | use RawAction; |
37 | use RevertAction; |
38 | use RollbackAction; |
39 | use UnwatchAction; |
40 | use WatchAction; |
41 | use Wikimedia\ObjectFactory\ObjectFactory; |
42 | |
43 | /** |
44 | * @since 1.37 |
45 | * @author DannyS712 |
46 | */ |
47 | class 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 | } |