Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
76.92% |
340 / 442 |
|
47.06% |
8 / 17 |
CRAP | |
0.00% |
0 / 1 |
MovePage | |
77.10% |
340 / 441 |
|
47.06% |
8 / 17 |
190.48 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
1 | |||
setMaximumMovedPages | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
authorizeInternal | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
6.17 | |||
probablyCanMove | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
authorizeMove | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
checkPermissions | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
isValidMove | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
18 | |||
isValidFileMove | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
9 | |||
isValidMoveTarget | |
85.19% |
23 / 27 |
|
0.00% |
0 / 1 |
9.26 | |||
move | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
moveIfAllowed | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
4.77 | |||
moveSubpages | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
moveSubpagesIfAllowed | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
moveSubpagesInternal | |
71.05% |
27 / 38 |
|
0.00% |
0 / 1 |
13.94 | |||
moveUnsafe | |
50.00% |
48 / 96 |
|
0.00% |
0 / 1 |
22.50 | |||
moveFile | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
moveToInternal | |
76.86% |
93 / 121 |
|
0.00% |
0 / 1 |
13.78 |
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 along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Page; |
22 | |
23 | use ChangeTags; |
24 | use ContentHandler; |
25 | use File; |
26 | use IDBAccessObject; |
27 | use LogFormatter; |
28 | use ManualLogEntry; |
29 | use MediaWiki\Collation\CollationFactory; |
30 | use MediaWiki\CommentStore\CommentStoreComment; |
31 | use MediaWiki\Config\ServiceOptions; |
32 | use MediaWiki\Content\IContentHandlerFactory; |
33 | use MediaWiki\Context\RequestContext; |
34 | use MediaWiki\Deferred\AtomicSectionUpdate; |
35 | use MediaWiki\Deferred\DeferredUpdates; |
36 | use MediaWiki\EditPage\SpamChecker; |
37 | use MediaWiki\HookContainer\HookContainer; |
38 | use MediaWiki\HookContainer\HookRunner; |
39 | use MediaWiki\MainConfigNames; |
40 | use MediaWiki\Permissions\Authority; |
41 | use MediaWiki\Permissions\PermissionStatus; |
42 | use MediaWiki\Permissions\RestrictionStore; |
43 | use MediaWiki\Revision\RevisionStore; |
44 | use MediaWiki\Revision\SlotRecord; |
45 | use MediaWiki\Status\Status; |
46 | use MediaWiki\Storage\PageUpdaterFactory; |
47 | use MediaWiki\Title\NamespaceInfo; |
48 | use MediaWiki\Title\Title; |
49 | use MediaWiki\User\UserEditTracker; |
50 | use MediaWiki\User\UserFactory; |
51 | use MediaWiki\User\UserIdentity; |
52 | use RepoGroup; |
53 | use RuntimeException; |
54 | use StringUtils; |
55 | use WatchedItemStoreInterface; |
56 | use Wikimedia\Rdbms\IConnectionProvider; |
57 | use Wikimedia\Rdbms\IDatabase; |
58 | use WikiPage; |
59 | use WikitextContent; |
60 | |
61 | /** |
62 | * Handles the backend logic of moving a page from one title |
63 | * to another. |
64 | * |
65 | * @since 1.24 |
66 | */ |
67 | class MovePage { |
68 | |
69 | /** |
70 | * @var Title |
71 | */ |
72 | protected $oldTitle; |
73 | |
74 | /** |
75 | * @var Title |
76 | */ |
77 | protected $newTitle; |
78 | |
79 | /** |
80 | * @var ServiceOptions |
81 | */ |
82 | protected $options; |
83 | |
84 | /** |
85 | * @var IConnectionProvider |
86 | */ |
87 | protected $dbProvider; |
88 | |
89 | /** |
90 | * @var NamespaceInfo |
91 | */ |
92 | protected $nsInfo; |
93 | |
94 | /** |
95 | * @var WatchedItemStoreInterface |
96 | */ |
97 | protected $watchedItems; |
98 | |
99 | /** |
100 | * @var RepoGroup |
101 | */ |
102 | protected $repoGroup; |
103 | |
104 | /** |
105 | * @var IContentHandlerFactory |
106 | */ |
107 | private $contentHandlerFactory; |
108 | |
109 | /** |
110 | * @var RevisionStore |
111 | */ |
112 | private $revisionStore; |
113 | |
114 | /** |
115 | * @var SpamChecker |
116 | */ |
117 | private $spamChecker; |
118 | |
119 | /** |
120 | * @var HookRunner |
121 | */ |
122 | private $hookRunner; |
123 | |
124 | /** |
125 | * @var WikiPageFactory |
126 | */ |
127 | private $wikiPageFactory; |
128 | |
129 | /** |
130 | * @var UserFactory |
131 | */ |
132 | private $userFactory; |
133 | |
134 | /** @var UserEditTracker */ |
135 | private $userEditTracker; |
136 | |
137 | /** @var MovePageFactory */ |
138 | private $movePageFactory; |
139 | |
140 | /** @var CollationFactory */ |
141 | public $collationFactory; |
142 | |
143 | /** @var PageUpdaterFactory */ |
144 | private $pageUpdaterFactory; |
145 | |
146 | /** @var RestrictionStore */ |
147 | private $restrictionStore; |
148 | |
149 | /** @var int */ |
150 | private $maximumMovedPages; |
151 | |
152 | /** |
153 | * @internal For use by PageCommandFactory |
154 | */ |
155 | public const CONSTRUCTOR_OPTIONS = [ |
156 | MainConfigNames::CategoryCollation, |
157 | MainConfigNames::MaximumMovedPages, |
158 | ]; |
159 | |
160 | /** |
161 | * @see MovePageFactory |
162 | * |
163 | * @param Title $oldTitle |
164 | * @param Title $newTitle |
165 | * @param ServiceOptions $options |
166 | * @param IConnectionProvider $dbProvider |
167 | * @param NamespaceInfo $nsInfo |
168 | * @param WatchedItemStoreInterface $watchedItems |
169 | * @param RepoGroup $repoGroup |
170 | * @param IContentHandlerFactory $contentHandlerFactory |
171 | * @param RevisionStore $revisionStore |
172 | * @param SpamChecker $spamChecker |
173 | * @param HookContainer $hookContainer |
174 | * @param WikiPageFactory $wikiPageFactory |
175 | * @param UserFactory $userFactory |
176 | * @param UserEditTracker $userEditTracker |
177 | * @param MovePageFactory $movePageFactory |
178 | * @param CollationFactory $collationFactory |
179 | * @param PageUpdaterFactory $pageUpdaterFactory |
180 | * @param RestrictionStore $restrictionStore |
181 | */ |
182 | public function __construct( |
183 | Title $oldTitle, |
184 | Title $newTitle, |
185 | ServiceOptions $options, |
186 | IConnectionProvider $dbProvider, |
187 | NamespaceInfo $nsInfo, |
188 | WatchedItemStoreInterface $watchedItems, |
189 | RepoGroup $repoGroup, |
190 | IContentHandlerFactory $contentHandlerFactory, |
191 | RevisionStore $revisionStore, |
192 | SpamChecker $spamChecker, |
193 | HookContainer $hookContainer, |
194 | WikiPageFactory $wikiPageFactory, |
195 | UserFactory $userFactory, |
196 | UserEditTracker $userEditTracker, |
197 | MovePageFactory $movePageFactory, |
198 | CollationFactory $collationFactory, |
199 | PageUpdaterFactory $pageUpdaterFactory, |
200 | RestrictionStore $restrictionStore |
201 | ) { |
202 | $this->oldTitle = $oldTitle; |
203 | $this->newTitle = $newTitle; |
204 | |
205 | $this->options = $options; |
206 | $this->dbProvider = $dbProvider; |
207 | $this->nsInfo = $nsInfo; |
208 | $this->watchedItems = $watchedItems; |
209 | $this->repoGroup = $repoGroup; |
210 | $this->contentHandlerFactory = $contentHandlerFactory; |
211 | $this->revisionStore = $revisionStore; |
212 | $this->spamChecker = $spamChecker; |
213 | $this->hookRunner = new HookRunner( $hookContainer ); |
214 | $this->wikiPageFactory = $wikiPageFactory; |
215 | $this->userFactory = $userFactory; |
216 | $this->userEditTracker = $userEditTracker; |
217 | $this->movePageFactory = $movePageFactory; |
218 | $this->collationFactory = $collationFactory; |
219 | $this->pageUpdaterFactory = $pageUpdaterFactory; |
220 | $this->restrictionStore = $restrictionStore; |
221 | |
222 | $this->maximumMovedPages = $this->options->get( MainConfigNames::MaximumMovedPages ); |
223 | } |
224 | |
225 | /** |
226 | * Override $wgMaximumMovedPages. |
227 | * |
228 | * @param int $max The maximum number of subpages to move, or -1 for no limit |
229 | */ |
230 | public function setMaximumMovedPages( $max ) { |
231 | $this->maximumMovedPages = $max; |
232 | } |
233 | |
234 | /** |
235 | * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status ) |
236 | * @param Authority $performer |
237 | * @param string|null $reason |
238 | * @return PermissionStatus |
239 | */ |
240 | private function authorizeInternal( |
241 | callable $authorizer, |
242 | Authority $performer, |
243 | ?string $reason |
244 | ): PermissionStatus { |
245 | $status = PermissionStatus::newEmpty(); |
246 | |
247 | $authorizer( 'move', $this->oldTitle, $status ); |
248 | $authorizer( 'edit', $this->oldTitle, $status ); |
249 | $authorizer( 'move-target', $this->newTitle, $status ); |
250 | $authorizer( 'edit', $this->newTitle, $status ); |
251 | |
252 | if ( $reason !== null && $this->spamChecker->checkSummary( $reason ) !== false ) { |
253 | // This is kind of lame, won't display nice |
254 | $status->fatal( 'spamprotectiontext' ); |
255 | } |
256 | |
257 | $tp = $this->newTitle->getTitleProtection(); |
258 | if ( $tp !== false && !$performer->isAllowed( $tp['permission'] ) ) { |
259 | $status->fatal( 'cantmove-titleprotected' ); |
260 | } |
261 | |
262 | // TODO: change hook signature to accept Authority and PermissionStatus |
263 | $user = $this->userFactory->newFromAuthority( $performer ); |
264 | $status = Status::wrap( $status ); |
265 | $this->hookRunner->onMovePageCheckPermissions( |
266 | $this->oldTitle, $this->newTitle, $user, $reason, $status ); |
267 | // TODO: remove conversion code after hook signature is changed. |
268 | $permissionStatus = PermissionStatus::newEmpty(); |
269 | foreach ( $status->getErrorsArray() as $error ) { |
270 | $permissionStatus->fatal( ...$error ); |
271 | } |
272 | return $permissionStatus; |
273 | } |
274 | |
275 | /** |
276 | * Check whether $performer can execute the move. |
277 | * |
278 | * @note this method does not guarantee full permissions check, so it should |
279 | * only be used to to decide whether to show a move form. To authorize the move |
280 | * action use {@link self::authorizeMove} instead. |
281 | * |
282 | * @param Authority $performer |
283 | * @param string|null $reason |
284 | * @return PermissionStatus |
285 | */ |
286 | public function probablyCanMove( Authority $performer, string $reason = null ): PermissionStatus { |
287 | return $this->authorizeInternal( |
288 | static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { |
289 | return $performer->probablyCan( $action, $target, $status ); |
290 | }, |
291 | $performer, |
292 | $reason |
293 | ); |
294 | } |
295 | |
296 | /** |
297 | * Authorize the move by $performer. |
298 | * |
299 | * @note this method should be used right before the actual move is performed. |
300 | * To check whether a current performer has the potential to move the page, |
301 | * use {@link self::probablyCanMove} instead. |
302 | * |
303 | * @param Authority $performer |
304 | * @param string|null $reason |
305 | * @return PermissionStatus |
306 | */ |
307 | public function authorizeMove( Authority $performer, string $reason = null ): PermissionStatus { |
308 | return $this->authorizeInternal( |
309 | static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { |
310 | return $performer->authorizeWrite( $action, $target, $status ); |
311 | }, |
312 | $performer, |
313 | $reason |
314 | ); |
315 | } |
316 | |
317 | /** |
318 | * Check if the user is allowed to perform the move. |
319 | * |
320 | * @param Authority $performer |
321 | * @param string|null $reason To check against summary spam regex. Set to null to skip the check, |
322 | * for instance to display errors preemptively before the user has filled in a summary. |
323 | * @deprecated since 1.36, use ::authorizeMove or ::probablyCanMove instead. |
324 | * @return Status |
325 | */ |
326 | public function checkPermissions( Authority $performer, $reason ) { |
327 | $permissionStatus = $this->authorizeInternal( |
328 | static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { |
329 | return $performer->definitelyCan( $action, $target, $status ); |
330 | }, |
331 | $performer, |
332 | $reason |
333 | ); |
334 | return Status::wrap( $permissionStatus ); |
335 | } |
336 | |
337 | /** |
338 | * Does various checks that the move is |
339 | * valid. Only things based on the two titles |
340 | * should be checked here. |
341 | * |
342 | * @return Status |
343 | */ |
344 | public function isValidMove() { |
345 | $status = new Status(); |
346 | |
347 | if ( $this->oldTitle->equals( $this->newTitle ) ) { |
348 | $status->fatal( 'selfmove' ); |
349 | } elseif ( $this->newTitle->getArticleID( IDBAccessObject::READ_LATEST /* T272386 */ ) |
350 | && !$this->isValidMoveTarget() |
351 | ) { |
352 | // The move is allowed only if (1) the target doesn't exist, or (2) the target is a |
353 | // redirect to the source, and has no history (so we can undo bad moves right after |
354 | // they're done). If the target is a single revision redirect to a different page, |
355 | // it can be deleted with just `delete-redirect` rights (i.e. without needing |
356 | // `delete`) - see T239277 |
357 | $fatal = $this->newTitle->isSingleRevRedirect() ? 'redirectexists' : 'articleexists'; |
358 | $status->fatal( $fatal, $this->newTitle->getPrefixedText() ); |
359 | } |
360 | |
361 | // @todo If the old title is invalid, maybe we should check if it somehow exists in the |
362 | // database and allow moving it to a valid name? Why prohibit the move from an empty name |
363 | // without checking in the database? |
364 | if ( $this->oldTitle->getDBkey() == '' ) { |
365 | $status->fatal( 'badarticleerror' ); |
366 | } elseif ( $this->oldTitle->isExternal() ) { |
367 | $status->fatal( 'immobile-source-namespace-iw' ); |
368 | } elseif ( !$this->oldTitle->isMovable() ) { |
369 | $nsText = $this->oldTitle->getNsText(); |
370 | if ( $nsText === '' ) { |
371 | $nsText = wfMessage( 'blanknamespace' )->text(); |
372 | } |
373 | $status->fatal( 'immobile-source-namespace', $nsText ); |
374 | } elseif ( !$this->oldTitle->exists() ) { |
375 | $status->fatal( 'movepage-source-doesnt-exist', $this->oldTitle->getPrefixedText() ); |
376 | } |
377 | |
378 | if ( $this->newTitle->isExternal() ) { |
379 | $status->fatal( 'immobile-target-namespace-iw' ); |
380 | } elseif ( !$this->newTitle->isMovable() ) { |
381 | $nsText = $this->newTitle->getNsText(); |
382 | if ( $nsText === '' ) { |
383 | $nsText = wfMessage( 'blanknamespace' )->text(); |
384 | } |
385 | $status->fatal( 'immobile-target-namespace', $nsText ); |
386 | } |
387 | if ( !$this->newTitle->isValid() ) { |
388 | $status->fatal( 'movepage-invalid-target-title' ); |
389 | } |
390 | |
391 | // Content model checks |
392 | if ( !$this->contentHandlerFactory |
393 | ->getContentHandler( $this->oldTitle->getContentModel() ) |
394 | ->canBeUsedOn( $this->newTitle ) |
395 | ) { |
396 | $status->fatal( |
397 | 'content-not-allowed-here', |
398 | ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ), |
399 | $this->newTitle->getPrefixedText(), |
400 | SlotRecord::MAIN |
401 | ); |
402 | } |
403 | |
404 | // Image-specific checks |
405 | if ( $this->oldTitle->inNamespace( NS_FILE ) ) { |
406 | $status->merge( $this->isValidFileMove() ); |
407 | } |
408 | |
409 | if ( $this->newTitle->inNamespace( NS_FILE ) && !$this->oldTitle->inNamespace( NS_FILE ) ) { |
410 | $status->fatal( 'nonfile-cannot-move-to-file' ); |
411 | } |
412 | |
413 | // Hook for extensions to say a title can't be moved for technical reasons |
414 | $this->hookRunner->onMovePageIsValidMove( $this->oldTitle, $this->newTitle, $status ); |
415 | |
416 | return $status; |
417 | } |
418 | |
419 | /** |
420 | * Checks for when a file is being moved |
421 | * |
422 | * @see UploadBase::getTitle |
423 | * @return Status |
424 | */ |
425 | protected function isValidFileMove() { |
426 | $status = new Status(); |
427 | |
428 | if ( !$this->newTitle->inNamespace( NS_FILE ) ) { |
429 | // No need for further errors about the target filename being wrong |
430 | return $status->fatal( 'imagenocrossnamespace' ); |
431 | } |
432 | |
433 | $file = $this->repoGroup->getLocalRepo()->newFile( $this->oldTitle ); |
434 | $file->load( IDBAccessObject::READ_LATEST ); |
435 | if ( $file->exists() ) { |
436 | if ( $this->newTitle->getText() != wfStripIllegalFilenameChars( $this->newTitle->getText() ) ) { |
437 | $status->fatal( 'imageinvalidfilename' ); |
438 | } |
439 | if ( strlen( $this->newTitle->getText() ) > 240 ) { |
440 | $status->fatal( 'filename-toolong' ); |
441 | } |
442 | if ( |
443 | !$this->repoGroup->getLocalRepo()->backendSupportsUnicodePaths() && |
444 | !preg_match( '/^[\x0-\x7f]*$/', $this->newTitle->getText() ) |
445 | ) { |
446 | $status->fatal( 'windows-nonascii-filename' ); |
447 | } |
448 | if ( strrpos( $this->newTitle->getText(), '.' ) === 0 ) { |
449 | // Filename cannot only be its extension |
450 | // Will probably fail the next check too. |
451 | $status->fatal( 'filename-tooshort' ); |
452 | } |
453 | if ( !File::checkExtensionCompatibility( $file, $this->newTitle->getDBkey() ) ) { |
454 | $status->fatal( 'imagetypemismatch' ); |
455 | } |
456 | } |
457 | |
458 | return $status; |
459 | } |
460 | |
461 | /** |
462 | * Checks if $this can be moved to a given Title |
463 | * - Selects for update, so don't call it unless you mean business |
464 | * |
465 | * @since 1.25 |
466 | * @return bool |
467 | */ |
468 | protected function isValidMoveTarget() { |
469 | # Is it an existing file? |
470 | if ( $this->newTitle->inNamespace( NS_FILE ) ) { |
471 | $file = $this->repoGroup->getLocalRepo()->newFile( $this->newTitle ); |
472 | $file->load( IDBAccessObject::READ_LATEST ); |
473 | if ( $file->exists() ) { |
474 | wfDebug( __METHOD__ . ": file exists" ); |
475 | return false; |
476 | } |
477 | } |
478 | # Is it a redirect with no history? |
479 | if ( !$this->newTitle->isSingleRevRedirect() ) { |
480 | wfDebug( __METHOD__ . ": not a one-rev redirect" ); |
481 | return false; |
482 | } |
483 | # Get the article text |
484 | $rev = $this->revisionStore->getRevisionByTitle( |
485 | $this->newTitle, |
486 | 0, |
487 | IDBAccessObject::READ_LATEST |
488 | ); |
489 | if ( !is_object( $rev ) ) { |
490 | return false; |
491 | } |
492 | $content = $rev->getContent( SlotRecord::MAIN ); |
493 | # Does the redirect point to the source? |
494 | # Or is it a broken self-redirect, usually caused by namespace collisions? |
495 | $redirTitle = $content ? $content->getRedirectTarget() : null; |
496 | |
497 | if ( $redirTitle ) { |
498 | if ( $redirTitle->getPrefixedDBkey() !== $this->oldTitle->getPrefixedDBkey() && |
499 | $redirTitle->getPrefixedDBkey() !== $this->newTitle->getPrefixedDBkey() ) { |
500 | wfDebug( __METHOD__ . ": redirect points to other page" ); |
501 | return false; |
502 | } else { |
503 | return true; |
504 | } |
505 | } else { |
506 | # Fail safe (not a redirect after all. strange.) |
507 | wfDebug( __METHOD__ . ": failsafe: database says " . $this->newTitle->getPrefixedDBkey() . |
508 | " is a redirect, but it doesn't contain a valid redirect." ); |
509 | return false; |
510 | } |
511 | } |
512 | |
513 | /** |
514 | * Move a page without taking user permissions into account. Only checks if the move is itself |
515 | * invalid, e.g., trying to move a special page or trying to move a page onto one that already |
516 | * exists. |
517 | * |
518 | * @param UserIdentity $user |
519 | * @param string|null $reason |
520 | * @param bool|null $createRedirect |
521 | * @param string[] $changeTags Change tags to apply to the entry in the move log |
522 | * @return Status |
523 | */ |
524 | public function move( |
525 | UserIdentity $user, $reason = null, $createRedirect = true, array $changeTags = [] |
526 | ) { |
527 | $status = $this->isValidMove(); |
528 | if ( !$status->isOK() ) { |
529 | return $status; |
530 | } |
531 | |
532 | return $this->moveUnsafe( $user, $reason ?? '', $createRedirect, $changeTags ); |
533 | } |
534 | |
535 | /** |
536 | * Same as move(), but with permissions checks. |
537 | * |
538 | * @param Authority $performer |
539 | * @param string|null $reason |
540 | * @param bool $createRedirect Ignored if user doesn't have suppressredirect permission |
541 | * @param string[] $changeTags Change tags to apply to the entry in the move log |
542 | * @return Status |
543 | */ |
544 | public function moveIfAllowed( |
545 | Authority $performer, $reason = null, $createRedirect = true, array $changeTags = [] |
546 | ) { |
547 | $status = $this->isValidMove(); |
548 | $status->merge( $this->authorizeMove( $performer, $reason ) ); |
549 | if ( $changeTags ) { |
550 | $status->merge( ChangeTags::canAddTagsAccompanyingChange( $changeTags, $performer ) ); |
551 | } |
552 | |
553 | if ( !$status->isOK() ) { |
554 | // TODO: wrap block spreading into Authority side-effect? |
555 | $user = $this->userFactory->newFromAuthority( $performer ); |
556 | // Auto-block user's IP if the account was "hard" blocked |
557 | $user->spreadAnyEditBlock(); |
558 | return $status; |
559 | } |
560 | |
561 | // Check suppressredirect permission |
562 | if ( !$performer->isAllowed( 'suppressredirect' ) ) { |
563 | $createRedirect = true; |
564 | } |
565 | |
566 | return $this->moveUnsafe( $performer->getUser(), $reason ?? '', $createRedirect, $changeTags ); |
567 | } |
568 | |
569 | /** |
570 | * Move the source page's subpages to be subpages of the target page, without checking user |
571 | * permissions. The caller is responsible for moving the source page itself. We will still not |
572 | * do moves that are inherently not allowed, nor will we move more than $wgMaximumMovedPages. |
573 | * |
574 | * @param UserIdentity $user |
575 | * @param string|null $reason The reason for the move |
576 | * @param bool|null $createRedirect Whether to create redirects from the old subpages to |
577 | * the new ones |
578 | * @param string[] $changeTags Applied to entries in the move log and redirect page revision |
579 | * @return Status Good if no errors occurred. Ok if at least one page succeeded. The "value" |
580 | * of the top-level status is an array containing the per-title status for each page. For any |
581 | * move that succeeded, the "value" of the per-title status is the new page title. |
582 | */ |
583 | public function moveSubpages( |
584 | UserIdentity $user, $reason = null, $createRedirect = true, array $changeTags = [] |
585 | ) { |
586 | return $this->moveSubpagesInternal( |
587 | function ( Title $oldSubpage, Title $newSubpage ) |
588 | use ( $user, $reason, $createRedirect, $changeTags ) { |
589 | $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage ); |
590 | return $mp->move( $user, $reason, $createRedirect, $changeTags ); |
591 | } |
592 | ); |
593 | } |
594 | |
595 | /** |
596 | * Move the source page's subpages to be subpages of the target page, with user permission |
597 | * checks. The caller is responsible for moving the source page itself. |
598 | * |
599 | * @param Authority $performer |
600 | * @param string|null $reason The reason for the move |
601 | * @param bool|null $createRedirect Whether to create redirects from the old subpages to |
602 | * the new ones. Ignored if the user doesn't have the 'suppressredirect' right. |
603 | * @param string[] $changeTags Applied to entries in the move log and redirect page revision |
604 | * @return Status Good if no errors occurred. Ok if at least one page succeeded. The "value" |
605 | * of the top-level status is an array containing the per-title status for each page. For any |
606 | * move that succeeded, the "value" of the per-title status is the new page title. |
607 | */ |
608 | public function moveSubpagesIfAllowed( |
609 | Authority $performer, $reason = null, $createRedirect = true, array $changeTags = [] |
610 | ) { |
611 | if ( !$performer->authorizeWrite( 'move-subpages', $this->oldTitle ) ) { |
612 | return Status::newFatal( 'cant-move-subpages' ); |
613 | } |
614 | return $this->moveSubpagesInternal( |
615 | function ( Title $oldSubpage, Title $newSubpage ) |
616 | use ( $performer, $reason, $createRedirect, $changeTags ) { |
617 | $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage ); |
618 | return $mp->moveIfAllowed( $performer, $reason, $createRedirect, $changeTags ); |
619 | } |
620 | ); |
621 | } |
622 | |
623 | /** |
624 | * @param callable $subpageMoveCallback |
625 | * @return Status |
626 | */ |
627 | private function moveSubpagesInternal( callable $subpageMoveCallback ) { |
628 | // Do the source and target namespaces support subpages? |
629 | if ( !$this->nsInfo->hasSubpages( $this->oldTitle->getNamespace() ) ) { |
630 | return Status::newFatal( 'namespace-nosubpages', |
631 | $this->nsInfo->getCanonicalName( $this->oldTitle->getNamespace() ) ); |
632 | } |
633 | if ( !$this->nsInfo->hasSubpages( $this->newTitle->getNamespace() ) ) { |
634 | return Status::newFatal( 'namespace-nosubpages', |
635 | $this->nsInfo->getCanonicalName( $this->newTitle->getNamespace() ) ); |
636 | } |
637 | |
638 | // Return a status for the overall result. Its value will be an array with per-title |
639 | // status for each subpage. Merge any errors from the per-title statuses into the |
640 | // top-level status without resetting the overall result. |
641 | $max = $this->maximumMovedPages; |
642 | $topStatus = Status::newGood(); |
643 | $perTitleStatus = []; |
644 | $subpages = $this->oldTitle->getSubpages( $max >= 0 ? $max + 1 : -1 ); |
645 | $count = 0; |
646 | foreach ( $subpages as $oldSubpage ) { |
647 | $count++; |
648 | if ( $max >= 0 && $count > $max ) { |
649 | $status = Status::newFatal( 'movepage-max-pages', $max ); |
650 | $perTitleStatus[$oldSubpage->getPrefixedText()] = $status; |
651 | $topStatus->merge( $status ); |
652 | $topStatus->setOK( true ); |
653 | break; |
654 | } |
655 | |
656 | // We don't know whether this function was called before or after moving the root page, |
657 | // so check both titles |
658 | if ( $oldSubpage->getArticleID() == $this->oldTitle->getArticleID() || |
659 | $oldSubpage->getArticleID() == $this->newTitle->getArticleID() |
660 | ) { |
661 | // When moving a page to a subpage of itself, don't move it twice |
662 | continue; |
663 | } |
664 | $newPageName = preg_replace( |
665 | '#^' . preg_quote( $this->oldTitle->getDBkey(), '#' ) . '#', |
666 | StringUtils::escapeRegexReplacement( $this->newTitle->getDBkey() ), # T23234 |
667 | $oldSubpage->getDBkey() ); |
668 | if ( $oldSubpage->isTalkPage() ) { |
669 | $newNs = $this->nsInfo->getTalkPage( $this->newTitle )->getNamespace(); |
670 | } else { |
671 | $newNs = $this->nsInfo->getSubjectPage( $this->newTitle )->getNamespace(); |
672 | } |
673 | // T16385: we need makeTitleSafe because the new page names may be longer than 255 |
674 | // characters. |
675 | $newSubpage = Title::makeTitleSafe( $newNs, $newPageName ); |
676 | $status = $subpageMoveCallback( $oldSubpage, $newSubpage ); |
677 | if ( $status->isOK() ) { |
678 | $status->setResult( true, $newSubpage->getPrefixedText() ); |
679 | } |
680 | $perTitleStatus[$oldSubpage->getPrefixedText()] = $status; |
681 | $topStatus->merge( $status ); |
682 | $topStatus->setOK( true ); |
683 | } |
684 | |
685 | $topStatus->value = $perTitleStatus; |
686 | return $topStatus; |
687 | } |
688 | |
689 | /** |
690 | * Moves *without* any sort of safety or other checks. Hooks can still fail the move, however. |
691 | * |
692 | * @param UserIdentity $user |
693 | * @param string $reason |
694 | * @param bool $createRedirect |
695 | * @param string[] $changeTags Change tags to apply to the entry in the move log |
696 | * @return Status |
697 | */ |
698 | private function moveUnsafe( UserIdentity $user, $reason, $createRedirect, array $changeTags ) { |
699 | $status = Status::newGood(); |
700 | |
701 | // TODO: make hooks accept UserIdentity |
702 | $userObj = $this->userFactory->newFromUserIdentity( $user ); |
703 | $this->hookRunner->onTitleMove( $this->oldTitle, $this->newTitle, $userObj, $reason, $status ); |
704 | if ( !$status->isOK() ) { |
705 | // Move was aborted by the hook |
706 | return $status; |
707 | } |
708 | |
709 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
710 | $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); |
711 | |
712 | $this->hookRunner->onTitleMoveStarting( $this->oldTitle, $this->newTitle, $userObj ); |
713 | |
714 | $pageid = $this->oldTitle->getArticleID( IDBAccessObject::READ_LATEST ); |
715 | $protected = $this->restrictionStore->isProtected( $this->oldTitle ); |
716 | |
717 | // Attempt the actual move |
718 | $moveAttemptResult = $this->moveToInternal( $user, $this->newTitle, $reason, $createRedirect, |
719 | $changeTags ); |
720 | |
721 | if ( !$moveAttemptResult->isGood() ) { |
722 | // T265779: Attempt to delete target page failed |
723 | $dbw->cancelAtomic( __METHOD__ ); |
724 | return $moveAttemptResult; |
725 | } else { |
726 | $nullRevision = $moveAttemptResult->getValue()['nullRevision']; |
727 | '@phan-var \MediaWiki\Revision\RevisionRecord $nullRevision'; |
728 | } |
729 | |
730 | $redirid = $this->oldTitle->getArticleID(); |
731 | |
732 | if ( $protected ) { |
733 | # Protect the redirect title as the title used to be... |
734 | $res = $dbw->newSelectQueryBuilder() |
735 | ->select( [ 'pr_type', 'pr_level', 'pr_cascade', 'pr_expiry' ] ) |
736 | ->from( 'page_restrictions' ) |
737 | ->where( [ 'pr_page' => $pageid ] ) |
738 | ->forUpdate() |
739 | ->caller( __METHOD__ ) |
740 | ->fetchResultSet(); |
741 | $rowsInsert = []; |
742 | foreach ( $res as $row ) { |
743 | $rowsInsert[] = [ |
744 | 'pr_page' => $redirid, |
745 | 'pr_type' => $row->pr_type, |
746 | 'pr_level' => $row->pr_level, |
747 | 'pr_cascade' => $row->pr_cascade, |
748 | 'pr_expiry' => $row->pr_expiry |
749 | ]; |
750 | } |
751 | $dbw->newInsertQueryBuilder() |
752 | ->insertInto( 'page_restrictions' ) |
753 | ->ignore() |
754 | ->rows( $rowsInsert ) |
755 | ->caller( __METHOD__ )->execute(); |
756 | |
757 | // Build comment for log |
758 | $comment = wfMessage( |
759 | 'prot_1movedto2', |
760 | $this->oldTitle->getPrefixedText(), |
761 | $this->newTitle->getPrefixedText() |
762 | )->inContentLanguage()->text(); |
763 | if ( $reason ) { |
764 | $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; |
765 | } |
766 | |
767 | // reread inserted pr_ids for log relation |
768 | $logRelationsValues = $dbw->newSelectQueryBuilder() |
769 | ->select( 'pr_id' ) |
770 | ->from( 'page_restrictions' ) |
771 | ->where( [ 'pr_page' => $redirid ] ) |
772 | ->caller( __METHOD__ )->fetchFieldValues(); |
773 | |
774 | // Update the protection log |
775 | $logEntry = new ManualLogEntry( 'protect', 'move_prot' ); |
776 | $logEntry->setTarget( $this->newTitle ); |
777 | $logEntry->setComment( $comment ); |
778 | $logEntry->setPerformer( $user ); |
779 | $logEntry->setParameters( [ |
780 | '4::oldtitle' => $this->oldTitle->getPrefixedText(), |
781 | ] ); |
782 | $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] ); |
783 | $logEntry->addTags( $changeTags ); |
784 | $logId = $logEntry->insert(); |
785 | $logEntry->publish( $logId ); |
786 | } |
787 | |
788 | # Update watchlists |
789 | $oldtitle = $this->oldTitle->getDBkey(); |
790 | $newtitle = $this->newTitle->getDBkey(); |
791 | $oldsnamespace = $this->nsInfo->getSubject( $this->oldTitle->getNamespace() ); |
792 | $newsnamespace = $this->nsInfo->getSubject( $this->newTitle->getNamespace() ); |
793 | if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) { |
794 | $this->watchedItems->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle ); |
795 | } |
796 | |
797 | // If it is a file then move it last. |
798 | // This is done after all database changes so that file system errors cancel the transaction. |
799 | if ( $this->oldTitle->getNamespace() === NS_FILE ) { |
800 | $status = $this->moveFile( $this->oldTitle, $this->newTitle ); |
801 | if ( !$status->isOK() ) { |
802 | $dbw->cancelAtomic( __METHOD__ ); |
803 | return $status; |
804 | } |
805 | } |
806 | |
807 | $this->hookRunner->onPageMoveCompleting( |
808 | $this->oldTitle, $this->newTitle, |
809 | $user, $pageid, $redirid, $reason, $nullRevision |
810 | ); |
811 | |
812 | $dbw->endAtomic( __METHOD__ ); |
813 | |
814 | // Keep each single hook handler atomic |
815 | DeferredUpdates::addUpdate( |
816 | new AtomicSectionUpdate( |
817 | $dbw, |
818 | __METHOD__, |
819 | function () use ( $user, $pageid, $redirid, $reason, $nullRevision ) { |
820 | $this->hookRunner->onPageMoveComplete( |
821 | $this->oldTitle, |
822 | $this->newTitle, |
823 | $user, |
824 | $pageid, |
825 | $redirid, |
826 | $reason, |
827 | $nullRevision |
828 | ); |
829 | } |
830 | ) |
831 | ); |
832 | |
833 | return $moveAttemptResult; |
834 | } |
835 | |
836 | /** |
837 | * Move a file associated with a page to a new location. |
838 | * Can also be used to revert after a DB failure. |
839 | * |
840 | * @internal |
841 | * @param Title $oldTitle Old location to move the file from. |
842 | * @param Title $newTitle New location to move the file to. |
843 | * @return Status |
844 | */ |
845 | private function moveFile( $oldTitle, $newTitle ) { |
846 | $file = $this->repoGroup->getLocalRepo()->newFile( $oldTitle ); |
847 | $file->load( IDBAccessObject::READ_LATEST ); |
848 | if ( $file->exists() ) { |
849 | $status = $file->move( $newTitle ); |
850 | } else { |
851 | $status = Status::newGood(); |
852 | } |
853 | |
854 | // Clear RepoGroup process cache |
855 | $this->repoGroup->clearCache( $oldTitle ); |
856 | $this->repoGroup->clearCache( $newTitle ); # clear false negative cache |
857 | return $status; |
858 | } |
859 | |
860 | /** |
861 | * Move page to a title which is either a redirect to the |
862 | * source page or nonexistent |
863 | * |
864 | * @todo This was basically directly moved from Title, it should be split into |
865 | * smaller functions |
866 | * @param UserIdentity $user doing the move |
867 | * @param Title &$nt The page to move to, which should be a redirect or non-existent |
868 | * @param string $reason The reason for the move |
869 | * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check |
870 | * if the user has the suppressredirect right |
871 | * @param string[] $changeTags Change tags to apply to the entry in the move log |
872 | * @return Status Status object with the following value on success: |
873 | * [ |
874 | * 'nullRevision' => The ("null") revision created by the move (RevisionRecord) |
875 | * 'redirectRevision' => The initial revision of the redirect if it was created (RevisionRecord|null) |
876 | * ] |
877 | */ |
878 | private function moveToInternal( |
879 | UserIdentity $user, |
880 | &$nt, |
881 | $reason = '', |
882 | $createRedirect = true, |
883 | array $changeTags = [] |
884 | ): Status { |
885 | if ( $nt->getArticleID( IDBAccessObject::READ_LATEST ) ) { |
886 | $moveOverRedirect = true; |
887 | $logType = 'move_redir'; |
888 | } else { |
889 | $moveOverRedirect = false; |
890 | $logType = 'move'; |
891 | } |
892 | |
893 | if ( $moveOverRedirect ) { |
894 | $overwriteMessage = wfMessage( |
895 | 'delete_and_move_reason', |
896 | $this->oldTitle->getPrefixedText() |
897 | )->inContentLanguage()->text(); |
898 | $newpage = $this->wikiPageFactory->newFromTitle( $nt ); |
899 | $errs = []; |
900 | $status = $newpage->doDeleteArticleReal( |
901 | $overwriteMessage, |
902 | $user, |
903 | /* $suppress */ false, |
904 | /* unused */ null, |
905 | $errs, |
906 | /* unused */ null, |
907 | $changeTags, |
908 | 'delete_redir' |
909 | ); |
910 | |
911 | if ( !$status->isGood() ) { |
912 | return $status; |
913 | } |
914 | |
915 | $nt->resetArticleID( false ); |
916 | } |
917 | |
918 | if ( $createRedirect ) { |
919 | if ( $this->oldTitle->getNamespace() === NS_CATEGORY |
920 | && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled() |
921 | ) { |
922 | $redirectContent = new WikitextContent( |
923 | wfMessage( 'category-move-redirect-override' ) |
924 | ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() ); |
925 | } else { |
926 | $redirectContent = $this->contentHandlerFactory |
927 | ->getContentHandler( $this->oldTitle->getContentModel() ) |
928 | ->makeRedirectContent( |
929 | $nt, |
930 | wfMessage( 'move-redirect-text' )->inContentLanguage()->plain() |
931 | ); |
932 | } |
933 | |
934 | // NOTE: If this page's content model does not support redirects, $redirectContent will be null. |
935 | } else { |
936 | $redirectContent = null; |
937 | } |
938 | |
939 | // T59084: log_page should be the ID of the *moved* page |
940 | $oldid = $this->oldTitle->getArticleID(); |
941 | $logTitle = clone $this->oldTitle; |
942 | |
943 | $logEntry = new ManualLogEntry( 'move', $logType ); |
944 | $logEntry->setPerformer( $user ); |
945 | $logEntry->setTarget( $logTitle ); |
946 | $logEntry->setComment( $reason ); |
947 | $logEntry->setParameters( [ |
948 | '4::target' => $nt->getPrefixedText(), |
949 | '5::noredir' => $redirectContent ? '0' : '1', |
950 | ] ); |
951 | |
952 | $formatter = LogFormatter::newFromEntry( $logEntry ); |
953 | $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) ); |
954 | $comment = $formatter->getPlainActionText(); |
955 | if ( $reason ) { |
956 | $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; |
957 | } |
958 | |
959 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
960 | |
961 | $oldpage = $this->wikiPageFactory->newFromTitle( $this->oldTitle ); |
962 | $oldcountable = $oldpage->isCountable(); |
963 | |
964 | $newpage = $this->wikiPageFactory->newFromTitle( $nt ); |
965 | |
966 | # Change the name of the target page: |
967 | $dbw->newUpdateQueryBuilder() |
968 | ->update( 'page' ) |
969 | ->set( [ |
970 | 'page_namespace' => $nt->getNamespace(), |
971 | 'page_title' => $nt->getDBkey(), |
972 | ] ) |
973 | ->where( [ 'page_id' => $oldid ] ) |
974 | ->caller( __METHOD__ )->execute(); |
975 | |
976 | // Reset $nt before using it to create the null revision (T248789). |
977 | // But not $this->oldTitle yet, see below (T47348). |
978 | $nt->resetArticleID( $oldid ); |
979 | |
980 | $commentObj = CommentStoreComment::newUnsavedComment( $comment ); |
981 | # Save a null revision in the page's history notifying of the move |
982 | $nullRevision = $this->revisionStore->newNullRevision( |
983 | $dbw, |
984 | $nt, |
985 | $commentObj, |
986 | true, |
987 | $user |
988 | ); |
989 | if ( $nullRevision === null ) { |
990 | $id = $nt->getArticleID( IDBAccessObject::READ_EXCLUSIVE ); |
991 | $msg = 'Failed to create null revision while moving page ID ' . |
992 | $oldid . ' to ' . $nt->getPrefixedDBkey() . " (page ID $id)"; |
993 | |
994 | // XXX This should be handled more gracefully |
995 | throw new RuntimeException( $msg ); |
996 | } |
997 | |
998 | $nullRevision = $this->revisionStore->insertRevisionOn( $nullRevision, $dbw ); |
999 | $logEntry->setAssociatedRevId( $nullRevision->getId() ); |
1000 | |
1001 | /** |
1002 | * T163966 |
1003 | * Increment user_editcount during page moves |
1004 | * Moved from SpecialMovePage.php per T195550 |
1005 | */ |
1006 | $this->userEditTracker->incrementUserEditCount( $user ); |
1007 | |
1008 | // Get the old redirect state before clean up |
1009 | $isRedirect = $this->oldTitle->isRedirect(); |
1010 | if ( !$redirectContent ) { |
1011 | // Clean up the old title *before* reset article id - T47348 |
1012 | WikiPage::onArticleDelete( $this->oldTitle ); |
1013 | } |
1014 | |
1015 | $this->oldTitle->resetArticleID( 0 ); // 0 == non existing |
1016 | $newpage->loadPageData( IDBAccessObject::READ_LOCKING ); // T48397 |
1017 | |
1018 | $newpage->updateRevisionOn( $dbw, $nullRevision, null, $isRedirect ); |
1019 | |
1020 | $fakeTags = []; |
1021 | $this->hookRunner->onRevisionFromEditComplete( |
1022 | $newpage, $nullRevision, $nullRevision->getParentId(), $user, $fakeTags ); |
1023 | |
1024 | $options = [ |
1025 | 'changed' => false, |
1026 | 'moved' => true, |
1027 | 'oldtitle' => $this->oldTitle, |
1028 | 'oldcountable' => $oldcountable, |
1029 | 'causeAction' => 'MovePage', |
1030 | 'causeAgent' => $user->getName(), |
1031 | ]; |
1032 | |
1033 | $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $newpage ); |
1034 | $updater->prepareUpdate( $nullRevision, $options ); |
1035 | $updater->doUpdates(); |
1036 | |
1037 | WikiPage::onArticleCreate( $nt ); |
1038 | |
1039 | # Recreate the redirect, this time in the other direction. |
1040 | $redirectRevision = null; |
1041 | if ( $redirectContent ) { |
1042 | $redirectArticle = $this->wikiPageFactory->newFromTitle( $this->oldTitle ); |
1043 | $redirectArticle->loadFromRow( false, IDBAccessObject::READ_LOCKING ); // T48397 |
1044 | $redirectRevision = $redirectArticle->newPageUpdater( $user ) |
1045 | ->setContent( SlotRecord::MAIN, $redirectContent ) |
1046 | ->addTags( $changeTags ) |
1047 | ->addSoftwareTag( 'mw-new-redirect' ) |
1048 | ->setUsePageCreationLog( false ) |
1049 | ->setFlags( EDIT_SUPPRESS_RC ) |
1050 | ->saveRevision( $commentObj ); |
1051 | } |
1052 | |
1053 | # Log the move |
1054 | $logid = $logEntry->insert(); |
1055 | |
1056 | $logEntry->addTags( $changeTags ); |
1057 | $logEntry->publish( $logid ); |
1058 | |
1059 | return Status::newGood( [ |
1060 | 'nullRevision' => $nullRevision, |
1061 | 'redirectRevision' => $redirectRevision, |
1062 | ] ); |
1063 | } |
1064 | } |
1065 | |
1066 | /** @deprecated class alias since 1.40 */ |
1067 | class_alias( MovePage::class, 'MovePage' ); |