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