]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/MovePage.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / includes / MovePage.php
1 <?php
2
3 /**
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12  * GNU General Public License for more details.
13  *
14  * You should have received a copy of the GNU General Public License along
15  * with this program; if not, write to the Free Software Foundation, Inc.,
16  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17  * http://www.gnu.org/copyleft/gpl.html
18  *
19  * @file
20  */
21
22 use MediaWiki\MediaWikiServices;
23
24 /**
25  * Handles the backend logic of moving a page from one title
26  * to another.
27  *
28  * @since 1.24
29  */
30 class MovePage {
31
32         /**
33          * @var Title
34          */
35         protected $oldTitle;
36
37         /**
38          * @var Title
39          */
40         protected $newTitle;
41
42         public function __construct( Title $oldTitle, Title $newTitle ) {
43                 $this->oldTitle = $oldTitle;
44                 $this->newTitle = $newTitle;
45         }
46
47         public function checkPermissions( User $user, $reason ) {
48                 $status = new Status();
49
50                 $errors = wfMergeErrorArrays(
51                         $this->oldTitle->getUserPermissionsErrors( 'move', $user ),
52                         $this->oldTitle->getUserPermissionsErrors( 'edit', $user ),
53                         $this->newTitle->getUserPermissionsErrors( 'move-target', $user ),
54                         $this->newTitle->getUserPermissionsErrors( 'edit', $user )
55                 );
56
57                 // Convert into a Status object
58                 if ( $errors ) {
59                         foreach ( $errors as $error ) {
60                                 call_user_func_array( [ $status, 'fatal' ], $error );
61                         }
62                 }
63
64                 if ( EditPage::matchSummarySpamRegex( $reason ) !== false ) {
65                         // This is kind of lame, won't display nice
66                         $status->fatal( 'spamprotectiontext' );
67                 }
68
69                 $tp = $this->newTitle->getTitleProtection();
70                 if ( $tp !== false && !$user->isAllowed( $tp['permission'] ) ) {
71                                 $status->fatal( 'cantmove-titleprotected' );
72                 }
73
74                 Hooks::run( 'MovePageCheckPermissions',
75                         [ $this->oldTitle, $this->newTitle, $user, $reason, $status ]
76                 );
77
78                 return $status;
79         }
80
81         /**
82          * Does various sanity checks that the move is
83          * valid. Only things based on the two titles
84          * should be checked here.
85          *
86          * @return Status
87          */
88         public function isValidMove() {
89                 global $wgContentHandlerUseDB;
90                 $status = new Status();
91
92                 if ( $this->oldTitle->equals( $this->newTitle ) ) {
93                         $status->fatal( 'selfmove' );
94                 }
95                 if ( !$this->oldTitle->isMovable() ) {
96                         $status->fatal( 'immobile-source-namespace', $this->oldTitle->getNsText() );
97                 }
98                 if ( $this->newTitle->isExternal() ) {
99                         $status->fatal( 'immobile-target-namespace-iw' );
100                 }
101                 if ( !$this->newTitle->isMovable() ) {
102                         $status->fatal( 'immobile-target-namespace', $this->newTitle->getNsText() );
103                 }
104
105                 $oldid = $this->oldTitle->getArticleID();
106
107                 if ( strlen( $this->newTitle->getDBkey() ) < 1 ) {
108                         $status->fatal( 'articleexists' );
109                 }
110                 if (
111                         ( $this->oldTitle->getDBkey() == '' ) ||
112                         ( !$oldid ) ||
113                         ( $this->newTitle->getDBkey() == '' )
114                 ) {
115                         $status->fatal( 'badarticleerror' );
116                 }
117
118                 # The move is allowed only if (1) the target doesn't exist, or
119                 # (2) the target is a redirect to the source, and has no history
120                 # (so we can undo bad moves right after they're done).
121                 if ( $this->newTitle->getArticleID() && !$this->isValidMoveTarget() ) {
122                         $status->fatal( 'articleexists' );
123                 }
124
125                 // Content model checks
126                 if ( !$wgContentHandlerUseDB &&
127                         $this->oldTitle->getContentModel() !== $this->newTitle->getContentModel() ) {
128                         // can't move a page if that would change the page's content model
129                         $status->fatal(
130                                 'bad-target-model',
131                                 ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
132                                 ContentHandler::getLocalizedName( $this->newTitle->getContentModel() )
133                         );
134                 } elseif (
135                         !ContentHandler::getForTitle( $this->oldTitle )->canBeUsedOn( $this->newTitle )
136                 ) {
137                         $status->fatal(
138                                 'content-not-allowed-here',
139                                 ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
140                                 $this->newTitle->getPrefixedText()
141                         );
142                 }
143
144                 // Image-specific checks
145                 if ( $this->oldTitle->inNamespace( NS_FILE ) ) {
146                         $status->merge( $this->isValidFileMove() );
147                 }
148
149                 if ( $this->newTitle->inNamespace( NS_FILE ) && !$this->oldTitle->inNamespace( NS_FILE ) ) {
150                         $status->fatal( 'nonfile-cannot-move-to-file' );
151                 }
152
153                 // Hook for extensions to say a title can't be moved for technical reasons
154                 Hooks::run( 'MovePageIsValidMove', [ $this->oldTitle, $this->newTitle, $status ] );
155
156                 return $status;
157         }
158
159         /**
160          * Sanity checks for when a file is being moved
161          *
162          * @return Status
163          */
164         protected function isValidFileMove() {
165                 $status = new Status();
166                 $file = wfLocalFile( $this->oldTitle );
167                 $file->load( File::READ_LATEST );
168                 if ( $file->exists() ) {
169                         if ( $this->newTitle->getText() != wfStripIllegalFilenameChars( $this->newTitle->getText() ) ) {
170                                 $status->fatal( 'imageinvalidfilename' );
171                         }
172                         if ( !File::checkExtensionCompatibility( $file, $this->newTitle->getDBkey() ) ) {
173                                 $status->fatal( 'imagetypemismatch' );
174                         }
175                 }
176
177                 if ( !$this->newTitle->inNamespace( NS_FILE ) ) {
178                         $status->fatal( 'imagenocrossnamespace' );
179                 }
180
181                 return $status;
182         }
183
184         /**
185          * Checks if $this can be moved to a given Title
186          * - Selects for update, so don't call it unless you mean business
187          *
188          * @since 1.25
189          * @return bool
190          */
191         protected function isValidMoveTarget() {
192                 # Is it an existing file?
193                 if ( $this->newTitle->inNamespace( NS_FILE ) ) {
194                         $file = wfLocalFile( $this->newTitle );
195                         $file->load( File::READ_LATEST );
196                         if ( $file->exists() ) {
197                                 wfDebug( __METHOD__ . ": file exists\n" );
198                                 return false;
199                         }
200                 }
201                 # Is it a redirect with no history?
202                 if ( !$this->newTitle->isSingleRevRedirect() ) {
203                         wfDebug( __METHOD__ . ": not a one-rev redirect\n" );
204                         return false;
205                 }
206                 # Get the article text
207                 $rev = Revision::newFromTitle( $this->newTitle, false, Revision::READ_LATEST );
208                 if ( !is_object( $rev ) ) {
209                         return false;
210                 }
211                 $content = $rev->getContent();
212                 # Does the redirect point to the source?
213                 # Or is it a broken self-redirect, usually caused by namespace collisions?
214                 $redirTitle = $content ? $content->getRedirectTarget() : null;
215
216                 if ( $redirTitle ) {
217                         if ( $redirTitle->getPrefixedDBkey() !== $this->oldTitle->getPrefixedDBkey() &&
218                                 $redirTitle->getPrefixedDBkey() !== $this->newTitle->getPrefixedDBkey() ) {
219                                 wfDebug( __METHOD__ . ": redirect points to other page\n" );
220                                 return false;
221                         } else {
222                                 return true;
223                         }
224                 } else {
225                         # Fail safe (not a redirect after all. strange.)
226                         wfDebug( __METHOD__ . ": failsafe: database says " . $this->newTitle->getPrefixedDBkey() .
227                                 " is a redirect, but it doesn't contain a valid redirect.\n" );
228                         return false;
229                 }
230         }
231
232         /**
233          * @param User $user
234          * @param string $reason
235          * @param bool $createRedirect
236          * @param string[] $changeTags Change tags to apply to the entry in the move log. Caller
237          *  should perform permission checks with ChangeTags::canAddTagsAccompanyingChange
238          * @return Status
239          */
240         public function move( User $user, $reason, $createRedirect, array $changeTags = [] ) {
241                 global $wgCategoryCollation;
242
243                 Hooks::run( 'TitleMove', [ $this->oldTitle, $this->newTitle, $user ] );
244
245                 // If it is a file, move it first.
246                 // It is done before all other moving stuff is done because it's hard to revert.
247                 $dbw = wfGetDB( DB_MASTER );
248                 if ( $this->oldTitle->getNamespace() == NS_FILE ) {
249                         $file = wfLocalFile( $this->oldTitle );
250                         $file->load( File::READ_LATEST );
251                         if ( $file->exists() ) {
252                                 $status = $file->move( $this->newTitle );
253                                 if ( !$status->isOK() ) {
254                                         return $status;
255                                 }
256                         }
257                         // Clear RepoGroup process cache
258                         RepoGroup::singleton()->clearCache( $this->oldTitle );
259                         RepoGroup::singleton()->clearCache( $this->newTitle ); # clear false negative cache
260                 }
261
262                 $dbw->startAtomic( __METHOD__ );
263
264                 Hooks::run( 'TitleMoveStarting', [ $this->oldTitle, $this->newTitle, $user ] );
265
266                 $pageid = $this->oldTitle->getArticleID( Title::GAID_FOR_UPDATE );
267                 $protected = $this->oldTitle->isProtected();
268
269                 // Do the actual move; if this fails, it will throw an MWException(!)
270                 $nullRevision = $this->moveToInternal( $user, $this->newTitle, $reason, $createRedirect,
271                         $changeTags );
272
273                 // Refresh the sortkey for this row.  Be careful to avoid resetting
274                 // cl_timestamp, which may disturb time-based lists on some sites.
275                 // @todo This block should be killed, it's duplicating code
276                 // from LinksUpdate::getCategoryInsertions() and friends.
277                 $prefixes = $dbw->select(
278                         'categorylinks',
279                         [ 'cl_sortkey_prefix', 'cl_to' ],
280                         [ 'cl_from' => $pageid ],
281                         __METHOD__
282                 );
283                 if ( $this->newTitle->getNamespace() == NS_CATEGORY ) {
284                         $type = 'subcat';
285                 } elseif ( $this->newTitle->getNamespace() == NS_FILE ) {
286                         $type = 'file';
287                 } else {
288                         $type = 'page';
289                 }
290                 foreach ( $prefixes as $prefixRow ) {
291                         $prefix = $prefixRow->cl_sortkey_prefix;
292                         $catTo = $prefixRow->cl_to;
293                         $dbw->update( 'categorylinks',
294                                 [
295                                         'cl_sortkey' => Collation::singleton()->getSortKey(
296                                                         $this->newTitle->getCategorySortkey( $prefix ) ),
297                                         'cl_collation' => $wgCategoryCollation,
298                                         'cl_type' => $type,
299                                         'cl_timestamp=cl_timestamp' ],
300                                 [
301                                         'cl_from' => $pageid,
302                                         'cl_to' => $catTo ],
303                                 __METHOD__
304                         );
305                 }
306
307                 $redirid = $this->oldTitle->getArticleID();
308
309                 if ( $protected ) {
310                         # Protect the redirect title as the title used to be...
311                         $res = $dbw->select(
312                                 'page_restrictions',
313                                 '*',
314                                 [ 'pr_page' => $pageid ],
315                                 __METHOD__,
316                                 'FOR UPDATE'
317                         );
318                         $rowsInsert = [];
319                         foreach ( $res as $row ) {
320                                 $rowsInsert[] = [
321                                         'pr_page' => $redirid,
322                                         'pr_type' => $row->pr_type,
323                                         'pr_level' => $row->pr_level,
324                                         'pr_cascade' => $row->pr_cascade,
325                                         'pr_user' => $row->pr_user,
326                                         'pr_expiry' => $row->pr_expiry
327                                 ];
328                         }
329                         $dbw->insert( 'page_restrictions', $rowsInsert, __METHOD__, [ 'IGNORE' ] );
330
331                         // Build comment for log
332                         $comment = wfMessage(
333                                 'prot_1movedto2',
334                                 $this->oldTitle->getPrefixedText(),
335                                 $this->newTitle->getPrefixedText()
336                         )->inContentLanguage()->text();
337                         if ( $reason ) {
338                                 $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
339                         }
340
341                         // reread inserted pr_ids for log relation
342                         $insertedPrIds = $dbw->select(
343                                 'page_restrictions',
344                                 'pr_id',
345                                 [ 'pr_page' => $redirid ],
346                                 __METHOD__
347                         );
348                         $logRelationsValues = [];
349                         foreach ( $insertedPrIds as $prid ) {
350                                 $logRelationsValues[] = $prid->pr_id;
351                         }
352
353                         // Update the protection log
354                         $logEntry = new ManualLogEntry( 'protect', 'move_prot' );
355                         $logEntry->setTarget( $this->newTitle );
356                         $logEntry->setComment( $comment );
357                         $logEntry->setPerformer( $user );
358                         $logEntry->setParameters( [
359                                 '4::oldtitle' => $this->oldTitle->getPrefixedText(),
360                         ] );
361                         $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] );
362                         $logEntry->setTags( $changeTags );
363                         $logId = $logEntry->insert();
364                         $logEntry->publish( $logId );
365                 }
366
367                 // Update *_from_namespace fields as needed
368                 if ( $this->oldTitle->getNamespace() != $this->newTitle->getNamespace() ) {
369                         $dbw->update( 'pagelinks',
370                                 [ 'pl_from_namespace' => $this->newTitle->getNamespace() ],
371                                 [ 'pl_from' => $pageid ],
372                                 __METHOD__
373                         );
374                         $dbw->update( 'templatelinks',
375                                 [ 'tl_from_namespace' => $this->newTitle->getNamespace() ],
376                                 [ 'tl_from' => $pageid ],
377                                 __METHOD__
378                         );
379                         $dbw->update( 'imagelinks',
380                                 [ 'il_from_namespace' => $this->newTitle->getNamespace() ],
381                                 [ 'il_from' => $pageid ],
382                                 __METHOD__
383                         );
384                 }
385
386                 # Update watchlists
387                 $oldtitle = $this->oldTitle->getDBkey();
388                 $newtitle = $this->newTitle->getDBkey();
389                 $oldsnamespace = MWNamespace::getSubject( $this->oldTitle->getNamespace() );
390                 $newsnamespace = MWNamespace::getSubject( $this->newTitle->getNamespace() );
391                 if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) {
392                         $store = MediaWikiServices::getInstance()->getWatchedItemStore();
393                         $store->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle );
394                 }
395
396                 Hooks::run(
397                         'TitleMoveCompleting',
398                         [ $this->oldTitle, $this->newTitle,
399                                 $user, $pageid, $redirid, $reason, $nullRevision ]
400                 );
401
402                 $dbw->endAtomic( __METHOD__ );
403
404                 $params = [
405                         &$this->oldTitle,
406                         &$this->newTitle,
407                         &$user,
408                         $pageid,
409                         $redirid,
410                         $reason,
411                         $nullRevision
412                 ];
413                 // Keep each single hook handler atomic
414                 DeferredUpdates::addUpdate(
415                         new AtomicSectionUpdate(
416                                 $dbw,
417                                 __METHOD__,
418                                 // Hold onto $user to avoid HHVM bug where it no longer
419                                 // becomes a reference (T118683)
420                                 function () use ( $params, &$user ) {
421                                         Hooks::run( 'TitleMoveComplete', $params );
422                                 }
423                         )
424                 );
425
426                 return Status::newGood();
427         }
428
429         /**
430          * Move page to a title which is either a redirect to the
431          * source page or nonexistent
432          *
433          * @todo This was basically directly moved from Title, it should be split into
434          *   smaller functions
435          * @param User $user the User doing the move
436          * @param Title $nt The page to move to, which should be a redirect or non-existent
437          * @param string $reason The reason for the move
438          * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check
439          *   if the user has the suppressredirect right
440          * @param string[] $changeTags Change tags to apply to the entry in the move log
441          * @return Revision the revision created by the move
442          * @throws MWException
443          */
444         private function moveToInternal( User $user, &$nt, $reason = '', $createRedirect = true,
445                 array $changeTags = []
446         ) {
447                 if ( $nt->exists() ) {
448                         $moveOverRedirect = true;
449                         $logType = 'move_redir';
450                 } else {
451                         $moveOverRedirect = false;
452                         $logType = 'move';
453                 }
454
455                 if ( $moveOverRedirect ) {
456                         $overwriteMessage = wfMessage(
457                                         'delete_and_move_reason',
458                                         $this->oldTitle->getPrefixedText()
459                                 )->inContentLanguage()->text();
460                         $newpage = WikiPage::factory( $nt );
461                         $errs = [];
462                         $status = $newpage->doDeleteArticleReal(
463                                 $overwriteMessage,
464                                 /* $suppress */ false,
465                                 $nt->getArticleID(),
466                                 /* $commit */ false,
467                                 $errs,
468                                 $user,
469                                 $changeTags,
470                                 'delete_redir'
471                         );
472
473                         if ( !$status->isGood() ) {
474                                 throw new MWException( 'Failed to delete page-move revision: ' . $status );
475                         }
476
477                         $nt->resetArticleID( false );
478                 }
479
480                 if ( $createRedirect ) {
481                         if ( $this->oldTitle->getNamespace() == NS_CATEGORY
482                                 && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled()
483                         ) {
484                                 $redirectContent = new WikitextContent(
485                                         wfMessage( 'category-move-redirect-override' )
486                                                 ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() );
487                         } else {
488                                 $contentHandler = ContentHandler::getForTitle( $this->oldTitle );
489                                 $redirectContent = $contentHandler->makeRedirectContent( $nt,
490                                         wfMessage( 'move-redirect-text' )->inContentLanguage()->plain() );
491                         }
492
493                         // NOTE: If this page's content model does not support redirects, $redirectContent will be null.
494                 } else {
495                         $redirectContent = null;
496                 }
497
498                 // Figure out whether the content model is no longer the default
499                 $oldDefault = ContentHandler::getDefaultModelFor( $this->oldTitle );
500                 $contentModel = $this->oldTitle->getContentModel();
501                 $newDefault = ContentHandler::getDefaultModelFor( $nt );
502                 $defaultContentModelChanging = ( $oldDefault !== $newDefault
503                         && $oldDefault === $contentModel );
504
505                 // T59084: log_page should be the ID of the *moved* page
506                 $oldid = $this->oldTitle->getArticleID();
507                 $logTitle = clone $this->oldTitle;
508
509                 $logEntry = new ManualLogEntry( 'move', $logType );
510                 $logEntry->setPerformer( $user );
511                 $logEntry->setTarget( $logTitle );
512                 $logEntry->setComment( $reason );
513                 $logEntry->setParameters( [
514                         '4::target' => $nt->getPrefixedText(),
515                         '5::noredir' => $redirectContent ? '0' : '1',
516                 ] );
517
518                 $formatter = LogFormatter::newFromEntry( $logEntry );
519                 $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) );
520                 $comment = $formatter->getPlainActionText();
521                 if ( $reason ) {
522                         $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
523                 }
524
525                 $dbw = wfGetDB( DB_MASTER );
526
527                 $oldpage = WikiPage::factory( $this->oldTitle );
528                 $oldcountable = $oldpage->isCountable();
529
530                 $newpage = WikiPage::factory( $nt );
531
532                 # Save a null revision in the page's history notifying of the move
533                 $nullRevision = Revision::newNullRevision( $dbw, $oldid, $comment, true, $user );
534                 if ( !is_object( $nullRevision ) ) {
535                         throw new MWException( 'No valid null revision produced in ' . __METHOD__ );
536                 }
537
538                 $nullRevId = $nullRevision->insertOn( $dbw );
539                 $logEntry->setAssociatedRevId( $nullRevId );
540
541                 # Change the name of the target page:
542                 $dbw->update( 'page',
543                         /* SET */ [
544                                 'page_namespace' => $nt->getNamespace(),
545                                 'page_title' => $nt->getDBkey(),
546                         ],
547                         /* WHERE */ [ 'page_id' => $oldid ],
548                         __METHOD__
549                 );
550
551                 if ( !$redirectContent ) {
552                         // Clean up the old title *before* reset article id - T47348
553                         WikiPage::onArticleDelete( $this->oldTitle );
554                 }
555
556                 $this->oldTitle->resetArticleID( 0 ); // 0 == non existing
557                 $nt->resetArticleID( $oldid );
558                 $newpage->loadPageData( WikiPage::READ_LOCKING ); // T48397
559
560                 $newpage->updateRevisionOn( $dbw, $nullRevision );
561
562                 Hooks::run( 'NewRevisionFromEditComplete',
563                         [ $newpage, $nullRevision, $nullRevision->getParentId(), $user ] );
564
565                 $newpage->doEditUpdates( $nullRevision, $user,
566                         [ 'changed' => false, 'moved' => true, 'oldcountable' => $oldcountable ] );
567
568                 // If the default content model changes, we need to populate rev_content_model
569                 if ( $defaultContentModelChanging ) {
570                         $dbw->update(
571                                 'revision',
572                                 [ 'rev_content_model' => $contentModel ],
573                                 [ 'rev_page' => $nt->getArticleID(), 'rev_content_model IS NULL' ],
574                                 __METHOD__
575                         );
576                 }
577
578                 WikiPage::onArticleCreate( $nt );
579
580                 # Recreate the redirect, this time in the other direction.
581                 if ( $redirectContent ) {
582                         $redirectArticle = WikiPage::factory( $this->oldTitle );
583                         $redirectArticle->loadFromRow( false, WikiPage::READ_LOCKING ); // T48397
584                         $newid = $redirectArticle->insertOn( $dbw );
585                         if ( $newid ) { // sanity
586                                 $this->oldTitle->resetArticleID( $newid );
587                                 $redirectRevision = new Revision( [
588                                         'title' => $this->oldTitle, // for determining the default content model
589                                         'page' => $newid,
590                                         'user_text' => $user->getName(),
591                                         'user' => $user->getId(),
592                                         'comment' => $comment,
593                                         'content' => $redirectContent ] );
594                                 $redirectRevId = $redirectRevision->insertOn( $dbw );
595                                 $redirectArticle->updateRevisionOn( $dbw, $redirectRevision, 0 );
596
597                                 Hooks::run( 'NewRevisionFromEditComplete',
598                                         [ $redirectArticle, $redirectRevision, false, $user ] );
599
600                                 $redirectArticle->doEditUpdates( $redirectRevision, $user, [ 'created' => true ] );
601
602                                 ChangeTags::addTags( $changeTags, null, $redirectRevId, null );
603                         }
604                 }
605
606                 # Log the move
607                 $logid = $logEntry->insert();
608
609                 $logEntry->setTags( $changeTags );
610                 $logEntry->publish( $logid );
611
612                 return $nullRevision;
613         }
614 }