Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
76.61% |
344 / 449 |
|
47.06% |
8 / 17 |
CRAP | |
0.00% |
0 / 1 |
MovePage | |
76.79% |
344 / 448 |
|
47.06% |
8 / 17 |
207.91 | |
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 | |
49.48% |
48 / 97 |
|
0.00% |
0 / 1 |
26.60 | |||
moveFile | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
moveToInternal | |
76.00% |
95 / 125 |
|
0.00% |
0 / 1 |
16.71 |
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 | if ( $rowsInsert ) { |
679 | $dbw->newInsertQueryBuilder() |
680 | ->insertInto( 'page_restrictions' ) |
681 | ->ignore() |
682 | ->rows( $rowsInsert ) |
683 | ->caller( __METHOD__ )->execute(); |
684 | } |
685 | |
686 | // Build comment for log |
687 | $comment = wfMessage( |
688 | 'prot_1movedto2', |
689 | $this->oldTitle->getPrefixedText(), |
690 | $this->newTitle->getPrefixedText() |
691 | )->inContentLanguage()->text(); |
692 | if ( $reason ) { |
693 | $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; |
694 | } |
695 | |
696 | // reread inserted pr_ids for log relation |
697 | $logRelationsValues = $dbw->newSelectQueryBuilder() |
698 | ->select( 'pr_id' ) |
699 | ->from( 'page_restrictions' ) |
700 | ->where( [ 'pr_page' => $redirid ] ) |
701 | ->caller( __METHOD__ )->fetchFieldValues(); |
702 | |
703 | // Update the protection log |
704 | $logEntry = new ManualLogEntry( 'protect', 'move_prot' ); |
705 | $logEntry->setTarget( $this->newTitle ); |
706 | $logEntry->setComment( $comment ); |
707 | $logEntry->setPerformer( $user ); |
708 | $logEntry->setParameters( [ |
709 | '4::oldtitle' => $this->oldTitle->getPrefixedText(), |
710 | ] ); |
711 | $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] ); |
712 | $logEntry->addTags( $changeTags ); |
713 | $logId = $logEntry->insert(); |
714 | $logEntry->publish( $logId ); |
715 | } |
716 | |
717 | # Update watchlists |
718 | $oldtitle = $this->oldTitle->getDBkey(); |
719 | $newtitle = $this->newTitle->getDBkey(); |
720 | $oldsnamespace = $this->nsInfo->getSubject( $this->oldTitle->getNamespace() ); |
721 | $newsnamespace = $this->nsInfo->getSubject( $this->newTitle->getNamespace() ); |
722 | if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) { |
723 | $this->watchedItems->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle ); |
724 | } |
725 | |
726 | // If it is a file then move it last. |
727 | // This is done after all database changes so that file system errors cancel the transaction. |
728 | if ( $this->oldTitle->getNamespace() === NS_FILE ) { |
729 | $status = $this->moveFile( $this->oldTitle, $this->newTitle ); |
730 | if ( !$status->isOK() ) { |
731 | $dbw->cancelAtomic( __METHOD__ ); |
732 | return $status; |
733 | } |
734 | } |
735 | |
736 | $this->hookRunner->onPageMoveCompleting( |
737 | $this->oldTitle, $this->newTitle, |
738 | $user, $pageid, $redirid, $reason, $nullRevision |
739 | ); |
740 | |
741 | $dbw->endAtomic( __METHOD__ ); |
742 | |
743 | // Keep each single hook handler atomic |
744 | DeferredUpdates::addUpdate( |
745 | new AtomicSectionUpdate( |
746 | $dbw, |
747 | __METHOD__, |
748 | function () use ( $user, $pageid, $redirid, $reason, $nullRevision ) { |
749 | $this->hookRunner->onPageMoveComplete( |
750 | $this->oldTitle, |
751 | $this->newTitle, |
752 | $user, |
753 | $pageid, |
754 | $redirid, |
755 | $reason, |
756 | $nullRevision |
757 | ); |
758 | } |
759 | ) |
760 | ); |
761 | |
762 | return $moveAttemptResult; |
763 | } |
764 | |
765 | /** |
766 | * Move a file associated with a page to a new location. |
767 | * Can also be used to revert after a DB failure. |
768 | * |
769 | * @internal |
770 | * @param Title $oldTitle Old location to move the file from. |
771 | * @param Title $newTitle New location to move the file to. |
772 | * @return Status |
773 | */ |
774 | private function moveFile( $oldTitle, $newTitle ) { |
775 | $file = $this->repoGroup->getLocalRepo()->newFile( $oldTitle ); |
776 | $file->load( IDBAccessObject::READ_LATEST ); |
777 | if ( $file->exists() ) { |
778 | $status = $file->move( $newTitle ); |
779 | } else { |
780 | $status = Status::newGood(); |
781 | } |
782 | |
783 | // Clear RepoGroup process cache |
784 | $this->repoGroup->clearCache( $oldTitle ); |
785 | $this->repoGroup->clearCache( $newTitle ); # clear false negative cache |
786 | return $status; |
787 | } |
788 | |
789 | /** |
790 | * Move page to a title which is either a redirect to the |
791 | * source page or nonexistent |
792 | * |
793 | * @todo This was basically directly moved from Title, it should be split into |
794 | * smaller functions |
795 | * @param UserIdentity $user doing the move |
796 | * @param Title &$nt The page to move to, which should be a redirect or non-existent |
797 | * @param string $reason The reason for the move |
798 | * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check |
799 | * if the user has the suppressredirect right |
800 | * @param string[] $changeTags Change tags to apply to the entry in the move log |
801 | * @return Status Status object with the following value on success: |
802 | * [ |
803 | * 'nullRevision' => The ("null") revision created by the move (RevisionRecord) |
804 | * 'redirectRevision' => The initial revision of the redirect if it was created (RevisionRecord|null) |
805 | * ] |
806 | */ |
807 | private function moveToInternal( |
808 | UserIdentity $user, |
809 | &$nt, |
810 | $reason = '', |
811 | $createRedirect = true, |
812 | array $changeTags = [] |
813 | ): Status { |
814 | if ( $nt->getArticleID( IDBAccessObject::READ_LATEST ) ) { |
815 | $moveOverRedirect = true; |
816 | $logType = 'move_redir'; |
817 | } else { |
818 | $moveOverRedirect = false; |
819 | $logType = 'move'; |
820 | } |
821 | |
822 | if ( $moveOverRedirect ) { |
823 | $overwriteMessage = wfMessage( |
824 | 'delete_and_move_reason', |
825 | $this->oldTitle->getPrefixedText() |
826 | )->inContentLanguage()->text(); |
827 | $newpage = $this->wikiPageFactory->newFromTitle( $nt ); |
828 | // TODO The public methods of this class should take an Authority. |
829 | $moverAuthority = $this->userFactory->newFromUserIdentity( $user ); |
830 | $deletePage = $this->deletePageFactory->newDeletePage( $newpage, $moverAuthority ); |
831 | $status = $deletePage |
832 | ->setTags( $changeTags ) |
833 | ->setLogSubtype( 'delete_redir' ) |
834 | ->deleteUnsafe( $overwriteMessage ); |
835 | if ( $status->isGood() && $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) { |
836 | // FIXME Scheduled deletion not properly handled here -- it should probably either ensure an |
837 | // immediate deletion or not fail if it was scheduled. |
838 | $status->warning( 'delete-scheduled', wfEscapeWikiText( $nt->getPrefixedText() ) ); |
839 | } |
840 | |
841 | if ( !$status->isGood() ) { |
842 | return $status; |
843 | } |
844 | |
845 | $nt->resetArticleID( false ); |
846 | } |
847 | |
848 | if ( $createRedirect ) { |
849 | if ( $this->oldTitle->getNamespace() === NS_CATEGORY |
850 | && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled() |
851 | ) { |
852 | $redirectContent = new WikitextContent( |
853 | wfMessage( 'category-move-redirect-override' ) |
854 | ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() ); |
855 | } else { |
856 | $redirectContent = $this->contentHandlerFactory |
857 | ->getContentHandler( $this->oldTitle->getContentModel() ) |
858 | ->makeRedirectContent( |
859 | $nt, |
860 | wfMessage( 'move-redirect-text' )->inContentLanguage()->plain() |
861 | ); |
862 | } |
863 | |
864 | // NOTE: If this page's content model does not support redirects, $redirectContent will be null. |
865 | } else { |
866 | $redirectContent = null; |
867 | } |
868 | |
869 | // T59084: log_page should be the ID of the *moved* page |
870 | $oldid = $this->oldTitle->getArticleID(); |
871 | $logTitle = clone $this->oldTitle; |
872 | |
873 | $logEntry = new ManualLogEntry( 'move', $logType ); |
874 | $logEntry->setPerformer( $user ); |
875 | $logEntry->setTarget( $logTitle ); |
876 | $logEntry->setComment( $reason ); |
877 | $logEntry->setParameters( [ |
878 | '4::target' => $nt->getPrefixedText(), |
879 | '5::noredir' => $redirectContent ? '0' : '1', |
880 | ] ); |
881 | |
882 | $formatter = $this->logFormatterFactory->newFromEntry( $logEntry ); |
883 | $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) ); |
884 | $comment = $formatter->getPlainActionText(); |
885 | if ( $reason ) { |
886 | $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; |
887 | } |
888 | |
889 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
890 | |
891 | $oldpage = $this->wikiPageFactory->newFromTitle( $this->oldTitle ); |
892 | $oldcountable = $oldpage->isCountable(); |
893 | |
894 | $newpage = $this->wikiPageFactory->newFromTitle( $nt ); |
895 | |
896 | # Change the name of the target page: |
897 | $dbw->newUpdateQueryBuilder() |
898 | ->update( 'page' ) |
899 | ->set( [ |
900 | 'page_namespace' => $nt->getNamespace(), |
901 | 'page_title' => $nt->getDBkey(), |
902 | ] ) |
903 | ->where( [ 'page_id' => $oldid ] ) |
904 | ->caller( __METHOD__ )->execute(); |
905 | |
906 | // Reset $nt before using it to create the null revision (T248789). |
907 | // But not $this->oldTitle yet, see below (T47348). |
908 | $nt->resetArticleID( $oldid ); |
909 | |
910 | $commentObj = CommentStoreComment::newUnsavedComment( $comment ); |
911 | # Save a null revision in the page's history notifying of the move |
912 | $nullRevision = $this->revisionStore->newNullRevision( |
913 | $dbw, |
914 | $nt, |
915 |