MediaWiki REL1_34
WikiPage.php
Go to the documentation of this file.
1<?php
34use Wikimedia\Assert\Assert;
38
47class WikiPage implements Page, IDBAccessObject {
48 // Constants for $mDataLoadedFrom and related
49
53 public $mTitle = null;
54
59 public $mDataLoaded = false;
60
65 public $mIsRedirect = false;
66
71 public $mLatest = false;
72
76 public $mPreparedEdit = false;
77
81 protected $mId = null;
82
86 protected $mDataLoadedFrom = self::READ_NONE;
87
91 protected $mRedirectTarget = null;
92
96 protected $mLastRevision = null;
97
101 protected $mTimestamp = '';
102
106 protected $mTouched = '19700101000000';
107
111 protected $mLinksUpdated = '19700101000000';
112
116 private $derivedDataUpdater = null;
117
122 public function __construct( Title $title ) {
123 $this->mTitle = $title;
124 }
125
130 public function __clone() {
131 $this->mTitle = clone $this->mTitle;
132 }
133
142 public static function factory( Title $title ) {
143 $ns = $title->getNamespace();
144
145 if ( $ns == NS_MEDIA ) {
146 throw new MWException( "NS_MEDIA is a virtual namespace; use NS_FILE." );
147 } elseif ( $ns < 0 ) {
148 throw new MWException( "Invalid or virtual namespace $ns given." );
149 }
150
151 $page = null;
152 if ( !Hooks::run( 'WikiPageFactory', [ $title, &$page ] ) ) {
153 return $page;
154 }
155
156 switch ( $ns ) {
157 case NS_FILE:
158 $page = new WikiFilePage( $title );
159 break;
160 case NS_CATEGORY:
161 $page = new WikiCategoryPage( $title );
162 break;
163 default:
164 $page = new WikiPage( $title );
165 }
166
167 return $page;
168 }
169
180 public static function newFromID( $id, $from = 'fromdb' ) {
181 // page ids are never 0 or negative, see T63166
182 if ( $id < 1 ) {
183 return null;
184 }
185
186 $from = self::convertSelectType( $from );
187 $db = wfGetDB( $from === self::READ_LATEST ? DB_MASTER : DB_REPLICA );
188 $pageQuery = self::getQueryInfo();
189 $row = $db->selectRow(
190 $pageQuery['tables'], $pageQuery['fields'], [ 'page_id' => $id ], __METHOD__,
191 [], $pageQuery['joins']
192 );
193 if ( !$row ) {
194 return null;
195 }
196 return self::newFromRow( $row, $from );
197 }
198
210 public static function newFromRow( $row, $from = 'fromdb' ) {
211 $page = self::factory( Title::newFromRow( $row ) );
212 $page->loadFromRow( $row, $from );
213 return $page;
214 }
215
222 protected static function convertSelectType( $type ) {
223 switch ( $type ) {
224 case 'fromdb':
225 return self::READ_NORMAL;
226 case 'fromdbmaster':
227 return self::READ_LATEST;
228 case 'forupdate':
229 return self::READ_LOCKING;
230 default:
231 // It may already be an integer or whatever else
232 return $type;
233 }
234 }
235
239 private function getRevisionStore() {
240 return MediaWikiServices::getInstance()->getRevisionStore();
241 }
242
246 private function getRevisionRenderer() {
247 return MediaWikiServices::getInstance()->getRevisionRenderer();
248 }
249
253 private function getSlotRoleRegistry() {
254 return MediaWikiServices::getInstance()->getSlotRoleRegistry();
255 }
256
260 private function getParserCache() {
261 return MediaWikiServices::getInstance()->getParserCache();
262 }
263
267 private function getDBLoadBalancer() {
268 return MediaWikiServices::getInstance()->getDBLoadBalancer();
269 }
270
277 public function getActionOverrides() {
278 return $this->getContentHandler()->getActionOverrides();
279 }
280
290 public function getContentHandler() {
291 return ContentHandler::getForModelID( $this->getContentModel() );
292 }
293
298 public function getTitle() {
299 return $this->mTitle;
300 }
301
306 public function clear() {
307 $this->mDataLoaded = false;
308 $this->mDataLoadedFrom = self::READ_NONE;
309
310 $this->clearCacheFields();
311 }
312
317 protected function clearCacheFields() {
318 $this->mId = null;
319 $this->mRedirectTarget = null; // Title object if set
320 $this->mLastRevision = null; // Latest revision
321 $this->mTouched = '19700101000000';
322 $this->mLinksUpdated = '19700101000000';
323 $this->mTimestamp = '';
324 $this->mIsRedirect = false;
325 $this->mLatest = false;
326 // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already
327 // checks the requested rev ID and content against the cached one. For most
328 // content types, the output should not change during the lifetime of this cache.
329 // Clearing it can cause extra parses on edit for no reason.
330 }
331
337 public function clearPreparedEdit() {
338 $this->mPreparedEdit = false;
339 }
340
348 public static function selectFields() {
350
351 wfDeprecated( __METHOD__, '1.31' );
352
353 $fields = [
354 'page_id',
355 'page_namespace',
356 'page_title',
357 'page_restrictions',
358 'page_is_redirect',
359 'page_is_new',
360 'page_random',
361 'page_touched',
362 'page_links_updated',
363 'page_latest',
364 'page_len',
365 ];
366
368 $fields[] = 'page_content_model';
369 }
370
371 if ( $wgPageLanguageUseDB ) {
372 $fields[] = 'page_lang';
373 }
374
375 return $fields;
376 }
377
387 public static function getQueryInfo() {
389
390 $ret = [
391 'tables' => [ 'page' ],
392 'fields' => [
393 'page_id',
394 'page_namespace',
395 'page_title',
396 'page_restrictions',
397 'page_is_redirect',
398 'page_is_new',
399 'page_random',
400 'page_touched',
401 'page_links_updated',
402 'page_latest',
403 'page_len',
404 ],
405 'joins' => [],
406 ];
407
409 $ret['fields'][] = 'page_content_model';
410 }
411
412 if ( $wgPageLanguageUseDB ) {
413 $ret['fields'][] = 'page_lang';
414 }
415
416 return $ret;
417 }
418
426 protected function pageData( $dbr, $conditions, $options = [] ) {
427 $pageQuery = self::getQueryInfo();
428
429 // Avoid PHP 7.1 warning of passing $this by reference
430 $wikiPage = $this;
431
432 Hooks::run( 'ArticlePageDataBefore', [
433 &$wikiPage, &$pageQuery['fields'], &$pageQuery['tables'], &$pageQuery['joins']
434 ] );
435
436 $row = $dbr->selectRow(
437 $pageQuery['tables'],
438 $pageQuery['fields'],
439 $conditions,
440 __METHOD__,
441 $options,
442 $pageQuery['joins']
443 );
444
445 Hooks::run( 'ArticlePageDataAfter', [ &$wikiPage, &$row ] );
446
447 return $row;
448 }
449
459 public function pageDataFromTitle( $dbr, $title, $options = [] ) {
460 return $this->pageData( $dbr, [
461 'page_namespace' => $title->getNamespace(),
462 'page_title' => $title->getDBkey() ], $options );
463 }
464
473 public function pageDataFromId( $dbr, $id, $options = [] ) {
474 return $this->pageData( $dbr, [ 'page_id' => $id ], $options );
475 }
476
489 public function loadPageData( $from = 'fromdb' ) {
490 $from = self::convertSelectType( $from );
491 if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
492 // We already have the data from the correct location, no need to load it twice.
493 return;
494 }
495
496 if ( is_int( $from ) ) {
497 list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
498 $loadBalancer = $this->getDBLoadBalancer();
499 $db = $loadBalancer->getConnection( $index );
500 $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts );
501
502 if ( !$data
503 && $index == DB_REPLICA
504 && $loadBalancer->getServerCount() > 1
505 && $loadBalancer->hasOrMadeRecentMasterChanges()
506 ) {
507 $from = self::READ_LATEST;
508 list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
509 $db = $loadBalancer->getConnection( $index );
510 $data = $this->pageDataFromTitle( $db, $this->mTitle, $opts );
511 }
512 } else {
513 // No idea from where the caller got this data, assume replica DB.
514 $data = $from;
515 $from = self::READ_NORMAL;
516 }
517
518 $this->loadFromRow( $data, $from );
519 }
520
534 public function wasLoadedFrom( $from ) {
535 $from = self::convertSelectType( $from );
536
537 if ( !is_int( $from ) ) {
538 // No idea from where the caller got this data, assume replica DB.
539 $from = self::READ_NORMAL;
540 }
541
542 if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
543 return true;
544 }
545
546 return false;
547 }
548
560 public function loadFromRow( $data, $from ) {
561 $lc = MediaWikiServices::getInstance()->getLinkCache();
562 $lc->clearLink( $this->mTitle );
563
564 if ( $data ) {
565 $lc->addGoodLinkObjFromRow( $this->mTitle, $data );
566
567 $this->mTitle->loadFromRow( $data );
568
569 // Old-fashioned restrictions
570 $this->mTitle->loadRestrictions( $data->page_restrictions );
571
572 $this->mId = intval( $data->page_id );
573 $this->mTouched = wfTimestamp( TS_MW, $data->page_touched );
574 $this->mLinksUpdated = wfTimestampOrNull( TS_MW, $data->page_links_updated );
575 $this->mIsRedirect = intval( $data->page_is_redirect );
576 $this->mLatest = intval( $data->page_latest );
577 // T39225: $latest may no longer match the cached latest Revision object.
578 // Double-check the ID of any cached latest Revision object for consistency.
579 if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) {
580 $this->mLastRevision = null;
581 $this->mTimestamp = '';
582 }
583 } else {
584 $lc->addBadLinkObj( $this->mTitle );
585
586 $this->mTitle->loadFromRow( false );
587
588 $this->clearCacheFields();
589
590 $this->mId = 0;
591 }
592
593 $this->mDataLoaded = true;
594 $this->mDataLoadedFrom = self::convertSelectType( $from );
595 }
596
600 public function getId() {
601 if ( !$this->mDataLoaded ) {
602 $this->loadPageData();
603 }
604 return $this->mId;
605 }
606
610 public function exists() {
611 if ( !$this->mDataLoaded ) {
612 $this->loadPageData();
613 }
614 return $this->mId > 0;
615 }
616
625 public function hasViewableContent() {
626 return $this->mTitle->isKnown();
627 }
628
634 public function isRedirect() {
635 if ( !$this->mDataLoaded ) {
636 $this->loadPageData();
637 }
638
639 return (bool)$this->mIsRedirect;
640 }
641
652 public function getContentModel() {
653 if ( $this->exists() ) {
654 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
655
656 return $cache->getWithSetCallback(
657 $cache->makeKey( 'page-content-model', $this->getLatest() ),
658 $cache::TTL_MONTH,
659 function () {
660 $rev = $this->getRevision();
661 if ( $rev ) {
662 // Look at the revision's actual content model
663 return $rev->getContentModel();
664 } else {
665 $title = $this->mTitle->getPrefixedDBkey();
666 wfWarn( "Page $title exists but has no (visible) revisions!" );
667 return $this->mTitle->getContentModel();
668 }
669 }
670 );
671 }
672
673 // use the default model for this page
674 return $this->mTitle->getContentModel();
675 }
676
681 public function checkTouched() {
682 if ( !$this->mDataLoaded ) {
683 $this->loadPageData();
684 }
685 return ( $this->mId && !$this->mIsRedirect );
686 }
687
692 public function getTouched() {
693 if ( !$this->mDataLoaded ) {
694 $this->loadPageData();
695 }
696 return $this->mTouched;
697 }
698
703 public function getLinksTimestamp() {
704 if ( !$this->mDataLoaded ) {
705 $this->loadPageData();
706 }
707 return $this->mLinksUpdated;
708 }
709
714 public function getLatest() {
715 if ( !$this->mDataLoaded ) {
716 $this->loadPageData();
717 }
718 return (int)$this->mLatest;
719 }
720
725 public function getOldestRevision() {
726 // Try using the replica DB first, then try the master
727 $rev = $this->mTitle->getFirstRevision();
728 if ( !$rev ) {
729 $rev = $this->mTitle->getFirstRevision( Title::READ_LATEST );
730 }
731 return $rev;
732 }
733
738 protected function loadLastEdit() {
739 if ( $this->mLastRevision !== null ) {
740 return; // already loaded
741 }
742
743 $latest = $this->getLatest();
744 if ( !$latest ) {
745 return; // page doesn't exist or is missing page_latest info
746 }
747
748 if ( $this->mDataLoadedFrom == self::READ_LOCKING ) {
749 // T39225: if session S1 loads the page row FOR UPDATE, the result always
750 // includes the latest changes committed. This is true even within REPEATABLE-READ
751 // transactions, where S1 normally only sees changes committed before the first S1
752 // SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it
753 // may not find it since a page row UPDATE and revision row INSERT by S2 may have
754 // happened after the first S1 SELECT.
755 // https://dev.mysql.com/doc/refman/5.0/en/set-transaction.html#isolevel_repeatable-read
756 $flags = Revision::READ_LOCKING;
757 $revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
758 } elseif ( $this->mDataLoadedFrom == self::READ_LATEST ) {
759 // Bug T93976: if page_latest was loaded from the master, fetch the
760 // revision from there as well, as it may not exist yet on a replica DB.
761 // Also, this keeps the queries in the same REPEATABLE-READ snapshot.
762 $flags = Revision::READ_LATEST;
763 $revision = Revision::newFromPageId( $this->getId(), $latest, $flags );
764 } else {
766 $revision = Revision::newKnownCurrent( $dbr, $this->getTitle(), $latest );
767 }
768
769 if ( $revision ) { // sanity
770 $this->setLastEdit( $revision );
771 }
772 }
773
778 protected function setLastEdit( Revision $revision ) {
779 $this->mLastRevision = $revision;
780 $this->mTimestamp = $revision->getTimestamp();
781 }
782
787 public function getRevision() {
788 $this->loadLastEdit();
789 if ( $this->mLastRevision ) {
790 return $this->mLastRevision;
791 }
792 return null;
793 }
794
799 public function getRevisionRecord() {
800 $this->loadLastEdit();
801 if ( $this->mLastRevision ) {
802 return $this->mLastRevision->getRevisionRecord();
803 }
804 return null;
805 }
806
820 public function getContent( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) {
821 $this->loadLastEdit();
822 if ( $this->mLastRevision ) {
823 return $this->mLastRevision->getContent( $audience, $user );
824 }
825 return null;
826 }
827
831 public function getTimestamp() {
832 // Check if the field has been filled by WikiPage::setTimestamp()
833 if ( !$this->mTimestamp ) {
834 $this->loadLastEdit();
835 }
836
837 return wfTimestamp( TS_MW, $this->mTimestamp );
838 }
839
845 public function setTimestamp( $ts ) {
846 $this->mTimestamp = wfTimestamp( TS_MW, $ts );
847 }
848
858 public function getUser( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) {
859 $this->loadLastEdit();
860 if ( $this->mLastRevision ) {
861 return $this->mLastRevision->getUser( $audience, $user );
862 } else {
863 return -1;
864 }
865 }
866
877 public function getCreator( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) {
878 $revision = $this->getOldestRevision();
879 if ( $revision ) {
880 $userName = $revision->getUserText( $audience, $user );
881 return User::newFromName( $userName, false );
882 } else {
883 return null;
884 }
885 }
886
896 public function getUserText( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) {
897 $this->loadLastEdit();
898 if ( $this->mLastRevision ) {
899 return $this->mLastRevision->getUserText( $audience, $user );
900 } else {
901 return '';
902 }
903 }
904
915 public function getComment( $audience = RevisionRecord::FOR_PUBLIC, User $user = null ) {
916 $this->loadLastEdit();
917 if ( $this->mLastRevision ) {
918 return $this->mLastRevision->getComment( $audience, $user );
919 } else {
920 return '';
921 }
922 }
923
929 public function getMinorEdit() {
930 $this->loadLastEdit();
931 if ( $this->mLastRevision ) {
932 return $this->mLastRevision->isMinor();
933 } else {
934 return false;
935 }
936 }
937
946 public function isCountable( $editInfo = false ) {
948
949 // NOTE: Keep in sync with DerivedPageDataUpdater::isCountable.
950
951 if ( !$this->mTitle->isContentPage() ) {
952 return false;
953 }
954
955 if ( $editInfo ) {
956 // NOTE: only the main slot can make a page a redirect
957 $content = $editInfo->pstContent;
958 } else {
959 $content = $this->getContent();
960 }
961
962 if ( !$content || $content->isRedirect() ) {
963 return false;
964 }
965
966 $hasLinks = null;
967
968 if ( $wgArticleCountMethod === 'link' ) {
969 // nasty special case to avoid re-parsing to detect links
970
971 if ( $editInfo ) {
972 // ParserOutput::getLinks() is a 2D array of page links, so
973 // to be really correct we would need to recurse in the array
974 // but the main array should only have items in it if there are
975 // links.
976 $hasLinks = (bool)count( $editInfo->output->getLinks() );
977 } else {
978 // NOTE: keep in sync with RevisionRenderer::getLinkCount
979 // NOTE: keep in sync with DerivedPageDataUpdater::isCountable
980 $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
981 [ 'pl_from' => $this->getId() ], __METHOD__ );
982 }
983 }
984
985 // TODO: MCR: determine $hasLinks for each slot, and use that info
986 // with that slot's Content's isCountable method. That requires per-
987 // slot ParserOutput in the ParserCache, or per-slot info in the
988 // pagelinks table.
989 return $content->isCountable( $hasLinks );
990 }
991
999 public function getRedirectTarget() {
1000 if ( !$this->mTitle->isRedirect() ) {
1001 return null;
1002 }
1003
1004 if ( $this->mRedirectTarget !== null ) {
1005 return $this->mRedirectTarget;
1006 }
1007
1008 // Query the redirect table
1009 $dbr = wfGetDB( DB_REPLICA );
1010 $row = $dbr->selectRow( 'redirect',
1011 [ 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ],
1012 [ 'rd_from' => $this->getId() ],
1013 __METHOD__
1014 );
1015
1016 // rd_fragment and rd_interwiki were added later, populate them if empty
1017 if ( $row && !is_null( $row->rd_fragment ) && !is_null( $row->rd_interwiki ) ) {
1018 // (T203942) We can't redirect to Media namespace because it's virtual.
1019 // We don't want to modify Title objects farther down the
1020 // line. So, let's fix this here by changing to File namespace.
1021 if ( $row->rd_namespace == NS_MEDIA ) {
1022 $namespace = NS_FILE;
1023 } else {
1024 $namespace = $row->rd_namespace;
1025 }
1026 $this->mRedirectTarget = Title::makeTitle(
1027 $namespace, $row->rd_title,
1028 $row->rd_fragment, $row->rd_interwiki
1029 );
1030 return $this->mRedirectTarget;
1031 }
1032
1033 // This page doesn't have an entry in the redirect table
1034 $this->mRedirectTarget = $this->insertRedirect();
1035 return $this->mRedirectTarget;
1036 }
1037
1046 public function insertRedirect() {
1047 $content = $this->getContent();
1048 $retval = $content ? $content->getUltimateRedirectTarget() : null;
1049 if ( !$retval ) {
1050 return null;
1051 }
1052
1053 // Update the DB post-send if the page has not cached since now
1054 $latest = $this->getLatest();
1055 DeferredUpdates::addCallableUpdate(
1056 function () use ( $retval, $latest ) {
1057 $this->insertRedirectEntry( $retval, $latest );
1058 },
1059 DeferredUpdates::POSTSEND,
1061 );
1062
1063 return $retval;
1064 }
1065
1072 public function insertRedirectEntry( Title $rt, $oldLatest = null ) {
1073 $dbw = wfGetDB( DB_MASTER );
1074 $dbw->startAtomic( __METHOD__ );
1075
1076 if ( !$oldLatest || $oldLatest == $this->lockAndGetLatest() ) {
1077 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
1078 $truncatedFragment = $contLang->truncateForDatabase( $rt->getFragment(), 255 );
1079 $dbw->upsert(
1080 'redirect',
1081 [
1082 'rd_from' => $this->getId(),
1083 'rd_namespace' => $rt->getNamespace(),
1084 'rd_title' => $rt->getDBkey(),
1085 'rd_fragment' => $truncatedFragment,
1086 'rd_interwiki' => $rt->getInterwiki(),
1087 ],
1088 [ 'rd_from' ],
1089 [
1090 'rd_namespace' => $rt->getNamespace(),
1091 'rd_title' => $rt->getDBkey(),
1092 'rd_fragment' => $truncatedFragment,
1093 'rd_interwiki' => $rt->getInterwiki(),
1094 ],
1095 __METHOD__
1096 );
1097 $success = true;
1098 } else {
1099 $success = false;
1100 }
1101
1102 $dbw->endAtomic( __METHOD__ );
1103
1104 return $success;
1105 }
1106
1112 public function followRedirect() {
1113 return $this->getRedirectURL( $this->getRedirectTarget() );
1114 }
1115
1123 public function getRedirectURL( $rt ) {
1124 if ( !$rt ) {
1125 return false;
1126 }
1127
1128 if ( $rt->isExternal() ) {
1129 if ( $rt->isLocal() ) {
1130 // Offsite wikis need an HTTP redirect.
1131 // This can be hard to reverse and may produce loops,
1132 // so they may be disabled in the site configuration.
1133 $source = $this->mTitle->getFullURL( 'redirect=no' );
1134 return $rt->getFullURL( [ 'rdfrom' => $source ] );
1135 } else {
1136 // External pages without "local" bit set are not valid
1137 // redirect targets
1138 return false;
1139 }
1140 }
1141
1142 if ( $rt->isSpecialPage() ) {
1143 // Gotta handle redirects to special pages differently:
1144 // Fill the HTTP response "Location" header and ignore the rest of the page we're on.
1145 // Some pages are not valid targets.
1146 if ( $rt->isValidRedirectTarget() ) {
1147 return $rt->getFullURL();
1148 } else {
1149 return false;
1150 }
1151 }
1152
1153 return $rt;
1154 }
1155
1161 public function getContributors() {
1162 // @todo: This is expensive; cache this info somewhere.
1163
1164 $dbr = wfGetDB( DB_REPLICA );
1165
1166 $actorMigration = ActorMigration::newMigration();
1167 $actorQuery = $actorMigration->getJoin( 'rev_user' );
1168
1169 $tables = array_merge( [ 'revision' ], $actorQuery['tables'], [ 'user' ] );
1170
1171 $fields = [
1172 'user_id' => $actorQuery['fields']['rev_user'],
1173 'user_name' => $actorQuery['fields']['rev_user_text'],
1174 'actor_id' => $actorQuery['fields']['rev_actor'],
1175 'user_real_name' => 'MIN(user_real_name)',
1176 'timestamp' => 'MAX(rev_timestamp)',
1177 ];
1178
1179 $conds = [ 'rev_page' => $this->getId() ];
1180
1181 // The user who made the top revision gets credited as "this page was last edited by
1182 // John, based on contributions by Tom, Dick and Harry", so don't include them twice.
1183 $user = $this->getUser()
1184 ? User::newFromId( $this->getUser() )
1185 : User::newFromName( $this->getUserText(), false );
1186 $conds[] = 'NOT(' . $actorMigration->getWhere( $dbr, 'rev_user', $user )['conds'] . ')';
1187
1188 // Username hidden?
1189 $conds[] = "{$dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER )} = 0";
1190
1191 $jconds = [
1192 'user' => [ 'LEFT JOIN', $actorQuery['fields']['rev_user'] . ' = user_id' ],
1193 ] + $actorQuery['joins'];
1194
1195 $options = [
1196 'GROUP BY' => [ $fields['user_id'], $fields['user_name'] ],
1197 'ORDER BY' => 'timestamp DESC',
1198 ];
1199
1200 $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $jconds );
1201 return new UserArrayFromResult( $res );
1202 }
1203
1211 public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) {
1212 return $parserOptions->getStubThreshold() == 0
1213 && $this->exists()
1214 && ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() )
1215 && $this->getContentHandler()->isParserCacheSupported();
1216 }
1217
1233 public function getParserOutput(
1234 ParserOptions $parserOptions, $oldid = null, $forceParse = false
1235 ) {
1236 $useParserCache =
1237 ( !$forceParse ) && $this->shouldCheckParserCache( $parserOptions, $oldid );
1238
1239 if ( $useParserCache && !$parserOptions->isSafeToCache() ) {
1240 throw new InvalidArgumentException(
1241 'The supplied ParserOptions are not safe to cache. Fix the options or set $forceParse = true.'
1242 );
1243 }
1244
1245 wfDebug( __METHOD__ .
1246 ': using parser cache: ' . ( $useParserCache ? 'yes' : 'no' ) . "\n" );
1247 if ( $parserOptions->getStubThreshold() ) {
1248 wfIncrStats( 'pcache.miss.stub' );
1249 }
1250
1251 if ( $useParserCache ) {
1252 $parserOutput = $this->getParserCache()
1253 ->get( $this, $parserOptions );
1254 if ( $parserOutput !== false ) {
1255 return $parserOutput;
1256 }
1257 }
1258
1259 if ( $oldid === null || $oldid === 0 ) {
1260 $oldid = $this->getLatest();
1261 }
1262
1263 $pool = new PoolWorkArticleView( $this, $parserOptions, $oldid, $useParserCache );
1264 $pool->execute();
1265
1266 return $pool->getParserOutput();
1267 }
1268
1274 public function doViewUpdates( User $user, $oldid = 0 ) {
1275 if ( wfReadOnly() ) {
1276 return;
1277 }
1278
1279 // Update newtalk / watchlist notification status;
1280 // Avoid outage if the master is not reachable by using a deferred updated
1281 DeferredUpdates::addCallableUpdate(
1282 function () use ( $user, $oldid ) {
1283 Hooks::run( 'PageViewUpdates', [ $this, $user ] );
1284
1285 $user->clearNotification( $this->mTitle, $oldid );
1286 },
1287 DeferredUpdates::PRESEND
1288 );
1289 }
1290
1297 public function doPurge() {
1298 // Avoid PHP 7.1 warning of passing $this by reference
1299 $wikiPage = $this;
1300
1301 if ( !Hooks::run( 'ArticlePurge', [ &$wikiPage ] ) ) {
1302 return false;
1303 }
1304
1305 $this->mTitle->invalidateCache();
1306
1307 // Clear file cache
1309 // Send purge after above page_touched update was committed
1310 DeferredUpdates::addUpdate(
1311 new CdnCacheUpdate( $this->mTitle->getCdnUrls() ),
1312 DeferredUpdates::PRESEND
1313 );
1314
1315 if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
1316 $messageCache = MessageCache::singleton();
1317 $messageCache->updateMessageOverride( $this->mTitle, $this->getContent() );
1318 }
1319
1320 return true;
1321 }
1322
1339 public function insertOn( $dbw, $pageId = null ) {
1340 $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : [];
1341 $dbw->insert(
1342 'page',
1343 [
1344 'page_namespace' => $this->mTitle->getNamespace(),
1345 'page_title' => $this->mTitle->getDBkey(),
1346 'page_restrictions' => '',
1347 'page_is_redirect' => 0, // Will set this shortly...
1348 'page_is_new' => 1,
1349 'page_random' => wfRandom(),
1350 'page_touched' => $dbw->timestamp(),
1351 'page_latest' => 0, // Fill this in shortly...
1352 'page_len' => 0, // Fill this in shortly...
1353 ] + $pageIdForInsert,
1354 __METHOD__,
1355 [ 'IGNORE' ]
1356 );
1357
1358 if ( $dbw->affectedRows() > 0 ) {
1359 $newid = $pageId ? (int)$pageId : $dbw->insertId();
1360 $this->mId = $newid;
1361 $this->mTitle->resetArticleID( $newid );
1362
1363 return $newid;
1364 } else {
1365 return false; // nothing changed
1366 }
1367 }
1368
1384 public function updateRevisionOn( $dbw, $revision, $lastRevision = null,
1385 $lastRevIsRedirect = null
1386 ) {
1388
1389 // TODO: move into PageUpdater or PageStore
1390 // NOTE: when doing that, make sure cached fields get reset in doEditContent,
1391 // and in the compat stub!
1392
1393 // Assertion to try to catch T92046
1394 if ( (int)$revision->getId() === 0 ) {
1395 throw new InvalidArgumentException(
1396 __METHOD__ . ': Revision has ID ' . var_export( $revision->getId(), 1 )
1397 );
1398 }
1399
1400 $content = $revision->getContent();
1401 $len = $content ? $content->getSize() : 0;
1402 $rt = $content ? $content->getUltimateRedirectTarget() : null;
1403
1404 $conditions = [ 'page_id' => $this->getId() ];
1405
1406 if ( !is_null( $lastRevision ) ) {
1407 // An extra check against threads stepping on each other
1408 $conditions['page_latest'] = $lastRevision;
1409 }
1410
1411 $revId = $revision->getId();
1412 Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' );
1413
1414 $row = [ /* SET */
1415 'page_latest' => $revId,
1416 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ),
1417 'page_is_new' => ( $lastRevision === 0 ) ? 1 : 0,
1418 'page_is_redirect' => $rt !== null ? 1 : 0,
1419 'page_len' => $len,
1420 ];
1421
1422 if ( $wgContentHandlerUseDB ) {
1423 $row['page_content_model'] = $revision->getContentModel();
1424 }
1425
1426 $dbw->update( 'page',
1427 $row,
1428 $conditions,
1429 __METHOD__ );
1430
1431 $result = $dbw->affectedRows() > 0;
1432 if ( $result ) {
1433 $this->updateRedirectOn( $dbw, $rt, $lastRevIsRedirect );
1434 $this->setLastEdit( $revision );
1435 $this->mLatest = $revision->getId();
1436 $this->mIsRedirect = (bool)$rt;
1437 // Update the LinkCache.
1438 $linkCache = MediaWikiServices::getInstance()->getLinkCache();
1439 $linkCache->addGoodLinkObj(
1440 $this->getId(),
1441 $this->mTitle,
1442 $len,
1443 $this->mIsRedirect,
1444 $this->mLatest,
1445 $revision->getContentModel()
1446 );
1447 }
1448
1449 return $result;
1450 }
1451
1463 public function updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect = null ) {
1464 // Always update redirects (target link might have changed)
1465 // Update/Insert if we don't know if the last revision was a redirect or not
1466 // Delete if changing from redirect to non-redirect
1467 $isRedirect = !is_null( $redirectTitle );
1468
1469 if ( !$isRedirect && $lastRevIsRedirect === false ) {
1470 return true;
1471 }
1472
1473 if ( $isRedirect ) {
1474 $success = $this->insertRedirectEntry( $redirectTitle );
1475 } else {
1476 // This is not a redirect, remove row from redirect table
1477 $where = [ 'rd_from' => $this->getId() ];
1478 $dbw->delete( 'redirect', $where, __METHOD__ );
1479 $success = true;
1480 }
1481
1482 if ( $this->getTitle()->getNamespace() == NS_FILE ) {
1483 RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $this->getTitle() );
1484 }
1485
1486 return $success;
1487 }
1488
1499 public function updateIfNewerOn( $dbw, $revision ) {
1500 $row = $dbw->selectRow(
1501 [ 'revision', 'page' ],
1502 [ 'rev_id', 'rev_timestamp', 'page_is_redirect' ],
1503 [
1504 'page_id' => $this->getId(),
1505 'page_latest=rev_id' ],
1506 __METHOD__ );
1507
1508 if ( $row ) {
1509 if ( wfTimestamp( TS_MW, $row->rev_timestamp ) >= $revision->getTimestamp() ) {
1510 return false;
1511 }
1512 $prev = $row->rev_id;
1513 $lastRevIsRedirect = (bool)$row->page_is_redirect;
1514 } else {
1515 // No or missing previous revision; mark the page as new
1516 $prev = 0;
1517 $lastRevIsRedirect = null;
1518 }
1519
1520 $ret = $this->updateRevisionOn( $dbw, $revision, $prev, $lastRevIsRedirect );
1521
1522 return $ret;
1523 }
1524
1537 public static function hasDifferencesOutsideMainSlot( Revision $a, Revision $b ) {
1538 $aSlots = $a->getRevisionRecord()->getSlots();
1539 $bSlots = $b->getRevisionRecord()->getSlots();
1540 $changedRoles = $aSlots->getRolesWithDifferentContent( $bSlots );
1541
1542 return ( $changedRoles !== [ SlotRecord::MAIN ] && $changedRoles !== [] );
1543 }
1544
1556 public function getUndoContent( Revision $undo, Revision $undoafter ) {
1557 // TODO: MCR: replace this with a method that returns a RevisionSlotsUpdate
1558
1559 if ( self::hasDifferencesOutsideMainSlot( $undo, $undoafter ) ) {
1560 // Cannot yet undo edits that involve anything other the main slot.
1561 return false;
1562 }
1563
1564 $handler = $undo->getContentHandler();
1565 return $handler->getUndoContent( $this->getRevision(), $undo, $undoafter );
1566 }
1567
1578 public function supportsSections() {
1579 return $this->getContentHandler()->supportsSections();
1580 }
1581
1596 public function replaceSectionContent(
1597 $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null
1598 ) {
1599 $baseRevId = null;
1600 if ( $edittime && $sectionId !== 'new' ) {
1601 $lb = $this->getDBLoadBalancer();
1602 $dbr = $lb->getConnectionRef( DB_REPLICA );
1603 $rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
1604 // Try the master if this thread may have just added it.
1605 // This could be abstracted into a Revision method, but we don't want
1606 // to encourage loading of revisions by timestamp.
1607 if ( !$rev
1608 && $lb->getServerCount() > 1
1609 && $lb->hasOrMadeRecentMasterChanges()
1610 ) {
1611 $dbw = $lb->getConnectionRef( DB_MASTER );
1612 $rev = Revision::loadFromTimestamp( $dbw, $this->mTitle, $edittime );
1613 }
1614 if ( $rev ) {
1615 $baseRevId = $rev->getId();
1616 }
1617 }
1618
1619 return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId );
1620 }
1621
1635 public function replaceSectionAtRev( $sectionId, Content $sectionContent,
1636 $sectionTitle = '', $baseRevId = null
1637 ) {
1638 if ( strval( $sectionId ) === '' ) {
1639 // Whole-page edit; let the whole text through
1640 $newContent = $sectionContent;
1641 } else {
1642 if ( !$this->supportsSections() ) {
1643 throw new MWException( "sections not supported for content model " .
1644 $this->getContentHandler()->getModelID() );
1645 }
1646
1647 // T32711: always use current version when adding a new section
1648 if ( is_null( $baseRevId ) || $sectionId === 'new' ) {
1649 $oldContent = $this->getContent();
1650 } else {
1651 $rev = Revision::newFromId( $baseRevId );
1652 if ( !$rev ) {
1653 wfDebug( __METHOD__ . " asked for bogus section (page: " .
1654 $this->getId() . "; section: $sectionId)\n" );
1655 return null;
1656 }
1657
1658 $oldContent = $rev->getContent();
1659 }
1660
1661 if ( !$oldContent ) {
1662 wfDebug( __METHOD__ . ": no page text\n" );
1663 return null;
1664 }
1665
1666 $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle );
1667 }
1668
1669 return $newContent;
1670 }
1671
1681 public function checkFlags( $flags ) {
1682 if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
1683 if ( $this->exists() ) {
1684 $flags |= EDIT_UPDATE;
1685 } else {
1686 $flags |= EDIT_NEW;
1687 }
1688 }
1689
1690 return $flags;
1691 }
1692
1696 private function newDerivedDataUpdater() {
1698
1700 $this, // NOTE: eventually, PageUpdater should not know about WikiPage
1701 $this->getRevisionStore(),
1702 $this->getRevisionRenderer(),
1703 $this->getSlotRoleRegistry(),
1704 $this->getParserCache(),
1705 JobQueueGroup::singleton(),
1706 MessageCache::singleton(),
1707 MediaWikiServices::getInstance()->getContentLanguage(),
1708 MediaWikiServices::getInstance()->getDBLoadBalancerFactory()
1709 );
1710
1711 $derivedDataUpdater->setLogger( LoggerFactory::getInstance( 'SaveParse' ) );
1714
1715 return $derivedDataUpdater;
1716 }
1717
1745 private function getDerivedDataUpdater(
1746 User $forUser = null,
1747 RevisionRecord $forRevision = null,
1748 RevisionSlotsUpdate $forUpdate = null,
1749 $forEdit = false
1750 ) {
1751 if ( !$forRevision && !$forUpdate ) {
1752 // NOTE: can't re-use an existing derivedDataUpdater if we don't know what the caller is
1753 // going to use it with.
1754 $this->derivedDataUpdater = null;
1755 }
1756
1757 if ( $this->derivedDataUpdater && !$this->derivedDataUpdater->isContentPrepared() ) {
1758 // NOTE: can't re-use an existing derivedDataUpdater if other code that has a reference
1759 // to it did not yet initialize it, because we don't know what data it will be
1760 // initialized with.
1761 $this->derivedDataUpdater = null;
1762 }
1763
1764 // XXX: It would be nice to have an LRU cache instead of trying to re-use a single instance.
1765 // However, there is no good way to construct a cache key. We'd need to check against all
1766 // cached instances.
1767
1768 if ( $this->derivedDataUpdater
1769 && !$this->derivedDataUpdater->isReusableFor(
1770 $forUser,
1771 $forRevision,
1772 $forUpdate,
1773 $forEdit ? $this->getLatest() : null
1774 )
1775 ) {
1776 $this->derivedDataUpdater = null;
1777 }
1778
1779 if ( !$this->derivedDataUpdater ) {
1780 $this->derivedDataUpdater = $this->newDerivedDataUpdater();
1781 }
1782
1783 return $this->derivedDataUpdater;
1784 }
1785
1801 public function newPageUpdater( User $user, RevisionSlotsUpdate $forUpdate = null ) {
1803
1804 $pageUpdater = new PageUpdater(
1805 $user,
1806 $this, // NOTE: eventually, PageUpdater should not know about WikiPage
1807 $this->getDerivedDataUpdater( $user, null, $forUpdate, true ),
1808 $this->getDBLoadBalancer(),
1809 $this->getRevisionStore(),
1810 $this->getSlotRoleRegistry()
1811 );
1812
1813 $pageUpdater->setUsePageCreationLog( $wgPageCreationLog );
1814 $pageUpdater->setAjaxEditStash( $wgAjaxEditStash );
1815 $pageUpdater->setUseAutomaticEditSummaries( $wgUseAutomaticEditSummaries );
1816
1817 return $pageUpdater;
1818 }
1819
1882 public function doEditContent(
1883 Content $content, $summary, $flags = 0, $originalRevId = false,
1884 User $user = null, $serialFormat = null, $tags = [], $undidRevId = 0
1885 ) {
1886 global $wgUser, $wgUseNPPatrol, $wgUseRCPatrol;
1887
1888 if ( !( $summary instanceof CommentStoreComment ) ) {
1889 $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
1890 }
1891
1892 if ( !$user ) {
1893 $user = $wgUser;
1894 }
1895
1896 // TODO: this check is here for backwards-compatibility with 1.31 behavior.
1897 // Checking the minoredit right should be done in the same place the 'bot' right is
1898 // checked for the EDIT_FORCE_BOT flag, which is currently in EditPage::attemptSave.
1899 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
1900 if ( ( $flags & EDIT_MINOR ) && !$permissionManager->userHasRight( $user, 'minoredit' ) ) {
1901 $flags = ( $flags & ~EDIT_MINOR );
1902 }
1903
1904 $slotsUpdate = new RevisionSlotsUpdate();
1905 $slotsUpdate->modifyContent( SlotRecord::MAIN, $content );
1906
1907 // NOTE: while doEditContent() executes, callbacks to getDerivedDataUpdater and
1908 // prepareContentForEdit will generally use the DerivedPageDataUpdater that is also
1909 // used by this PageUpdater. However, there is no guarantee for this.
1910 $updater = $this->newPageUpdater( $user, $slotsUpdate );
1911 $updater->setContent( SlotRecord::MAIN, $content );
1912 $updater->setOriginalRevisionId( $originalRevId );
1913 $updater->setUndidRevisionId( $undidRevId );
1914
1915 $needsPatrol = $wgUseRCPatrol || ( $wgUseNPPatrol && !$this->exists() );
1916
1917 // TODO: this logic should not be in the storage layer, it's here for compatibility
1918 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
1919 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
1920
1921 if ( $needsPatrol && $permissionManager->userCan(
1922 'autopatrol', $user, $this->getTitle()
1923 ) ) {
1924 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
1925 }
1926
1927 $updater->addTags( $tags );
1928
1929 $revRec = $updater->saveRevision(
1930 $summary,
1931 $flags
1932 );
1933
1934 // $revRec will be null if the edit failed, or if no new revision was created because
1935 // the content did not change.
1936 if ( $revRec ) {
1937 // update cached fields
1938 // TODO: this is currently redundant to what is done in updateRevisionOn.
1939 // But updateRevisionOn() should move into PageStore, and then this will be needed.
1940 $this->setLastEdit( new Revision( $revRec ) ); // TODO: use RevisionRecord
1941 $this->mLatest = $revRec->getId();
1942 }
1943
1944 return $updater->getStatus();
1945 }
1946
1961 public function makeParserOptions( $context ) {
1962 $options = ParserOptions::newCanonical( $context );
1963
1964 if ( $this->getTitle()->isConversionTable() ) {
1965 // @todo ConversionTable should become a separate content model, so
1966 // we don't need special cases like this one.
1967 $options->disableContentConversion();
1968 }
1969
1970 return $options;
1971 }
1972
1991 public function prepareContentForEdit(
1993 $revision = null,
1994 User $user = null,
1995 $serialFormat = null,
1996 $useCache = true
1997 ) {
1998 global $wgUser;
1999
2000 if ( !$user ) {
2001 $user = $wgUser;
2002 }
2003
2004 if ( $revision !== null ) {
2005 if ( $revision instanceof Revision ) {
2006 $revision = $revision->getRevisionRecord();
2007 } elseif ( !( $revision instanceof RevisionRecord ) ) {
2008 throw new InvalidArgumentException(
2009 __METHOD__ . ': invalid $revision argument type ' . gettype( $revision ) );
2010 }
2011 }
2012
2013 $slots = RevisionSlotsUpdate::newFromContent( [ SlotRecord::MAIN => $content ] );
2014 $updater = $this->getDerivedDataUpdater( $user, $revision, $slots );
2015
2016 if ( !$updater->isUpdatePrepared() ) {
2017 $updater->prepareContent( $user, $slots, $useCache );
2018
2019 if ( $revision ) {
2020 $updater->prepareUpdate(
2021 $revision,
2022 [
2023 'causeAction' => 'prepare-edit',
2024 'causeAgent' => $user->getName(),
2025 ]
2026 );
2027 }
2028 }
2029
2030 return $updater->getPreparedEdit();
2031 }
2032
2060 public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
2061 $options += [
2062 'causeAction' => 'edit-page',
2063 'causeAgent' => $user->getName(),
2064 ];
2065
2066 $revision = $revision->getRevisionRecord();
2067
2068 $updater = $this->getDerivedDataUpdater( $user, $revision );
2069
2070 $updater->prepareUpdate( $revision, $options );
2071
2072 $updater->doUpdates();
2073 }
2074
2088 public function updateParserCache( array $options = [] ) {
2089 $revision = $this->getRevisionRecord();
2090 if ( !$revision || !$revision->getId() ) {
2091 LoggerFactory::getInstance( 'wikipage' )->info(
2092 __METHOD__ . 'called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision'
2093 );
2094 return;
2095 }
2096 $user = User::newFromIdentity( $revision->getUser( RevisionRecord::RAW ) );
2097
2098 $updater = $this->getDerivedDataUpdater( $user, $revision );
2099 $updater->prepareUpdate( $revision, $options );
2100 $updater->doParserCacheUpdate();
2101 }
2102
2132 public function doSecondaryDataUpdates( array $options = [] ) {
2133 $options['recursive'] = $options['recursive'] ?? true;
2134 $revision = $this->getRevisionRecord();
2135 if ( !$revision || !$revision->getId() ) {
2136 LoggerFactory::getInstance( 'wikipage' )->info(
2137 __METHOD__ . 'called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision'
2138 );
2139 return;
2140 }
2141 $user = User::newFromIdentity( $revision->getUser( RevisionRecord::RAW ) );
2142
2143 $updater = $this->getDerivedDataUpdater( $user, $revision );
2144 $updater->prepareUpdate( $revision, $options );
2145 $updater->doSecondaryDataUpdates( $options );
2146 }
2147
2162 public function doUpdateRestrictions( array $limit, array $expiry,
2163 &$cascade, $reason, User $user, $tags = null
2164 ) {
2166
2167 if ( wfReadOnly() ) {
2168 return Status::newFatal( wfMessage( 'readonlytext', wfReadOnlyReason() ) );
2169 }
2170
2171 $this->loadPageData( 'fromdbmaster' );
2172 $this->mTitle->loadRestrictions( null, Title::READ_LATEST );
2173 $restrictionTypes = $this->mTitle->getRestrictionTypes();
2174 $id = $this->getId();
2175
2176 if ( !$cascade ) {
2177 $cascade = false;
2178 }
2179
2180 // Take this opportunity to purge out expired restrictions
2181 Title::purgeExpiredRestrictions();
2182
2183 // @todo: Same limitations as described in ProtectionForm.php (line 37);
2184 // we expect a single selection, but the schema allows otherwise.
2185 $isProtected = false;
2186 $protect = false;
2187 $changed = false;
2188
2189 $dbw = wfGetDB( DB_MASTER );
2190
2191 foreach ( $restrictionTypes as $action ) {
2192 if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) {
2193 $expiry[$action] = 'infinity';
2194 }
2195 if ( !isset( $limit[$action] ) ) {
2196 $limit[$action] = '';
2197 } elseif ( $limit[$action] != '' ) {
2198 $protect = true;
2199 }
2200
2201 // Get current restrictions on $action
2202 $current = implode( '', $this->mTitle->getRestrictions( $action ) );
2203 if ( $current != '' ) {
2204 $isProtected = true;
2205 }
2206
2207 if ( $limit[$action] != $current ) {
2208 $changed = true;
2209 } elseif ( $limit[$action] != '' ) {
2210 // Only check expiry change if the action is actually being
2211 // protected, since expiry does nothing on an not-protected
2212 // action.
2213 if ( $this->mTitle->getRestrictionExpiry( $action ) != $expiry[$action] ) {
2214 $changed = true;
2215 }
2216 }
2217 }
2218
2219 if ( !$changed && $protect && $this->mTitle->areRestrictionsCascading() != $cascade ) {
2220 $changed = true;
2221 }
2222
2223 // If nothing has changed, do nothing
2224 if ( !$changed ) {
2225 return Status::newGood();
2226 }
2227
2228 if ( !$protect ) { // No protection at all means unprotection
2229 $revCommentMsg = 'unprotectedarticle-comment';
2230 $logAction = 'unprotect';
2231 } elseif ( $isProtected ) {
2232 $revCommentMsg = 'modifiedarticleprotection-comment';
2233 $logAction = 'modify';
2234 } else {
2235 $revCommentMsg = 'protectedarticle-comment';
2236 $logAction = 'protect';
2237 }
2238
2239 $logRelationsValues = [];
2240 $logRelationsField = null;
2241 $logParamsDetails = [];
2242
2243 // Null revision (used for change tag insertion)
2244 $nullRevision = null;
2245
2246 if ( $id ) { // Protection of existing page
2247 // Avoid PHP 7.1 warning of passing $this by reference
2248 $wikiPage = $this;
2249
2250 if ( !Hooks::run( 'ArticleProtect', [ &$wikiPage, &$user, $limit, $reason ] ) ) {
2251 return Status::newGood();
2252 }
2253
2254 // Only certain restrictions can cascade...
2255 $editrestriction = isset( $limit['edit'] )
2256 ? [ $limit['edit'] ]
2257 : $this->mTitle->getRestrictions( 'edit' );
2258 foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) {
2259 $editrestriction[$key] = 'editprotected'; // backwards compatibility
2260 }
2261 foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) {
2262 $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility
2263 }
2264
2265 $cascadingRestrictionLevels = $wgCascadingRestrictionLevels;
2266 foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) {
2267 $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility
2268 }
2269 foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) {
2270 $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility
2271 }
2272
2273 // The schema allows multiple restrictions
2274 if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) {
2275 $cascade = false;
2276 }
2277
2278 // insert null revision to identify the page protection change as edit summary
2279 $latest = $this->getLatest();
2280 $nullRevision = $this->insertProtectNullRevision(
2281 $revCommentMsg,
2282 $limit,
2283 $expiry,
2284 $cascade,
2285 $reason,
2286 $user
2287 );
2288
2289 if ( $nullRevision === null ) {
2290 return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() );
2291 }
2292
2293 $logRelationsField = 'pr_id';
2294
2295 // Update restrictions table
2296 foreach ( $limit as $action => $restrictions ) {
2297 $dbw->delete(
2298 'page_restrictions',
2299 [
2300 'pr_page' => $id,
2301 'pr_type' => $action
2302 ],
2303 __METHOD__
2304 );
2305 if ( $restrictions != '' ) {
2306 $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0;
2307 $dbw->insert(
2308 'page_restrictions',
2309 [
2310 'pr_page' => $id,
2311 'pr_type' => $action,
2312 'pr_level' => $restrictions,
2313 'pr_cascade' => $cascadeValue,
2314 'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] )
2315 ],
2316 __METHOD__
2317 );
2318 $logRelationsValues[] = $dbw->insertId();
2319 $logParamsDetails[] = [
2320 'type' => $action,
2321 'level' => $restrictions,
2322 'expiry' => $expiry[$action],
2323 'cascade' => (bool)$cascadeValue,
2324 ];
2325 }
2326 }
2327
2328 // Clear out legacy restriction fields
2329 $dbw->update(
2330 'page',
2331 [ 'page_restrictions' => '' ],
2332 [ 'page_id' => $id ],
2333 __METHOD__
2334 );
2335
2336 // Avoid PHP 7.1 warning of passing $this by reference
2337 $wikiPage = $this;
2338
2339 Hooks::run( 'NewRevisionFromEditComplete',
2340 [ $this, $nullRevision, $latest, $user ] );
2341 Hooks::run( 'ArticleProtectComplete', [ &$wikiPage, &$user, $limit, $reason ] );
2342 } else { // Protection of non-existing page (also known as "title protection")
2343 // Cascade protection is meaningless in this case
2344 $cascade = false;
2345
2346 if ( $limit['create'] != '' ) {
2347 $commentFields = CommentStore::getStore()->insert( $dbw, 'pt_reason', $reason );
2348 $dbw->replace( 'protected_titles',
2349 [ [ 'pt_namespace', 'pt_title' ] ],
2350 [
2351 'pt_namespace' => $this->mTitle->getNamespace(),
2352 'pt_title' => $this->mTitle->getDBkey(),
2353 'pt_create_perm' => $limit['create'],
2354 'pt_timestamp' => $dbw->timestamp(),
2355 'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ),
2356 'pt_user' => $user->getId(),
2357 ] + $commentFields, __METHOD__
2358 );
2359 $logParamsDetails[] = [
2360 'type' => 'create',
2361 'level' => $limit['create'],
2362 'expiry' => $expiry['create'],
2363 ];
2364 } else {
2365 $dbw->delete( 'protected_titles',
2366 [
2367 'pt_namespace' => $this->mTitle->getNamespace(),
2368 'pt_title' => $this->mTitle->getDBkey()
2369 ], __METHOD__
2370 );
2371 }
2372 }
2373
2374 $this->mTitle->flushRestrictions();
2375 InfoAction::invalidateCache( $this->mTitle );
2376
2377 if ( $logAction == 'unprotect' ) {
2378 $params = [];
2379 } else {
2380 $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry );
2381 $params = [
2382 '4::description' => $protectDescriptionLog, // parameter for IRC
2383 '5:bool:cascade' => $cascade,
2384 'details' => $logParamsDetails, // parameter for localize and api
2385 ];
2386 }
2387
2388 // Update the protection log
2389 $logEntry = new ManualLogEntry( 'protect', $logAction );
2390 $logEntry->setTarget( $this->mTitle );
2391 $logEntry->setComment( $reason );
2392 $logEntry->setPerformer( $user );
2393 $logEntry->setParameters( $params );
2394 if ( !is_null( $nullRevision ) ) {
2395 $logEntry->setAssociatedRevId( $nullRevision->getId() );
2396 }
2397 $logEntry->addTags( $tags );
2398 if ( $logRelationsField !== null && count( $logRelationsValues ) ) {
2399 $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] );
2400 }
2401 $logId = $logEntry->insert();
2402 $logEntry->publish( $logId );
2403
2404 return Status::newGood( $logId );
2405 }
2406
2418 public function insertProtectNullRevision( $revCommentMsg, array $limit,
2419 array $expiry, $cascade, $reason, $user = null
2420 ) {
2421 $dbw = wfGetDB( DB_MASTER );
2422
2423 // Prepare a null revision to be added to the history
2424 $editComment = wfMessage(
2425 $revCommentMsg,
2426 $this->mTitle->getPrefixedText(),
2427 $user ? $user->getName() : ''
2428 )->inContentLanguage()->text();
2429 if ( $reason ) {
2430 $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
2431 }
2432 $protectDescription = $this->protectDescription( $limit, $expiry );
2433 if ( $protectDescription ) {
2434 $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2435 $editComment .= wfMessage( 'parentheses' )->params( $protectDescription )
2436 ->inContentLanguage()->text();
2437 }
2438 if ( $cascade ) {
2439 $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2440 $editComment .= wfMessage( 'brackets' )->params(
2441 wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text()
2442 )->inContentLanguage()->text();
2443 }
2444
2445 $nullRev = Revision::newNullRevision( $dbw, $this->getId(), $editComment, true, $user );
2446 if ( $nullRev ) {
2447 $nullRev->insertOn( $dbw );
2448
2449 // Update page record and touch page
2450 $oldLatest = $nullRev->getParentId();
2451 $this->updateRevisionOn( $dbw, $nullRev, $oldLatest );
2452 }
2453
2454 return $nullRev;
2455 }
2456
2461 protected function formatExpiry( $expiry ) {
2462 if ( $expiry != 'infinity' ) {
2463 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
2464 return wfMessage(
2465 'protect-expiring',
2466 $contLang->timeanddate( $expiry, false, false ),
2467 $contLang->date( $expiry, false, false ),
2468 $contLang->time( $expiry, false, false )
2469 )->inContentLanguage()->text();
2470 } else {
2471 return wfMessage( 'protect-expiry-indefinite' )
2472 ->inContentLanguage()->text();
2473 }
2474 }
2475
2483 public function protectDescription( array $limit, array $expiry ) {
2484 $protectDescription = '';
2485
2486 foreach ( array_filter( $limit ) as $action => $restrictions ) {
2487 # $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ].
2488 # All possible message keys are listed here for easier grepping:
2489 # * restriction-create
2490 # * restriction-edit
2491 # * restriction-move
2492 # * restriction-upload
2493 $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text();
2494 # $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ],
2495 # with '' filtered out. All possible message keys are listed below:
2496 # * protect-level-autoconfirmed
2497 # * protect-level-sysop
2498 $restrictionsText = wfMessage( 'protect-level-' . $restrictions )
2499 ->inContentLanguage()->text();
2500
2501 $expiryText = $this->formatExpiry( $expiry[$action] );
2502
2503 if ( $protectDescription !== '' ) {
2504 $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text();
2505 }
2506 $protectDescription .= wfMessage( 'protect-summary-desc' )
2507 ->params( $actionText, $restrictionsText, $expiryText )
2508 ->inContentLanguage()->text();
2509 }
2510
2511 return $protectDescription;
2512 }
2513
2525 public function protectDescriptionLog( array $limit, array $expiry ) {
2526 $protectDescriptionLog = '';
2527
2528 $dirMark = MediaWikiServices::getInstance()->getContentLanguage()->getDirMark();
2529 foreach ( array_filter( $limit ) as $action => $restrictions ) {
2530 $expiryText = $this->formatExpiry( $expiry[$action] );
2531 $protectDescriptionLog .=
2532 $dirMark .
2533 "[$action=$restrictions] ($expiryText)";
2534 }
2535
2536 return trim( $protectDescriptionLog );
2537 }
2538
2548 protected static function flattenRestrictions( $limit ) {
2549 if ( !is_array( $limit ) ) {
2550 throw new MWException( __METHOD__ . ' given non-array restriction set' );
2551 }
2552
2553 $bits = [];
2554 ksort( $limit );
2555
2556 foreach ( array_filter( $limit ) as $action => $restrictions ) {
2557 $bits[] = "$action=$restrictions";
2558 }
2559
2560 return implode( ':', $bits );
2561 }
2562
2575 public function isBatchedDelete( $safetyMargin = 0 ) {
2577
2578 $dbr = wfGetDB( DB_REPLICA );
2579 $revCount = $this->getRevisionStore()->countRevisionsByPageId( $dbr, $this->getId() );
2580 $revCount += $safetyMargin;
2581
2582 return $revCount >= $wgDeleteRevisionsBatchSize;
2583 }
2584
2604 public function doDeleteArticle(
2605 $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $user = null,
2606 $immediate = false
2607 ) {
2608 $status = $this->doDeleteArticleReal( $reason, $suppress, $u1, $u2, $error, $user,
2609 [], 'delete', $immediate );
2610
2611 // Returns true if the page was actually deleted, or is scheduled for deletion
2612 return $status->isOK();
2613 }
2614
2637 public function doDeleteArticleReal(
2638 $reason, $suppress = false, $u1 = null, $u2 = null, &$error = '', User $deleter = null,
2639 $tags = [], $logsubtype = 'delete', $immediate = false
2640 ) {
2641 global $wgUser;
2642
2643 wfDebug( __METHOD__ . "\n" );
2644
2645 $status = Status::newGood();
2646
2647 // Avoid PHP 7.1 warning of passing $this by reference
2648 $wikiPage = $this;
2649
2650 if ( !$deleter ) {
2651 $deleter = $wgUser;
2652 }
2653 if ( !Hooks::run( 'ArticleDelete',
2654 [ &$wikiPage, &$deleter, &$reason, &$error, &$status, $suppress ]
2655 ) ) {
2656 if ( $status->isOK() ) {
2657 // Hook aborted but didn't set a fatal status
2658 $status->fatal( 'delete-hook-aborted' );
2659 }
2660 return $status;
2661 }
2662
2663 return $this->doDeleteArticleBatched( $reason, $suppress, $deleter, $tags,
2664 $logsubtype, $immediate );
2665 }
2666
2675 public function doDeleteArticleBatched(
2676 $reason, $suppress, User $deleter, $tags,
2677 $logsubtype, $immediate = false, $webRequestId = null
2678 ) {
2679 wfDebug( __METHOD__ . "\n" );
2680
2681 $status = Status::newGood();
2682
2683 $dbw = wfGetDB( DB_MASTER );
2684 $dbw->startAtomic( __METHOD__ );
2685
2686 $this->loadPageData( self::READ_LATEST );
2687 $id = $this->getId();
2688 // T98706: lock the page from various other updates but avoid using
2689 // WikiPage::READ_LOCKING as that will carry over the FOR UPDATE to
2690 // the revisions queries (which also JOIN on user). Only lock the page
2691 // row and CAS check on page_latest to see if the trx snapshot matches.
2692 $lockedLatest = $this->lockAndGetLatest();
2693 if ( $id == 0 || $this->getLatest() != $lockedLatest ) {
2694 $dbw->endAtomic( __METHOD__ );
2695 // Page not there or trx snapshot is stale
2696 $status->error( 'cannotdelete',
2697 wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2698 return $status;
2699 }
2700
2701 // At this point we are now committed to returning an OK
2702 // status unless some DB query error or other exception comes up.
2703 // This way callers don't have to call rollback() if $status is bad
2704 // unless they actually try to catch exceptions (which is rare).
2705
2706 // we need to remember the old content so we can use it to generate all deletion updates.
2707 $revision = $this->getRevision();
2708 try {
2709 $content = $this->getContent( RevisionRecord::RAW );
2710 } catch ( Exception $ex ) {
2711 wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
2712 . $ex->getMessage() );
2713
2714 $content = null;
2715 }
2716
2717 // Archive revisions. In immediate mode, archive all revisions. Otherwise, archive
2718 // one batch of revisions and defer archival of any others to the job queue.
2719 $explictTrxLogged = false;
2720 while ( true ) {
2721 $done = $this->archiveRevisions( $dbw, $id, $suppress );
2722 if ( $done || !$immediate ) {
2723 break;
2724 }
2725 $dbw->endAtomic( __METHOD__ );
2726 if ( $dbw->explicitTrxActive() ) {
2727 // Explict transactions may never happen here in practice. Log to be sure.
2728 if ( !$explictTrxLogged ) {
2729 $explictTrxLogged = true;
2730 LoggerFactory::getInstance( 'wfDebug' )->debug(
2731 'explicit transaction active in ' . __METHOD__ . ' while deleting {title}', [
2732 'title' => $this->getTitle()->getText(),
2733 ] );
2734 }
2735 continue;
2736 }
2737 if ( $dbw->trxLevel() ) {
2738 $dbw->commit();
2739 }
2740 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
2741 $lbFactory->waitForReplication();
2742 $dbw->startAtomic( __METHOD__ );
2743 }
2744
2745 // If done archiving, also delete the article.
2746 if ( !$done ) {
2747 $dbw->endAtomic( __METHOD__ );
2748
2749 $jobParams = [
2750 'namespace' => $this->getTitle()->getNamespace(),
2751 'title' => $this->getTitle()->getDBkey(),
2752 'wikiPageId' => $id,
2753 'requestId' => $webRequestId ?? WebRequest::getRequestId(),
2754 'reason' => $reason,
2755 'suppress' => $suppress,
2756 'userId' => $deleter->getId(),
2757 'tags' => json_encode( $tags ),
2758 'logsubtype' => $logsubtype,
2759 ];
2760
2761 $job = new DeletePageJob( $jobParams );
2762 JobQueueGroup::singleton()->push( $job );
2763
2764 $status->warning( 'delete-scheduled',
2765 wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) );
2766 } else {
2767 // Get archivedRevisionCount by db query, because there's no better alternative.
2768 // Jobs cannot pass a count of archived revisions to the next job, because additional
2769 // deletion operations can be started while the first is running. Jobs from each
2770 // gracefully interleave, but would not know about each other's count. Deduplication
2771 // in the job queue to avoid simultaneous deletion operations would add overhead.
2772 // Number of archived revisions cannot be known beforehand, because edits can be made
2773 // while deletion operations are being processed, changing the number of archivals.
2774 $archivedRevisionCount = (int)$dbw->selectField(
2775 'archive', 'COUNT(*)',
2776 [
2777 'ar_namespace' => $this->getTitle()->getNamespace(),
2778 'ar_title' => $this->getTitle()->getDBkey(),
2779 'ar_page_id' => $id
2780 ], __METHOD__
2781 );
2782
2783 // Clone the title and wikiPage, so we have the information we need when
2784 // we log and run the ArticleDeleteComplete hook.
2785 $logTitle = clone $this->mTitle;
2786 $wikiPageBeforeDelete = clone $this;
2787
2788 // Now that it's safely backed up, delete it
2789 $dbw->delete( 'page', [ 'page_id' => $id ], __METHOD__ );
2790
2791 // Log the deletion, if the page was suppressed, put it in the suppression log instead
2792 $logtype = $suppress ? 'suppress' : 'delete';
2793
2794 $logEntry = new ManualLogEntry( $logtype, $logsubtype );
2795 $logEntry->setPerformer( $deleter );
2796 $logEntry->setTarget( $logTitle );
2797 $logEntry->setComment( $reason );
2798 $logEntry->addTags( $tags );
2799 $logid = $logEntry->insert();
2800
2801 $dbw->onTransactionPreCommitOrIdle(
2802 function () use ( $logEntry, $logid ) {
2803 // T58776: avoid deadlocks (especially from FileDeleteForm)
2804 $logEntry->publish( $logid );
2805 },
2806 __METHOD__
2807 );
2808
2809 $dbw->endAtomic( __METHOD__ );
2810
2811 $this->doDeleteUpdates( $id, $content, $revision, $deleter );
2812
2813 Hooks::run( 'ArticleDeleteComplete', [
2814 &$wikiPageBeforeDelete,
2815 &$deleter,
2816 $reason,
2817 $id,
2818 $content,
2819 $logEntry,
2820 $archivedRevisionCount
2821 ] );
2822 $status->value = $logid;
2823
2824 // Show log excerpt on 404 pages rather than just a link
2825 $dbCache = ObjectCache::getInstance( 'db-replicated' );
2826 $key = $dbCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
2827 $dbCache->set( $key, 1, $dbCache::TTL_DAY );
2828 }
2829
2830 return $status;
2831 }
2832
2842 protected function archiveRevisions( $dbw, $id, $suppress ) {
2845
2846 // Given the lock above, we can be confident in the title and page ID values
2847 $namespace = $this->getTitle()->getNamespace();
2848 $dbKey = $this->getTitle()->getDBkey();
2849
2850 $commentStore = CommentStore::getStore();
2851 $actorMigration = ActorMigration::newMigration();
2852
2854 $bitfield = false;
2855
2856 // Bitfields to further suppress the content
2857 if ( $suppress ) {
2858 $bitfield = RevisionRecord::SUPPRESSED_ALL;
2859 $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] );
2860 }
2861
2862 // For now, shunt the revision data into the archive table.
2863 // Text is *not* removed from the text table; bulk storage
2864 // is left intact to avoid breaking block-compression or
2865 // immutable storage schemes.
2866 // In the future, we may keep revisions and mark them with
2867 // the rev_deleted field, which is reserved for this purpose.
2868
2869 // Lock rows in `revision` and its temp tables, but not any others.
2870 // Note array_intersect() preserves keys from the first arg, and we're
2871 // assuming $revQuery has `revision` primary and isn't using subtables
2872 // for anything we care about.
2873 $dbw->lockForUpdate(
2874 array_intersect(
2875 $revQuery['tables'],
2876 [ 'revision', 'revision_comment_temp', 'revision_actor_temp' ]
2877 ),
2878 [ 'rev_page' => $id ],
2879 __METHOD__,
2880 [],
2881 $revQuery['joins']
2882 );
2883
2884 // If SCHEMA_COMPAT_WRITE_OLD is set, also select all extra fields we still write,
2885 // so we can copy it to the archive table.
2886 // We know the fields exist, otherwise SCHEMA_COMPAT_WRITE_OLD could not function.
2888 $revQuery['fields'][] = 'rev_text_id';
2889
2890 if ( $wgContentHandlerUseDB ) {
2891 $revQuery['fields'][] = 'rev_content_model';
2892 $revQuery['fields'][] = 'rev_content_format';
2893 }
2894 }
2895
2896 // Get as many of the page revisions as we are allowed to. The +1 lets us recognize the
2897 // unusual case where there were exactly $wgDeleteRevisionBatchSize revisions remaining.
2898 $res = $dbw->select(
2899 $revQuery['tables'],
2900 $revQuery['fields'],
2901 [ 'rev_page' => $id ],
2902 __METHOD__,
2903 [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => $wgDeleteRevisionsBatchSize + 1 ],
2904 $revQuery['joins']
2905 );
2906
2907 // Build their equivalent archive rows
2908 $rowsInsert = [];
2909 $revids = [];
2910
2912 $ipRevIds = [];
2913
2914 $done = true;
2915 foreach ( $res as $row ) {
2916 if ( count( $revids ) >= $wgDeleteRevisionsBatchSize ) {
2917 $done = false;
2918 break;
2919 }
2920
2921 $comment = $commentStore->getComment( 'rev_comment', $row );
2922 $user = User::newFromAnyId( $row->rev_user, $row->rev_user_text, $row->rev_actor );
2923 $rowInsert = [
2924 'ar_namespace' => $namespace,
2925 'ar_title' => $dbKey,
2926 'ar_timestamp' => $row->rev_timestamp,
2927 'ar_minor_edit' => $row->rev_minor_edit,
2928 'ar_rev_id' => $row->rev_id,
2929 'ar_parent_id' => $row->rev_parent_id,
2938 'ar_len' => $row->rev_len,
2939 'ar_page_id' => $id,
2940 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted,
2941 'ar_sha1' => $row->rev_sha1,
2942 ] + $commentStore->insert( $dbw, 'ar_comment', $comment )
2943 + $actorMigration->getInsertValues( $dbw, 'ar_user', $user );
2944
2946 $rowInsert['ar_text_id'] = $row->rev_text_id;
2947
2948 if ( $wgContentHandlerUseDB ) {
2949 $rowInsert['ar_content_model'] = $row->rev_content_model;
2950 $rowInsert['ar_content_format'] = $row->rev_content_format;
2951 }
2952 }
2953
2954 $rowsInsert[] = $rowInsert;
2955 $revids[] = $row->rev_id;
2956
2957 // Keep track of IP edits, so that the corresponding rows can
2958 // be deleted in the ip_changes table.
2959 if ( (int)$row->rev_user === 0 && IP::isValid( $row->rev_user_text ) ) {
2960 $ipRevIds[] = $row->rev_id;
2961 }
2962 }
2963
2964 // This conditional is just a sanity check
2965 if ( count( $revids ) > 0 ) {
2966 // Copy them into the archive table
2967 $dbw->insert( 'archive', $rowsInsert, __METHOD__ );
2968
2969 $dbw->delete( 'revision', [ 'rev_id' => $revids ], __METHOD__ );
2970 $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ );
2971 $dbw->delete( 'revision_actor_temp', [ 'revactor_rev' => $revids ], __METHOD__ );
2972
2973 // Also delete records from ip_changes as applicable.
2974 if ( count( $ipRevIds ) > 0 ) {
2975 $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ );
2976 }
2977 }
2978
2979 return $done;
2980 }
2981
2988 public function lockAndGetLatest() {
2989 return (int)wfGetDB( DB_MASTER )->selectField(
2990 'page',
2991 'page_latest',
2992 [
2993 'page_id' => $this->getId(),
2994 // Typically page_id is enough, but some code might try to do
2995 // updates assuming the title is the same, so verify that
2996 'page_namespace' => $this->getTitle()->getNamespace(),
2997 'page_title' => $this->getTitle()->getDBkey()
2998 ],
2999 __METHOD__,
3000 [ 'FOR UPDATE' ]
3001 );
3002 }
3003
3016 public function doDeleteUpdates(
3017 $id, Content $content = null, Revision $revision = null, User $user = null
3018 ) {
3019 if ( $id !== $this->getId() ) {
3020 throw new InvalidArgumentException( 'Mismatching page ID' );
3021 }
3022
3023 try {
3024 $countable = $this->isCountable();
3025 } catch ( Exception $ex ) {
3026 // fallback for deleting broken pages for which we cannot load the content for
3027 // some reason. Note that doDeleteArticleReal() already logged this problem.
3028 $countable = false;
3029 }
3030
3031 // Update site status
3032 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
3033 [ 'edits' => 1, 'articles' => -$countable, 'pages' => -1 ]
3034 ) );
3035
3036 // Delete pagelinks, update secondary indexes, etc
3037 $updates = $this->getDeletionUpdates(
3038 $revision ? $revision->getRevisionRecord() : $content
3039 );
3040 foreach ( $updates as $update ) {
3041 DeferredUpdates::addUpdate( $update );
3042 }
3043
3044 $causeAgent = $user ? $user->getName() : 'unknown';
3045 // Reparse any pages transcluding this page
3046 LinksUpdate::queueRecursiveJobsForTable(
3047 $this->mTitle, 'templatelinks', 'delete-page', $causeAgent );
3048 // Reparse any pages including this image
3049 if ( $this->mTitle->getNamespace() == NS_FILE ) {
3050 LinksUpdate::queueRecursiveJobsForTable(
3051 $this->mTitle, 'imagelinks', 'delete-page', $causeAgent );
3052 }
3053
3054 // Clear caches
3055 self::onArticleDelete( $this->mTitle );
3056 ResourceLoaderWikiModule::invalidateModuleCache(
3057 $this->mTitle,
3058 $revision,
3059 null,
3060 WikiMap::getCurrentWikiDbDomain()->getId()
3061 );
3062
3063 // Reset this object and the Title object
3064 $this->loadFromRow( false, self::READ_LATEST );
3065
3066 // Search engine
3067 DeferredUpdates::addUpdate( new SearchUpdate( $id, $this->mTitle ) );
3068 }
3069
3099 public function doRollback(
3100 $fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags = null
3101 ) {
3102 $resultDetails = null;
3103
3104 // Check permissions
3105 $editErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $user );
3106 $rollbackErrors = $this->mTitle->getUserPermissionsErrors( 'rollback', $user );
3107 $errors = array_merge( $editErrors, wfArrayDiff2( $rollbackErrors, $editErrors ) );
3108
3109 if ( !$user->matchEditToken( $token, 'rollback' ) ) {
3110 $errors[] = [ 'sessionfailure' ];
3111 }
3112
3113 if ( $user->pingLimiter( 'rollback' ) || $user->pingLimiter() ) {
3114 $errors[] = [ 'actionthrottledtext' ];
3115 }
3116
3117 // If there were errors, bail out now
3118 if ( !empty( $errors ) ) {
3119 return $errors;
3120 }
3121
3122 return $this->commitRollback( $fromP, $summary, $bot, $resultDetails, $user, $tags );
3123 }
3124
3145 public function commitRollback( $fromP, $summary, $bot,
3146 &$resultDetails, User $guser, $tags = null
3147 ) {
3149
3150 $dbw = wfGetDB( DB_MASTER );
3151
3152 if ( wfReadOnly() ) {
3153 return [ [ 'readonlytext' ] ];
3154 }
3155
3156 // Begin revision creation cycle by creating a PageUpdater.
3157 // If the page is changed concurrently after grabParentRevision(), the rollback will fail.
3158 $updater = $this->newPageUpdater( $guser );
3159 $current = $updater->grabParentRevision();
3160
3161 if ( is_null( $current ) ) {
3162 // Something wrong... no page?
3163 return [ [ 'notanarticle' ] ];
3164 }
3165
3166 $currentEditorForPublic = $current->getUser( RevisionRecord::FOR_PUBLIC );
3167 $legacyCurrent = new Revision( $current );
3168 $from = str_replace( '_', ' ', $fromP );
3169
3170 // User name given should match up with the top revision.
3171 // If the revision's user is not visible, then $from should be empty.
3172 if ( $from !== ( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' ) ) {
3173 $resultDetails = [ 'current' => $legacyCurrent ];
3174 return [ [ 'alreadyrolled',
3175 htmlspecialchars( $this->mTitle->getPrefixedText() ),
3176 htmlspecialchars( $fromP ),
3177 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
3178 ] ];
3179 }
3180
3181 // Get the last edit not by this person...
3182 // Note: these may not be public values
3183 $actorWhere = ActorMigration::newMigration()->getWhere(
3184 $dbw,
3185 'rev_user',
3186 $current->getUser( RevisionRecord::RAW )
3187 );
3188
3189 $s = $dbw->selectRow(
3190 [ 'revision' ] + $actorWhere['tables'],
3191 [ 'rev_id', 'rev_timestamp', 'rev_deleted' ],
3192 [
3193 'rev_page' => $current->getPageId(),
3194 'NOT(' . $actorWhere['conds'] . ')',
3195 ],
3196 __METHOD__,
3197 [
3198 'USE INDEX' => [ 'revision' => 'page_timestamp' ],
3199 'ORDER BY' => 'rev_timestamp DESC'
3200 ],
3201 $actorWhere['joins']
3202 );
3203 if ( $s === false ) {
3204 // No one else ever edited this page
3205 return [ [ 'cantrollback' ] ];
3206 } elseif ( $s->rev_deleted & RevisionRecord::DELETED_TEXT
3207 || $s->rev_deleted & RevisionRecord::DELETED_USER
3208 ) {
3209 // Only admins can see this text
3210 return [ [ 'notvisiblerev' ] ];
3211 }
3212
3213 // Generate the edit summary if necessary
3214 $target = $this->getRevisionStore()->getRevisionById(
3215 $s->rev_id,
3216 RevisionStore::READ_LATEST
3217 );
3218 if ( empty( $summary ) ) {
3219 if ( !$currentEditorForPublic ) { // no public user name
3220 $summary = wfMessage( 'revertpage-nouser' );
3221 } elseif ( $wgDisableAnonTalk && $current->getUser() === 0 ) {
3222 $summary = wfMessage( 'revertpage-anon' );
3223 } else {
3224 $summary = wfMessage( 'revertpage' );
3225 }
3226 }
3227 $legacyTarget = new Revision( $target );
3228 $targetEditorForPublic = $target->getUser( RevisionRecord::FOR_PUBLIC );
3229
3230 // Allow the custom summary to use the same args as the default message
3231 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
3232 $args = [
3233 $targetEditorForPublic ? $targetEditorForPublic->getName() : null,
3234 $currentEditorForPublic ? $currentEditorForPublic->getName() : null,
3235 $s->rev_id,
3236 $contLang->timeanddate( wfTimestamp( TS_MW, $s->rev_timestamp ) ),
3237 $current->getId(),
3238 $contLang->timeanddate( $current->getTimestamp() )
3239 ];
3240 if ( $summary instanceof Message ) {
3241 $summary = $summary->params( $args )->inContentLanguage()->text();
3242 } else {
3243 $summary = wfMsgReplaceArgs( $summary, $args );
3244 }
3245
3246 // Trim spaces on user supplied text
3247 $summary = trim( $summary );
3248
3249 // Save
3250 $flags = EDIT_UPDATE | EDIT_INTERNAL;
3251
3252 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
3253 if ( $permissionManager->userHasRight( $guser, 'minoredit' ) ) {
3254 $flags |= EDIT_MINOR;
3255 }
3256
3257 if ( $bot && ( $permissionManager->userHasAnyRight( $guser, 'markbotedits', 'bot' ) ) ) {
3258 $flags |= EDIT_FORCE_BOT;
3259 }
3260
3261 // TODO: MCR: also log model changes in other slots, in case that becomes possible!
3262 $currentContent = $current->getContent( SlotRecord::MAIN );
3263 $targetContent = $target->getContent( SlotRecord::MAIN );
3264 $changingContentModel = $targetContent->getModel() !== $currentContent->getModel();
3265
3266 if ( in_array( 'mw-rollback', ChangeTags::getSoftwareTags() ) ) {
3267 $tags[] = 'mw-rollback';
3268 }
3269
3270 // Build rollback revision:
3271 // Restore old content
3272 // TODO: MCR: test this once we can store multiple slots
3273 foreach ( $target->getSlots()->getSlots() as $slot ) {
3274 $updater->inheritSlot( $slot );
3275 }
3276
3277 // Remove extra slots
3278 // TODO: MCR: test this once we can store multiple slots
3279 foreach ( $current->getSlotRoles() as $role ) {
3280 if ( !$target->hasSlot( $role ) ) {
3281 $updater->removeSlot( $role );
3282 }
3283 }
3284
3285 $updater->setOriginalRevisionId( $target->getId() );
3286 // Do not call setUndidRevisionId(), that causes an extra "mw-undo" tag to be added (T190374)
3287 $updater->addTags( $tags );
3288
3289 // TODO: this logic should not be in the storage layer, it's here for compatibility
3290 // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
3291 // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
3292
3293 if ( $wgUseRCPatrol && $permissionManager->userCan(
3294 'autopatrol', $guser, $this->getTitle()
3295 ) ) {
3296 $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
3297 }
3298
3299 // Actually store the rollback
3300 $rev = $updater->saveRevision(
3301 CommentStoreComment::newUnsavedComment( $summary ),
3302 $flags
3303 );
3304
3305 // Set patrolling and bot flag on the edits, which gets rollbacked.
3306 // This is done even on edit failure to have patrolling in that case (T64157).
3307 $set = [];
3308 if ( $bot && $permissionManager->userHasRight( $guser, 'markbotedits' ) ) {
3309 // Mark all reverted edits as bot
3310 $set['rc_bot'] = 1;
3311 }
3312
3313 if ( $wgUseRCPatrol ) {
3314 // Mark all reverted edits as patrolled
3315 $set['rc_patrolled'] = RecentChange::PRC_AUTOPATROLLED;
3316 }
3317
3318 if ( count( $set ) ) {
3319 $actorWhere = ActorMigration::newMigration()->getWhere(
3320 $dbw,
3321 'rc_user',
3322 $current->getUser( RevisionRecord::RAW ),
3323 false
3324 );
3325 $dbw->update( 'recentchanges', $set,
3326 [ /* WHERE */
3327 'rc_cur_id' => $current->getPageId(),
3328 'rc_timestamp > ' . $dbw->addQuotes( $s->rev_timestamp ),
3329 $actorWhere['conds'], // No tables/joins are needed for rc_user
3330 ],
3331 __METHOD__
3332 );
3333 }
3334
3335 if ( !$updater->wasSuccessful() ) {
3336 return $updater->getStatus()->getErrorsArray();
3337 }
3338
3339 // Report if the edit was not created because it did not change the content.
3340 if ( $updater->isUnchanged() ) {
3341 $resultDetails = [ 'current' => $legacyCurrent ];
3342 return [ [ 'alreadyrolled',
3343 htmlspecialchars( $this->mTitle->getPrefixedText() ),
3344 htmlspecialchars( $fromP ),
3345 htmlspecialchars( $currentEditorForPublic ? $currentEditorForPublic->getName() : '' )
3346 ] ];
3347 }
3348
3349 if ( $changingContentModel ) {
3350 // If the content model changed during the rollback,
3351 // make sure it gets logged to Special:Log/contentmodel
3352 $log = new ManualLogEntry( 'contentmodel', 'change' );
3353 $log->setPerformer( $guser );
3354 $log->setTarget( $this->mTitle );
3355 $log->setComment( $summary );
3356 $log->setParameters( [
3357 '4::oldmodel' => $currentContent->getModel(),
3358 '5::newmodel' => $targetContent->getModel(),
3359 ] );
3360
3361 $logId = $log->insert( $dbw );
3362 $log->publish( $logId );
3363 }
3364
3365 $revId = $rev->getId();
3366
3367 Hooks::run( 'ArticleRollbackComplete', [ $this, $guser, $legacyTarget, $legacyCurrent ] );
3368
3369 $resultDetails = [
3370 'summary' => $summary,
3371 'current' => $legacyCurrent,
3372 'target' => $legacyTarget,
3373 'newid' => $revId,
3374 'tags' => $tags
3375 ];
3376
3377 // TODO: make this return a Status object and wrap $resultDetails in that.
3378 return [];
3379 }
3380
3392 public static function onArticleCreate( Title $title ) {
3393 // TODO: move this into a PageEventEmitter service
3394
3395 // Update existence markers on article/talk tabs...
3396 $other = $title->getOtherPage();
3397
3398 $other->purgeSquid();
3399
3400 $title->touchLinks();
3401 $title->purgeSquid();
3402 $title->deleteTitleProtection();
3403
3404 MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3405
3406 // Invalidate caches of articles which include this page
3408 $title,
3409 'templatelinks',
3410 [ 'causeAction' => 'page-create' ]
3411 );
3412 JobQueueGroup::singleton()->lazyPush( $job );
3413
3414 if ( $title->getNamespace() == NS_CATEGORY ) {
3415 // Load the Category object, which will schedule a job to create
3416 // the category table row if necessary. Checking a replica DB is ok
3417 // here, in the worst case it'll run an unnecessary recount job on
3418 // a category that probably doesn't have many members.
3419 Category::newFromTitle( $title )->getID();
3420 }
3421 }
3422
3428 public static function onArticleDelete( Title $title ) {
3429 // TODO: move this into a PageEventEmitter service
3430
3431 // Update existence markers on article/talk tabs...
3432 // Clear Backlink cache first so that purge jobs use more up-to-date backlink information
3433 BacklinkCache::get( $title )->clear();
3434 $other = $title->getOtherPage();
3435
3436 $other->purgeSquid();
3437
3438 $title->touchLinks();
3439 $title->purgeSquid();
3440
3441 MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3442
3443 // File cache
3446
3447 // Messages
3448 if ( $title->getNamespace() == NS_MEDIAWIKI ) {
3449 MessageCache::singleton()->updateMessageOverride( $title, null );
3450 }
3451
3452 // Images
3453 if ( $title->getNamespace() == NS_FILE ) {
3455 $title,
3456 'imagelinks',
3457 [ 'causeAction' => 'page-delete' ]
3458 );
3459 JobQueueGroup::singleton()->lazyPush( $job );
3460 }
3461
3462 // User talk pages
3463 if ( $title->getNamespace() == NS_USER_TALK ) {
3464 $user = User::newFromName( $title->getText(), false );
3465 if ( $user ) {
3466 $user->setNewtalk( false );
3467 }
3468 }
3469
3470 // Image redirects
3471 RepoGroup::singleton()->getLocalRepo()->invalidateImageRedirect( $title );
3472
3473 // Purge cross-wiki cache entities referencing this page
3474 self::purgeInterwikiCheckKey( $title );
3475 }
3476
3485 public static function onArticleEdit(
3486 Title $title,
3487 Revision $revision = null,
3488 $slotsChanged = null
3489 ) {
3490 // TODO: move this into a PageEventEmitter service
3491 $jobs = [];
3492 if ( $slotsChanged === null || in_array( SlotRecord::MAIN, $slotsChanged ) ) {
3493 // Invalidate caches of articles which include this page.
3494 // Only for the main slot, because only the main slot is transcluded.
3495 // TODO: MCR: not true for TemplateStyles! [SlotHandler]
3497 $title,
3498 'templatelinks',
3499 [ 'causeAction' => 'page-edit' ]
3500 );
3501 }
3502 // Invalidate the caches of all pages which redirect here
3504 $title,
3505 'redirect',
3506 [ 'causeAction' => 'page-edit' ]
3507 );
3508 JobQueueGroup::singleton()->lazyPush( $jobs );
3509
3510 MediaWikiServices::getInstance()->getLinkCache()->invalidateTitle( $title );
3511
3512 // Purge CDN for this page only
3513 $title->purgeSquid();
3514 // Clear file cache for this page only
3516
3517 // Purge ?action=info cache
3518 $revid = $revision ? $revision->getId() : null;
3519 DeferredUpdates::addCallableUpdate( function () use ( $title, $revid ) {
3521 } );
3522
3523 // Purge cross-wiki cache entities referencing this page
3524 self::purgeInterwikiCheckKey( $title );
3525 }
3526
3534 private static function purgeInterwikiCheckKey( Title $title ) {
3536
3538 return; // @todo: perhaps this wiki is only used as a *source* for content?
3539 }
3540
3541 DeferredUpdates::addCallableUpdate( function () use ( $title ) {
3542 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
3543 $cache->resetCheckKey(
3544 // Do not include the namespace since there can be multiple aliases to it
3545 // due to different namespace text definitions on different wikis. This only
3546 // means that some cache invalidations happen that are not strictly needed.
3547 $cache->makeGlobalKey(
3548 'interwiki-page',
3549 WikiMap::getCurrentWikiDbDomain()->getId(),
3550 $title->getDBkey()
3551 )
3552 );
3553 } );
3554 }
3555
3562 public function getCategories() {
3563 $id = $this->getId();
3564 if ( $id == 0 ) {
3565 return TitleArray::newFromResult( new FakeResultWrapper( [] ) );
3566 }
3567
3568 $dbr = wfGetDB( DB_REPLICA );
3569 $res = $dbr->select( 'categorylinks',
3570 [ 'cl_to AS page_title, ' . NS_CATEGORY . ' AS page_namespace' ],
3571 // Have to do that since Database::fieldNamesWithAlias treats numeric indexes
3572 // as not being aliases, and NS_CATEGORY is numeric
3573 [ 'cl_from' => $id ],
3574 __METHOD__ );
3575
3577 }
3578
3585 public function getHiddenCategories() {
3586 $result = [];
3587 $id = $this->getId();
3588
3589 if ( $id == 0 ) {
3590 return [];
3591 }
3592
3593 $dbr = wfGetDB( DB_REPLICA );
3594 $res = $dbr->select( [ 'categorylinks', 'page_props', 'page' ],
3595 [ 'cl_to' ],
3596 [ 'cl_from' => $id, 'pp_page=page_id', 'pp_propname' => 'hiddencat',
3597 'page_namespace' => NS_CATEGORY, 'page_title=cl_to' ],
3598 __METHOD__ );
3599
3600 if ( $res !== false ) {
3601 foreach ( $res as $row ) {
3602 $result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to );
3603 }
3604 }
3605
3606 return $result;
3607 }
3608
3616 public function getAutoDeleteReason( &$hasHistory ) {
3617 return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory );
3618 }
3619
3630 public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
3631 $id = $id ?: $this->getId();
3632 $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
3633 getCategoryLinkType( $this->getTitle()->getNamespace() );
3634
3635 $addFields = [ 'cat_pages = cat_pages + 1' ];
3636 $removeFields = [ 'cat_pages = cat_pages - 1' ];
3637 if ( $type !== 'page' ) {
3638 $addFields[] = "cat_{$type}s = cat_{$type}s + 1";
3639 $removeFields[] = "cat_{$type}s = cat_{$type}s - 1";
3640 }
3641
3642 $dbw = wfGetDB( DB_MASTER );
3643
3644 if ( count( $added ) ) {
3645 $existingAdded = $dbw->selectFieldValues(
3646 'category',
3647 'cat_title',
3648 [ 'cat_title' => $added ],
3649 __METHOD__
3650 );
3651
3652 // For category rows that already exist, do a plain
3653 // UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE
3654 // to avoid creating gaps in the cat_id sequence.
3655 if ( count( $existingAdded ) ) {
3656 $dbw->update(
3657 'category',
3658 $addFields,
3659 [ 'cat_title' => $existingAdded ],
3660 __METHOD__
3661 );
3662 }
3663
3664 $missingAdded = array_diff( $added, $existingAdded );
3665 if ( count( $missingAdded ) ) {
3666 $insertRows = [];
3667 foreach ( $missingAdded as $cat ) {
3668 $insertRows[] = [
3669 'cat_title' => $cat,
3670 'cat_pages' => 1,
3671 'cat_subcats' => ( $type === 'subcat' ) ? 1 : 0,
3672 'cat_files' => ( $type === 'file' ) ? 1 : 0,
3673 ];
3674 }
3675 $dbw->upsert(
3676 'category',
3677 $insertRows,
3678 [ 'cat_title' ],
3679 $addFields,
3680 __METHOD__
3681 );
3682 }
3683 }
3684
3685 if ( count( $deleted ) ) {
3686 $dbw->update(
3687 'category',
3688 $removeFields,
3689 [ 'cat_title' => $deleted ],
3690 __METHOD__
3691 );
3692 }
3693
3694 foreach ( $added as $catName ) {
3695 $cat = Category::newFromName( $catName );
3696 Hooks::run( 'CategoryAfterPageAdded', [ $cat, $this ] );
3697 }
3698
3699 foreach ( $deleted as $catName ) {
3700 $cat = Category::newFromName( $catName );
3701 Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] );
3702 // Refresh counts on categories that should be empty now (after commit, T166757)
3703 DeferredUpdates::addCallableUpdate( function () use ( $cat ) {
3704 $cat->refreshCountsIfEmpty();
3705 } );
3706 }
3707 }
3708
3715 public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) {
3716 if ( wfReadOnly() ) {
3717 return;
3718 }
3719
3720 if ( !Hooks::run( 'OpportunisticLinksUpdate',
3721 [ $this, $this->mTitle, $parserOutput ]
3722 ) ) {
3723 return;
3724 }
3725
3726 $config = RequestContext::getMain()->getConfig();
3727
3728 $params = [
3729 'isOpportunistic' => true,
3730 'rootJobTimestamp' => $parserOutput->getCacheTime()
3731 ];
3732
3733 if ( $this->mTitle->areRestrictionsCascading() ) {
3734 // If the page is cascade protecting, the links should really be up-to-date
3735 JobQueueGroup::singleton()->lazyPush(
3736 RefreshLinksJob::newPrioritized( $this->mTitle, $params )
3737 );
3738 } elseif ( !$config->get( 'MiserMode' ) && $parserOutput->hasDynamicContent() ) {
3739 // Assume the output contains "dynamic" time/random based magic words.
3740 // Only update pages that expired due to dynamic content and NOT due to edits
3741 // to referenced templates/files. When the cache expires due to dynamic content,
3742 // page_touched is unchanged. We want to avoid triggering redundant jobs due to
3743 // views of pages that were just purged via HTMLCacheUpdateJob. In that case, the
3744 // template/file edit already triggered recursive RefreshLinksJob jobs.
3745 if ( $this->getLinksTimestamp() > $this->getTouched() ) {
3746 // If a page is uncacheable, do not keep spamming a job for it.
3747 // Although it would be de-duplicated, it would still waste I/O.
3748 $cache = ObjectCache::getLocalClusterInstance();
3749 $key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() );
3750 $ttl = max( $parserOutput->getCacheExpiry(), 3600 );
3751 if ( $cache->add( $key, time(), $ttl ) ) {
3752 JobQueueGroup::singleton()->lazyPush(
3753 RefreshLinksJob::newDynamic( $this->mTitle, $params )
3754 );
3755 }
3756 }
3757 }
3758 }
3759
3769 public function getDeletionUpdates( $rev = null ) {
3770 if ( !$rev ) {
3771 wfDeprecated( __METHOD__ . ' without a RevisionRecord', '1.32' );
3772
3773 try {
3774 $rev = $this->getRevisionRecord();
3775 } catch ( Exception $ex ) {
3776 // If we can't load the content, something is wrong. Perhaps that's why
3777 // the user is trying to delete the page, so let's not fail in that case.
3778 // Note that doDeleteArticleReal() will already have logged an issue with
3779 // loading the content.
3780 wfDebug( __METHOD__ . ' failed to load current revision of page ' . $this->getId() );
3781 }
3782 }
3783
3784 if ( !$rev ) {
3785 $slotContent = [];
3786 } elseif ( $rev instanceof Content ) {
3787 wfDeprecated( __METHOD__ . ' with a Content object instead of a RevisionRecord', '1.32' );
3788
3789 $slotContent = [ SlotRecord::MAIN => $rev ];
3790 } else {
3791 $slotContent = array_map( function ( SlotRecord $slot ) {
3792 return $slot->getContent( RevisionRecord::RAW );
3793 }, $rev->getSlots()->getSlots() );
3794 }
3795
3796 $allUpdates = [ new LinksDeletionUpdate( $this ) ];
3797
3798 // NOTE: once Content::getDeletionUpdates() is removed, we only need to content
3799 // model here, not the content object!
3800 // TODO: consolidate with similar logic in DerivedPageDataUpdater::getSecondaryDataUpdates()
3802 foreach ( $slotContent as $role => $content ) {
3803 $handler = $content->getContentHandler();
3804
3805 $updates = $handler->getDeletionUpdates(
3806 $this->getTitle(),
3807 $role
3808 );
3809 $allUpdates = array_merge( $allUpdates, $updates );
3810
3811 // TODO: remove B/C hack in 1.32!
3812 $legacyUpdates = $content->getDeletionUpdates( $this );
3813
3814 // HACK: filter out redundant and incomplete LinksDeletionUpdate
3815 $legacyUpdates = array_filter( $legacyUpdates, function ( $update ) {
3816 return !( $update instanceof LinksDeletionUpdate );
3817 } );
3818
3819 $allUpdates = array_merge( $allUpdates, $legacyUpdates );
3820 }
3821
3822 Hooks::run( 'PageDeletionDataUpdates', [ $this->getTitle(), $rev, &$allUpdates ] );
3823
3824 // TODO: hard deprecate old hook in 1.33
3825 Hooks::run( 'WikiPageDeletionUpdates', [ $this, $content, &$allUpdates ] );
3826 return $allUpdates;
3827 }
3828
3836 public function isLocal() {
3837 return true;
3838 }
3839
3849 public function getWikiDisplayName() {
3850 global $wgSitename;
3851 return $wgSitename;
3852 }
3853
3862 public function getSourceURL() {
3863 return $this->getTitle()->getCanonicalURL();
3864 }
3865
3872 $linkCache = MediaWikiServices::getInstance()->getLinkCache();
3873
3874 return $linkCache->getMutableCacheKeys( $cache, $this->getTitle() );
3875 }
3876
3877}
getUser()
bool $wgPageLanguageUseDB
Enable page language feature Allows setting page language in database.
$wgDeleteRevisionsBatchSize
Page deletions with > this number of revisions will use the job queue.
$wgCascadingRestrictionLevels
Restriction levels that can be used with cascading protection.
int $wgMultiContentRevisionSchemaMigrationStage
RevisionStore table schema migration stage (content, slots, content_models & slot_roles tables).
$wgDisableAnonTalk
Disable links to talk pages of anonymous users (IPs) in listings on special pages like page history,...
$wgUseAutomaticEditSummaries
If user doesn't specify any edit summary when making a an edit, MediaWiki will try to automatically c...
$wgSitename
Name of the site.
$wgPageCreationLog
Maintain a log of page creations at Special:Log/create?
$wgUseRCPatrol
Use RC Patrolling to check for vandalism (from recent changes and watchlists) New pages and new files...
$wgUseNPPatrol
Use new page patrolling to check new pages on Special:Newpages.
$wgArticleCountMethod
Method used to determine if a page in a content namespace should be counted as a valid article.
$wgAjaxEditStash
Have clients send edits to be prepared when filling in edit summaries.
$wgEnableScaryTranscluding
Enable interwiki transcluding.
$wgRCWatchCategoryMembership
Treat category membership changes as a RecentChange.
$wgContentHandlerUseDB
Set to false to disable use of the database fields introduced by the ContentHandler facility.
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfRandom()
Get a random decimal value in the domain of [0, 1), in a way not likely to give duplicate values for ...
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
wfIncrStats( $key, $count=1)
Increment a statistics counter.
wfReadOnly()
Check whether the wiki is in read-only mode.
wfGetDB( $db, $groups=[], $wiki=false)
Get a Database object.
wfArrayDiff2( $a, $b)
Like array_diff( $a, $b ) except that it works with two-dimensional arrays.
wfReadOnlyReason()
Check if the site is in read-only mode and return the message if so.
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
wfMsgReplaceArgs( $message, $args)
Replace message parameter keys on the given formatted output.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking,...
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Throws a warning that $function is deprecated.
if( $line===false) $args
Definition cdb.php:64
static get(Title $title)
Create a new BacklinkCache or reuse any existing one.
getCacheExpiry()
Returns the number of seconds after which this object should expire.
Handles purging the appropriate CDN objects given a list of URLs or Title instances.
static getSoftwareTags( $all=false)
Loads defined core tags, checks for invalid types (if not array), and filters for supported and enabl...
CommentStoreComment represents a comment stored by CommentStore.
Class DeletePageJob.
static newForBacklinks(Title $title, $table, $params=[])
static clearFileCache(Title $title)
Clear the file caches for a page for all actions.
static invalidateCache(Title $title, $revid=null)
Clear the info cache for a given Title.
Update object handling the cleanup of links tables after a page was deleted.
MediaWiki exception.
Class for creating new log entries and inserting them into the database.
Represents information returned by WikiPage::prepareContentForEdit()
PSR-3 logger instance factory.
MediaWikiServices is the service locator for the application scope of MediaWiki.
Page revision base class.
The RevisionRenderer service provides access to rendered output for revisions.
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
getContent()
Returns the Content of the given slot.
A registry service for SlotRoleHandlers, used to define which slot roles are available on which page.
A handle for managing updates for derived page data on edit, import, purge, etc.
setRcWatchCategoryMembership( $rcWatchCategoryMembership)
Controller-like object for creating and updating pages by creating new revisions.
Value object representing a modification of revision slots.
The Message class provides methods which fulfil two basic services:
Definition Message.php:162
Set options of the Parser.
getStubThreshold()
Thumb size preferred by the user.
isSafeToCache()
Test whether these options are safe to cache.
hasDynamicContent()
Check whether the cache TTL was lowered due to dynamic content.
static newPrioritized(Title $title, array $params)
static newDynamic(Title $title, array $params)
getContentHandler()
Returns the content handler appropriate for this revision's content model.
Definition Revision.php:791
static newKnownCurrent(IDatabase $db, $pageIdOrTitle, $revId=0)
Load a revision based on a known page ID and current revision ID from the DB.
static newFromPageId( $pageId, $revId=0, $flags=0)
Load either the current, or a specified, revision that's attached to a given page ID.
Definition Revision.php:157
static loadFromTimestamp( $db, $title, $timestamp)
Load the revision for the given title with the given timestamp.
Definition Revision.php:296
static newNullRevision( $dbw, $pageId, $summary, $minor, $user=null)
Create a new null-revision for insertion into a page's history.
static getQueryInfo( $options=[])
Return the tables, fields, and join conditions to be selected to create a new revision object.
Definition Revision.php:315
getTimestamp()
Definition Revision.php:798
getRevisionRecord()
Definition Revision.php:433
static newFromId( $id, $flags=0)
Load a page revision from a given revision ID number.
Definition Revision.php:119
Database independant search index updater.
static newFromResult( $res)
Represents a title within MediaWiki.
Definition Title.php:42
getNamespace()
Get the namespace index, i.e.
Definition Title.php:1037
getFragment()
Get the Title fragment (i.e.
Definition Title.php:1707
getDBkey()
Get the main part with underscores.
Definition Title.php:1013
getInterwiki()
Get the interwiki prefix.
Definition Title.php:923
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:51
getName()
Get the user name, or the IP of an anonymous user.
Definition User.php:2364
static newFromName( $name, $validate='valid')
Static factory method for creation from username.
Definition User.php:518
getId()
Get the user's ID.
Definition User.php:2335
clearNotification(&$title, $oldid=0)
Clear the user's notification timestamp for the given title.
Definition User.php:3802
static newFromId( $id)
Static factory method for creation from a given user ID.
Definition User.php:542
static newFromAnyId( $userId, $userName, $actorId, $dbDomain=false)
Static factory method for creation from an ID, name, and/or actor ID.
Definition User.php:599
static newFromIdentity(UserIdentity $identity)
Returns a User object corresponding to the given UserIdentity.
Definition User.php:574
pingLimiter( $action='edit', $incrBy=1)
Primitive rate limits: enforce maximum actions per time period to put a brake on flooding.
Definition User.php:1954
matchEditToken( $val, $salt='', $request=null, $maxage=null)
Check given value against the token value stored in the session.
Definition User.php:4502
Multi-datacenter aware caching interface.
Special handling for category pages.
Special handling for file pages.
Class representing a MediaWiki article and history.
Definition WikiPage.php:47
static newFromID( $id, $from='fromdb')
Constructor from a page id.
Definition WikiPage.php:180
doUpdateRestrictions(array $limit, array $expiry, &$cascade, $reason, User $user, $tags=null)
Update the article's restriction field, and leave a log entry.
getContributors()
Get a list of users who have edited this article, not including the user who made the most recent rev...
doPurge()
Perform the actions of a page purging.
followRedirect()
Get the Title object or URL this page redirects to.
insertOn( $dbw, $pageId=null)
Insert a new empty page record for this article.
doDeleteArticleBatched( $reason, $suppress, User $deleter, $tags, $logsubtype, $immediate=false, $webRequestId=null)
Back-end article deletion.
updateCategoryCounts(array $added, array $deleted, $id=0)
Update all the appropriate counts in the category table, given that we've added the categories $added...
static purgeInterwikiCheckKey(Title $title)
#-
wasLoadedFrom( $from)
Checks whether the page data was loaded using the given database access mode (or better).
Definition WikiPage.php:534
newDerivedDataUpdater()
static factory(Title $title)
Create a WikiPage object of the appropriate class for the given title.
Definition WikiPage.php:142
pageDataFromTitle( $dbr, $title, $options=[])
Fetch a page record matching the Title object's namespace and title using a sanitized title string.
Definition WikiPage.php:459
getTimestamp()
Definition WikiPage.php:831
checkFlags( $flags)
Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
isLocal()
Whether this content displayed on this page comes from the local database.
getRevision()
Get the latest revision.
Definition WikiPage.php:787
getUndoContent(Revision $undo, Revision $undoafter)
Get the content that needs to be saved in order to undo all revisions between $undo and $undoafter.
getContent( $audience=RevisionRecord::FOR_PUBLIC, User $user=null)
Get the content of the current revision.
Definition WikiPage.php:820
static onArticleEdit(Title $title, Revision $revision=null, $slotsChanged=null)
Purge caches on page update etc.
getLinksTimestamp()
Get the page_links_updated field.
Definition WikiPage.php:703
getMinorEdit()
Returns true if last revision was marked as "minor edit".
Definition WikiPage.php:929
clearCacheFields()
Clear the object cache fields.
Definition WikiPage.php:317
getRevisionRenderer()
Definition WikiPage.php:246
Revision $mLastRevision
Definition WikiPage.php:96
clearPreparedEdit()
Clear the mPreparedEdit cache field, as may be needed by mutable content types.
Definition WikiPage.php:337
getLatest()
Get the page_latest field.
Definition WikiPage.php:714
formatExpiry( $expiry)
getRevisionStore()
Definition WikiPage.php:239
doViewUpdates(User $user, $oldid=0)
Do standard deferred updates after page view (existing or missing page)
updateIfNewerOn( $dbw, $revision)
If the given revision is newer than the currently set page_latest, update the page record.
int $mId
Definition WikiPage.php:81
__clone()
Makes sure that the mTitle object is cloned to the newly cloned WikiPage.
Definition WikiPage.php:130
loadFromRow( $data, $from)
Load the object from a database row.
Definition WikiPage.php:560
archiveRevisions( $dbw, $id, $suppress)
Archives revisions as part of page deletion.
supportsSections()
Returns true if this page's content model supports sections.
getRedirectTarget()
If this page is a redirect, get its target.
Definition WikiPage.php:999
DerivedPageDataUpdater null $derivedDataUpdater
Definition WikiPage.php:116
setTimestamp( $ts)
Set the page timestamp (use only to avoid DB queries)
Definition WikiPage.php:845
protectDescriptionLog(array $limit, array $expiry)
Builds the description to serve as comment for the log entry.
makeParserOptions( $context)
Get parser options suitable for rendering the primary article wikitext.
pageData( $dbr, $conditions, $options=[])
Fetch a page record with the given conditions.
Definition WikiPage.php:426
getSourceURL()
Get the source URL for the content on this page, typically the canonical URL, but may be a remote lin...
getOldestRevision()
Get the Revision object of the oldest revision.
Definition WikiPage.php:725
isBatchedDelete( $safetyMargin=0)
Determines if deletion of this page would be batched (executed over time by the job queue) or not (co...
replaceSectionAtRev( $sectionId, Content $sectionContent, $sectionTitle='', $baseRevId=null)
string $mTouched
Definition WikiPage.php:106
setLastEdit(Revision $revision)
Set the latest revision.
Definition WikiPage.php:778
getDBLoadBalancer()
Definition WikiPage.php:267
updateRevisionOn( $dbw, $revision, $lastRevision=null, $lastRevIsRedirect=null)
Update the page record to point to a newly saved revision.
shouldCheckParserCache(ParserOptions $parserOptions, $oldId)
Should the parser cache be used?
loadLastEdit()
Loads everything except the text This isn't necessary for all uses, so it's only done if needed.
Definition WikiPage.php:738
getDerivedDataUpdater(User $forUser=null, RevisionRecord $forRevision=null, RevisionSlotsUpdate $forUpdate=null, $forEdit=false)
Returns a DerivedPageDataUpdater for use with the given target revision or new content.
getContentModel()
Returns the page's content model id (see the CONTENT_MODEL_XXX constants).
Definition WikiPage.php:652
pageDataFromId( $dbr, $id, $options=[])
Fetch a page record matching the requested ID.
Definition WikiPage.php:473
doEditUpdates(Revision $revision, User $user, array $options=[])
Do standard deferred updates after page edit.
insertRedirectEntry(Title $rt, $oldLatest=null)
Insert or update the redirect table entry for this page to indicate it redirects to $rt.
getCategories()
Returns a list of categories this page is a member of.
doDeleteUpdates( $id, Content $content=null, Revision $revision=null, User $user=null)
Do some database updates after deletion.
string $mTimestamp
Timestamp of the current revision or empty string if not loaded.
Definition WikiPage.php:101
getHiddenCategories()
Returns a list of hidden categories this page is a member of.
doEditContent(Content $content, $summary, $flags=0, $originalRevId=false, User $user=null, $serialFormat=null, $tags=[], $undidRevId=0)
Change an existing article or create a new article.
getDeletionUpdates( $rev=null)
Returns a list of updates to be performed when this page is deleted.
static newFromRow( $row, $from='fromdb')
Constructor from a database row.
Definition WikiPage.php:210
getAutoDeleteReason(&$hasHistory)
Auto-generates a deletion reason.
lockAndGetLatest()
Lock the page row for this title+id and return page_latest (or 0)
int false $mLatest
False means "not loaded".
Definition WikiPage.php:71
static flattenRestrictions( $limit)
Take an array of page restrictions and flatten it to a string suitable for insertion into the page_re...
getParserOutput(ParserOptions $parserOptions, $oldid=null, $forceParse=false)
Get a ParserOutput for the given ParserOptions and revision ID.
getUserText( $audience=RevisionRecord::FOR_PUBLIC, User $user=null)
Definition WikiPage.php:896
updateRedirectOn( $dbw, $redirectTitle, $lastRevIsRedirect=null)
Add row to the redirect table if this is a redirect, remove otherwise.
prepareContentForEdit(Content $content, $revision=null, User $user=null, $serialFormat=null, $useCache=true)
Prepare content which is about to be saved.
hasViewableContent()
Check if this page is something we're going to be showing some sort of sensible content for.
Definition WikiPage.php:625
triggerOpportunisticLinksUpdate(ParserOutput $parserOutput)
Opportunistically enqueue link update jobs given fresh parser output if useful.
getUser( $audience=RevisionRecord::FOR_PUBLIC, User $user=null)
Definition WikiPage.php:858
insertRedirect()
Insert an entry for this page into the redirect table if the content is a redirect.
static hasDifferencesOutsideMainSlot(Revision $a, Revision $b)
Helper method for checking whether two revisions have differences that go beyond the main slot.
getComment( $audience=RevisionRecord::FOR_PUBLIC, User $user=null)
Definition WikiPage.php:915
getActionOverrides()
Definition WikiPage.php:277
updateParserCache(array $options=[])
Update the parser cache.
getCreator( $audience=RevisionRecord::FOR_PUBLIC, User $user=null)
Get the User object of the user who created the page.
Definition WikiPage.php:877
static getQueryInfo()
Return the tables, fields, and join conditions to be selected to create a new page object.
Definition WikiPage.php:387
int $mDataLoadedFrom
One of the READ_* constants.
Definition WikiPage.php:86
bool $mDataLoaded
Definition WikiPage.php:59
static onArticleDelete(Title $title)
Clears caches when article is deleted.
Title $mRedirectTarget
Definition WikiPage.php:91
newPageUpdater(User $user, RevisionSlotsUpdate $forUpdate=null)
Returns a PageUpdater for creating new revisions on this page (or creating the page).
replaceSectionContent( $sectionId, Content $sectionContent, $sectionTitle='', $edittime=null)
static selectFields()
Return the list of revision fields that should be selected to create a new page.
Definition WikiPage.php:348
insertProtectNullRevision( $revCommentMsg, array $limit, array $expiry, $cascade, $reason, $user=null)
Insert a new null revision for this page.
getTitle()
Get the title object of the article.
Definition WikiPage.php:298
isRedirect()
Tests if the article content represents a redirect.
Definition WikiPage.php:634
static onArticleCreate(Title $title)
The onArticle*() functions are supposed to be a kind of hooks which should be called whenever any of ...
doDeleteArticleReal( $reason, $suppress=false, $u1=null, $u2=null, &$error='', User $deleter=null, $tags=[], $logsubtype='delete', $immediate=false)
Back-end article deletion Deletes the article with database consistency, writes logs,...
string $mLinksUpdated
Definition WikiPage.php:111
commitRollback( $fromP, $summary, $bot, &$resultDetails, User $guser, $tags=null)
Backend implementation of doRollback(), please refer there for parameter and return value documentati...
getRedirectURL( $rt)
Get the Title object or URL to use for a redirect.
doSecondaryDataUpdates(array $options=[])
Do secondary data updates (such as updating link tables).
loadPageData( $from='fromdb')
Load the object from a given source by title.
Definition WikiPage.php:489
getSlotRoleRegistry()
Definition WikiPage.php:253
clear()
Clear the object.
Definition WikiPage.php:306
checkTouched()
Loads page_touched and returns a value indicating if it should be used.
Definition WikiPage.php:681
isCountable( $editInfo=false)
Determine whether a page would be suitable for being counted as an article in the site_stats table ba...
Definition WikiPage.php:946
doRollback( $fromP, $summary, $token, $bot, &$resultDetails, User $user, $tags=null)
Roll back the most recent consecutive set of edits to a page from the same user; fails if there are n...
getContentHandler()
Returns the ContentHandler instance to be used to deal with the content of this WikiPage.
Definition WikiPage.php:290
getRevisionRecord()
Get the latest revision.
Definition WikiPage.php:799
getWikiDisplayName()
The display name for the site this content come from.
bool $mIsRedirect
Definition WikiPage.php:65
getParserCache()
Definition WikiPage.php:260
static convertSelectType( $type)
Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants.
Definition WikiPage.php:222
getMutableCacheKeys(WANObjectCache $cache)
protectDescription(array $limit, array $expiry)
Builds the description to serve as comment for the edit.
getTouched()
Get the page_touched field.
Definition WikiPage.php:692
__construct(Title $title)
Constructor and clear the article.
Definition WikiPage.php:122
doDeleteArticle( $reason, $suppress=false, $u1=null, $u2=null, &$error='', User $user=null, $immediate=false)
Same as doDeleteArticleReal(), but returns a simple boolean.
PreparedEdit false $mPreparedEdit
Map of cache fields (text, parser output, ect) for a proposed/new edit.
Definition WikiPage.php:76
Overloads the relevant methods of the real ResultsWrapper so it doesn't go anywhere near an actual da...
Database connection, tracking, load balancing, and transaction manager for a cluster.
const EDIT_FORCE_BOT
Definition Defines.php:145
const EDIT_INTERNAL
Definition Defines.php:148
const EDIT_UPDATE
Definition Defines.php:142
const SCHEMA_COMPAT_WRITE_OLD
Definition Defines.php:273
const NS_FILE
Definition Defines.php:75
const NS_MEDIAWIKI
Definition Defines.php:77
const NS_MEDIA
Definition Defines.php:57
const NS_USER_TALK
Definition Defines.php:72
const EDIT_MINOR
Definition Defines.php:143
const NS_CATEGORY
Definition Defines.php:83
const EDIT_NEW
Definition Defines.php:141
Base interface for content objects.
Definition Content.php:34
Interface for database access objects.
const READ_LOCKING
Constants for object loading bitfield flags (higher => higher QoS)
Interface for type hinting (accepts WikiPage, Article, ImagePage, CategoryPage)
Definition Page.php:29
Basic database interface for live and lazy-loaded relation database handles.
Definition IDatabase.php:38
$context
Definition load.php:45
$cache
Definition mcc.php:33
$source
const DB_REPLICA
Definition defines.php:25
const DB_MASTER
Definition defines.php:26
if(count( $args)< 1) $job
$content
Definition router.php:78