Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
70.18% |
725 / 1033 |
|
39.80% |
39 / 98 |
CRAP | |
0.00% |
0 / 1 |
WikiPage | |
70.25% |
725 / 1032 |
|
39.80% |
39 / 98 |
2892.08 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
__clone | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
convertSelectType | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
getPageUpdaterFactory | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionStore | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDBLoadBalancer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getActionOverrides | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getContentHandler | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getTitle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
clear | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
clearCacheFields | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
clearPreparedEdit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getQueryInfo | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
2 | |||
pageData | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
pageDataFromTitle | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
pageDataFromId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
loadPageData | |
80.00% |
16 / 20 |
|
0.00% |
0 / 1 |
9.65 | |||
wasLoadedFrom | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
loadFromRow | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
5 | |||
getId | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
exists | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
hasViewableContent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isRedirect | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
isNew | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getContentModel | |
68.00% |
17 / 25 |
|
0.00% |
0 / 1 |
3.29 | |||
checkTouched | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getTouched | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getLanguage | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getLinksTimestamp | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getLatest | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
loadLastEdit | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
6.10 | |||
setLastEdit | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionRecord | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getContent | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getTimestamp | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
setTimestamp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUser | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getCreator | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getUserText | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getComment | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getMinorEdit | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
isCountable | |
95.45% |
21 / 22 |
|
0.00% |
0 / 1 |
8 | |||
getRedirectTarget | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
insertRedirectEntry | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
followRedirect | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRedirectURL | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
56 | |||
getContributors | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
6 | |||
shouldCheckParserCache | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
30 | |||
getParserOutput | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
6.02 | |||
doViewUpdates | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
doPurge | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
insertOn | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
4 | |||
updateRevisionOn | |
97.56% |
40 / 41 |
|
0.00% |
0 / 1 |
8 | |||
hasDifferencesOutsideMainSlot | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
supportsSections | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
replaceSectionContent | |
25.00% |
3 / 12 |
|
0.00% |
0 / 1 |
27.67 | |||
replaceSectionAtRev | |
61.11% |
11 / 18 |
|
0.00% |
0 / 1 |
9.88 | |||
checkFlags | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
getDerivedDataUpdater | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
9 | |||
doUserEditContent | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
11 | |||
newPageUpdater | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
makeParserOptions | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
makeParserOptionsFromTitleAndModel | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
prepareContentForEdit | |
46.15% |
6 / 13 |
|
0.00% |
0 / 1 |
4.41 | |||
doEditUpdates | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
updateParserCache | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
doSecondaryDataUpdates | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
doUpdateRestrictions | |
91.77% |
145 / 158 |
|
0.00% |
0 / 1 |
37.76 | |||
getCurrentUpdate | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
insertNullProtectionRevision | |
80.00% |
16 / 20 |
|
0.00% |
0 / 1 |
4.13 | |||
formatExpiry | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
protectDescription | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
protectDescriptionLog | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
isBatchedDelete | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
doDeleteArticleReal | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
4.00 | |||
lockAndGetLatest | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
onArticleCreate | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
2.00 | |||
onArticleDelete | |
72.22% |
13 / 18 |
|
0.00% |
0 / 1 |
3.19 | |||
onArticleEdit | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
queueBacklinksJobs | |
36.00% |
9 / 25 |
|
0.00% |
0 / 1 |
24.78 | |||
purgeInterwikiCheckKey | |
28.57% |
4 / 14 |
|
0.00% |
0 / 1 |
3.46 | |||
getCategories | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getHiddenCategories | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
getAutoDeleteReason | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
updateCategoryCounts | |
87.93% |
51 / 58 |
|
0.00% |
0 / 1 |
12.25 | |||
triggerOpportunisticLinksUpdate | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
72 | |||
isLocal | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getWikiDisplayName | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getSourceURL | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
__wakeup | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNamespace | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDBkey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getWikiId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
canExist | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__toString | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isSamePageAs | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
toPageRecord | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
4.00 | |||
getConnectionProvider | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Page; |
22 | |
23 | use BadMethodCallException; |
24 | use HTMLCacheUpdateJob; |
25 | use InvalidArgumentException; |
26 | use MediaWiki\Actions\InfoAction; |
27 | use MediaWiki\Category\Category; |
28 | use MediaWiki\CommentStore\CommentStoreComment; |
29 | use MediaWiki\Content\Content; |
30 | use MediaWiki\Content\ContentHandler; |
31 | use MediaWiki\Context\IContextSource; |
32 | use MediaWiki\DAO\WikiAwareEntityTrait; |
33 | use MediaWiki\Deferred\DeferredUpdates; |
34 | use MediaWiki\Edit\PreparedEdit; |
35 | use MediaWiki\HookContainer\ProtectedHookAccessorTrait; |
36 | use MediaWiki\JobQueue\Jobs\RefreshLinksJob; |
37 | use MediaWiki\Linker\LinkTarget; |
38 | use MediaWiki\Logger\LoggerFactory; |
39 | use MediaWiki\Logging\ManualLogEntry; |
40 | use MediaWiki\MainConfigNames; |
41 | use MediaWiki\MediaWikiServices; |
42 | use MediaWiki\Parser\ParserOptions; |
43 | use MediaWiki\Parser\ParserOutput; |
44 | use MediaWiki\Permissions\Authority; |
45 | use MediaWiki\RecentChanges\RecentChange; |
46 | use MediaWiki\Revision\RevisionRecord; |
47 | use MediaWiki\Revision\RevisionStore; |
48 | use MediaWiki\Revision\SlotRecord; |
49 | use MediaWiki\Status\Status; |
50 | use MediaWiki\Storage\DerivedPageDataUpdater; |
51 | use MediaWiki\Storage\EditResult; |
52 | use MediaWiki\Storage\PageUpdateCauses; |
53 | use MediaWiki\Storage\PageUpdater; |
54 | use MediaWiki\Storage\PageUpdaterFactory; |
55 | use MediaWiki\Storage\PageUpdateStatus; |
56 | use MediaWiki\Storage\PreparedUpdate; |
57 | use MediaWiki\Storage\RevisionSlotsUpdate; |
58 | use MediaWiki\Title\Title; |
59 | use MediaWiki\Title\TitleArrayFromResult; |
60 | use MediaWiki\User\User; |
61 | use MediaWiki\User\UserArray; |
62 | use MediaWiki\User\UserArrayFromResult; |
63 | use MediaWiki\User\UserIdentity; |
64 | use MediaWiki\Utils\MWTimestamp; |
65 | use MediaWiki\WikiMap\WikiMap; |
66 | use RuntimeException; |
67 | use stdClass; |
68 | use Stringable; |
69 | use Wikimedia\Assert\Assert; |
70 | use Wikimedia\Assert\PreconditionException; |
71 | use Wikimedia\NonSerializable\NonSerializableTrait; |
72 | use Wikimedia\Rdbms\FakeResultWrapper; |
73 | use Wikimedia\Rdbms\IDatabase; |
74 | use Wikimedia\Rdbms\IDBAccessObject; |
75 | use Wikimedia\Rdbms\ILoadBalancer; |
76 | use Wikimedia\Rdbms\IReadableDatabase; |
77 | use Wikimedia\Rdbms\RawSQLValue; |
78 | use Wikimedia\Rdbms\SelectQueryBuilder; |
79 | |
80 | /** |
81 | * @defgroup Page Page |
82 | */ |
83 | |
84 | /** |
85 | * Base representation for an editable wiki page. |
86 | * |
87 | * Some fields are public only for backwards-compatibility. Use accessor methods. |
88 | * In the past, this class was part of Article.php and everything was public. |
89 | * |
90 | * @ingroup Page |
91 | */ |
92 | class WikiPage implements Stringable, Page, PageRecord { |
93 | use NonSerializableTrait; |
94 | use ProtectedHookAccessorTrait; |
95 | use WikiAwareEntityTrait; |
96 | |
97 | // Constants for $mDataLoadedFrom and related |
98 | |
99 | /** |
100 | * @var Title |
101 | * @note for access by subclasses only |
102 | */ |
103 | protected $mTitle; |
104 | |
105 | /** |
106 | * @var bool |
107 | * @note for access by subclasses only |
108 | */ |
109 | protected $mDataLoaded = false; |
110 | |
111 | /** |
112 | * A cache of the page_is_redirect field, loaded with page data |
113 | * @var bool |
114 | */ |
115 | private $mPageIsRedirectField = false; |
116 | |
117 | /** |
118 | * @var bool |
119 | */ |
120 | private $mIsNew = false; |
121 | |
122 | /** |
123 | * @var int|false False means "not loaded" |
124 | * @note for access by subclasses only |
125 | */ |
126 | protected $mLatest = false; |
127 | |
128 | /** |
129 | * @var PreparedEdit|false Map of cache fields (text, parser output, ect) for a proposed/new edit |
130 | * @note for access by subclasses only |
131 | */ |
132 | protected $mPreparedEdit = false; |
133 | |
134 | /** |
135 | * @var int|null |
136 | */ |
137 | protected $mId = null; |
138 | |
139 | /** |
140 | * @var int One of the READ_* constants |
141 | */ |
142 | protected $mDataLoadedFrom = IDBAccessObject::READ_NONE; |
143 | |
144 | /** |
145 | * @var RevisionRecord|null |
146 | */ |
147 | private $mLastRevision = null; |
148 | |
149 | /** |
150 | * @var string Timestamp of the current revision or empty string if not loaded |
151 | */ |
152 | protected $mTimestamp = ''; |
153 | |
154 | /** |
155 | * @var string |
156 | */ |
157 | protected $mTouched = '19700101000000'; |
158 | |
159 | /** |
160 | * @var string|null |
161 | */ |
162 | protected $mLanguage = null; |
163 | |
164 | /** |
165 | * @var string |
166 | */ |
167 | protected $mLinksUpdated = '19700101000000'; |
168 | |
169 | /** |
170 | * @var DerivedPageDataUpdater|null |
171 | */ |
172 | private $derivedDataUpdater = null; |
173 | |
174 | public function __construct( PageIdentity $pageIdentity ) { |
175 | $pageIdentity->assertWiki( PageIdentity::LOCAL ); |
176 | |
177 | // TODO: remove the need for casting to Title. |
178 | $title = Title::newFromPageIdentity( $pageIdentity ); |
179 | if ( !$title->canExist() ) { |
180 | throw new InvalidArgumentException( "WikiPage constructed on a Title that cannot exist as a page: $title" ); |
181 | } |
182 | |
183 | $this->mTitle = $title; |
184 | } |
185 | |
186 | /** |
187 | * Makes sure that the mTitle object is cloned |
188 | * to the newly cloned WikiPage. |
189 | */ |
190 | public function __clone() { |
191 | $this->mTitle = clone $this->mTitle; |
192 | } |
193 | |
194 | /** |
195 | * Convert 'fromdb', 'fromdbmaster' and 'forupdate' to READ_* constants. |
196 | * |
197 | * @param stdClass|string|int $type |
198 | * @return mixed |
199 | */ |
200 | public static function convertSelectType( $type ) { |
201 | switch ( $type ) { |
202 | case 'fromdb': |
203 | return IDBAccessObject::READ_NORMAL; |
204 | case 'fromdbmaster': |
205 | return IDBAccessObject::READ_LATEST; |
206 | case 'forupdate': |
207 | return IDBAccessObject::READ_LOCKING; |
208 | default: |
209 | // It may already be an integer or whatever else |
210 | return $type; |
211 | } |
212 | } |
213 | |
214 | private function getPageUpdaterFactory(): PageUpdaterFactory { |
215 | return MediaWikiServices::getInstance()->getPageUpdaterFactory(); |
216 | } |
217 | |
218 | /** |
219 | * @return RevisionStore |
220 | */ |
221 | private function getRevisionStore() { |
222 | return MediaWikiServices::getInstance()->getRevisionStore(); |
223 | } |
224 | |
225 | /** |
226 | * @return ILoadBalancer |
227 | */ |
228 | private function getDBLoadBalancer() { |
229 | return MediaWikiServices::getInstance()->getDBLoadBalancer(); |
230 | } |
231 | |
232 | /** |
233 | * @todo Move this UI stuff somewhere else |
234 | * |
235 | * @see ContentHandler::getActionOverrides |
236 | * @return array |
237 | */ |
238 | public function getActionOverrides() { |
239 | return $this->getContentHandler()->getActionOverrides(); |
240 | } |
241 | |
242 | /** |
243 | * Returns the ContentHandler instance to be used to deal with the content of this WikiPage. |
244 | * |
245 | * Shorthand for ContentHandler::getForModelID( $this->getContentModel() ); |
246 | * |
247 | * @return ContentHandler |
248 | * |
249 | * @since 1.21 |
250 | */ |
251 | public function getContentHandler() { |
252 | $factory = MediaWikiServices::getInstance()->getContentHandlerFactory(); |
253 | return $factory->getContentHandler( $this->getContentModel() ); |
254 | } |
255 | |
256 | /** |
257 | * Get the title object of the article |
258 | * @return Title Title object of this page |
259 | */ |
260 | public function getTitle(): Title { |
261 | return $this->mTitle; |
262 | } |
263 | |
264 | /** |
265 | * Clear the object |
266 | * @return void |
267 | */ |
268 | public function clear() { |
269 | $this->mDataLoaded = false; |
270 | $this->mDataLoadedFrom = IDBAccessObject::READ_NONE; |
271 | |
272 | $this->clearCacheFields(); |
273 | } |
274 | |
275 | /** |
276 | * Clear the object cache fields |
277 | * @return void |
278 | */ |
279 | protected function clearCacheFields() { |
280 | $this->mId = null; |
281 | $this->mPageIsRedirectField = false; |
282 | $this->mLastRevision = null; // Latest revision |
283 | $this->mTouched = '19700101000000'; |
284 | $this->mLanguage = null; |
285 | $this->mLinksUpdated = '19700101000000'; |
286 | $this->mTimestamp = ''; |
287 | $this->mIsNew = false; |
288 | $this->mLatest = false; |
289 | // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already |
290 | // checks the requested rev ID and content against the cached one. For most |
291 | // content types, the output should not change during the lifetime of this cache. |
292 | // Clearing it can cause extra parses on edit for no reason. |
293 | } |
294 | |
295 | /** |
296 | * Clear the mPreparedEdit cache field, as may be needed by mutable content types |
297 | * @return void |
298 | * @since 1.23 |
299 | */ |
300 | public function clearPreparedEdit() { |
301 | $this->mPreparedEdit = false; |
302 | } |
303 | |
304 | /** |
305 | * Return the tables, fields, and join conditions to be selected to create |
306 | * a new page object. |
307 | * @since 1.31 |
308 | * @return array[] With three keys: |
309 | * - tables: (string[]) to include in the `$table` to `IReadableDatabase->select()` or |
310 | * `SelectQueryBuilder::tables` |
311 | * - fields: (string[]) to include in the `$vars` to `IReadableDatabase->select()` or |
312 | * `SelectQueryBuilder::fields` |
313 | * - joins: (array) to include in the `$join_conds` to `IReadableDatabase->select()` or |
314 | * `SelectQueryBuilder::joinConds` |
315 | * @phan-return array{tables:string[],fields:string[],joins:array} |
316 | */ |
317 | public static function getQueryInfo() { |
318 | $pageLanguageUseDB = MediaWikiServices::getInstance()->getMainConfig()->get( |
319 | MainConfigNames::PageLanguageUseDB ); |
320 | |
321 | $ret = [ |
322 | 'tables' => [ 'page' ], |
323 | 'fields' => [ |
324 | 'page_id', |
325 | 'page_namespace', |
326 | 'page_title', |
327 | 'page_is_redirect', |
328 | 'page_is_new', |
329 | 'page_random', |
330 | 'page_touched', |
331 | 'page_links_updated', |
332 | 'page_latest', |
333 | 'page_len', |
334 | 'page_content_model', |
335 | ], |
336 | 'joins' => [], |
337 | ]; |
338 | |
339 | if ( $pageLanguageUseDB ) { |
340 | $ret['fields'][] = 'page_lang'; |
341 | } |
342 | |
343 | return $ret; |
344 | } |
345 | |
346 | /** |
347 | * Fetch a page record with the given conditions |
348 | * @param IReadableDatabase $dbr |
349 | * @param array $conditions |
350 | * @param array $options |
351 | * @return stdClass|false Database result resource, or false on failure |
352 | */ |
353 | protected function pageData( $dbr, $conditions, $options = [] ) { |
354 | $pageQuery = self::getQueryInfo(); |
355 | |
356 | $this->getHookRunner()->onArticlePageDataBefore( |
357 | $this, $pageQuery['fields'], $pageQuery['tables'], $pageQuery['joins'] ); |
358 | |
359 | $row = $dbr->newSelectQueryBuilder() |
360 | ->queryInfo( $pageQuery ) |
361 | ->where( $conditions ) |
362 | ->caller( __METHOD__ ) |
363 | ->options( $options ) |
364 | ->fetchRow(); |
365 | |
366 | $this->getHookRunner()->onArticlePageDataAfter( $this, $row ); |
367 | |
368 | return $row; |
369 | } |
370 | |
371 | /** |
372 | * Fetch a page record matching the Title object's namespace and title |
373 | * using a sanitized title string |
374 | * |
375 | * @param IReadableDatabase $dbr |
376 | * @param Title $title |
377 | * @param int $recency |
378 | * @return stdClass|false Database result resource, or false on failure |
379 | */ |
380 | public function pageDataFromTitle( $dbr, $title, $recency = IDBAccessObject::READ_NORMAL ) { |
381 | if ( !$title->canExist() ) { |
382 | return false; |
383 | } |
384 | $options = []; |
385 | if ( ( $recency & IDBAccessObject::READ_EXCLUSIVE ) == IDBAccessObject::READ_EXCLUSIVE ) { |
386 | $options[] = 'FOR UPDATE'; |
387 | } elseif ( ( $recency & IDBAccessObject::READ_LOCKING ) == IDBAccessObject::READ_LOCKING ) { |
388 | $options[] = 'LOCK IN SHARE MODE'; |
389 | } |
390 | |
391 | return $this->pageData( $dbr, [ |
392 | 'page_namespace' => $title->getNamespace(), |
393 | 'page_title' => $title->getDBkey() ], $options ); |
394 | } |
395 | |
396 | /** |
397 | * Fetch a page record matching the requested ID |
398 | * |
399 | * @param IReadableDatabase $dbr |
400 | * @param int $id |
401 | * @param array $options |
402 | * @return stdClass|false Database result resource, or false on failure |
403 | */ |
404 | public function pageDataFromId( $dbr, $id, $options = [] ) { |
405 | return $this->pageData( $dbr, [ 'page_id' => $id ], $options ); |
406 | } |
407 | |
408 | /** |
409 | * Load the object from a given source by title |
410 | * |
411 | * @param stdClass|string|int $from One of the following: |
412 | * - A DB query result object. |
413 | * - "fromdb" or IDBAccessObject::READ_NORMAL to get from a replica DB. |
414 | * - "fromdbmaster" or IDBAccessObject::READ_LATEST to get from the primary DB. |
415 | * - "forupdate" or IDBAccessObject::READ_LOCKING to get from the primary DB |
416 | * using SELECT FOR UPDATE. |
417 | * |
418 | * @return void |
419 | */ |
420 | public function loadPageData( $from = 'fromdb' ) { |
421 | $from = self::convertSelectType( $from ); |
422 | if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) { |
423 | // We already have the data from the correct location, no need to load it twice. |
424 | return; |
425 | } |
426 | |
427 | if ( is_int( $from ) ) { |
428 | $loadBalancer = $this->getDBLoadBalancer(); |
429 | if ( ( $from & IDBAccessObject::READ_LATEST ) == IDBAccessObject::READ_LATEST ) { |
430 | $index = DB_PRIMARY; |
431 | } else { |
432 | $index = DB_REPLICA; |
433 | } |
434 | $db = $loadBalancer->getConnection( $index ); |
435 | $data = $this->pageDataFromTitle( $db, $this->mTitle, $from ); |
436 | |
437 | if ( !$data |
438 | && $index == DB_REPLICA |
439 | && $loadBalancer->hasReplicaServers() |
440 | && $loadBalancer->hasOrMadeRecentPrimaryChanges() |
441 | ) { |
442 | $from = IDBAccessObject::READ_LATEST; |
443 | $db = $loadBalancer->getConnection( DB_PRIMARY ); |
444 | $data = $this->pageDataFromTitle( $db, $this->mTitle, $from ); |
445 | } |
446 | } else { |
447 | // No idea from where the caller got this data, assume replica DB. |
448 | $data = $from; |
449 | $from = IDBAccessObject::READ_NORMAL; |
450 | } |
451 | |
452 | $this->loadFromRow( $data, $from ); |
453 | } |
454 | |
455 | /** |
456 | * Checks whether the page data was loaded using the given database access mode (or better). |
457 | * |
458 | * @since 1.32 |
459 | * |
460 | * @param string|int $from One of the following: |
461 | * - "fromdb" or IDBAccessObject::READ_NORMAL to get from a replica DB. |
462 | * - "fromdbmaster" or IDBAccessObject::READ_LATEST to get from the primary DB. |
463 | * - "forupdate" or IDBAccessObject::READ_LOCKING to get from the primary DB |
464 | * using SELECT FOR UPDATE. |
465 | * |
466 | * @return bool |
467 | */ |
468 | public function wasLoadedFrom( $from ) { |
469 | $from = self::convertSelectType( $from ); |
470 | |
471 | if ( !is_int( $from ) ) { |
472 | // No idea from where the caller got this data, assume replica DB. |
473 | $from = IDBAccessObject::READ_NORMAL; |
474 | } |
475 | |
476 | if ( $from <= $this->mDataLoadedFrom ) { |
477 | return true; |
478 | } |
479 | |
480 | return false; |
481 | } |
482 | |
483 | /** |
484 | * Load the object from a database row |
485 | * |
486 | * @since 1.20 |
487 | * @param stdClass|false $data DB row containing fields returned by getQueryInfo() or false |
488 | * @param string|int $from One of the following: |
489 | * - "fromdb" or IDBAccessObject::READ_NORMAL if the data comes from a replica DB |
490 | * - "fromdbmaster" or IDBAccessObject::READ_LATEST if the data comes from the primary DB |
491 | * - "forupdate" or IDBAccessObject::READ_LOCKING if the data comes from |
492 | * the primary DB using SELECT FOR UPDATE |
493 | */ |
494 | public function loadFromRow( $data, $from ) { |
495 | $lc = MediaWikiServices::getInstance()->getLinkCache(); |
496 | $lc->clearLink( $this->mTitle ); |
497 | |
498 | if ( $data ) { |
499 | $lc->addGoodLinkObjFromRow( $this->mTitle, $data ); |
500 | |
501 | $this->mTitle->loadFromRow( $data ); |
502 | $this->mId = intval( $data->page_id ); |
503 | $this->mTouched = MWTimestamp::convert( TS_MW, $data->page_touched ); |
504 | $this->mLanguage = $data->page_lang ?? null; |
505 | $this->mLinksUpdated = $data->page_links_updated === null |
506 | ? null |
507 | : MWTimestamp::convert( TS_MW, $data->page_links_updated ); |
508 | $this->mPageIsRedirectField = (bool)$data->page_is_redirect; |
509 | $this->mIsNew = (bool)( $data->page_is_new ?? 0 ); |
510 | $this->mLatest = intval( $data->page_latest ); |
511 | // T39225: $latest may no longer match the cached latest RevisionRecord object. |
512 | // Double-check the ID of any cached latest RevisionRecord object for consistency. |
513 | if ( $this->mLastRevision && $this->mLastRevision->getId() != $this->mLatest ) { |
514 | $this->mLastRevision = null; |
515 | $this->mTimestamp = ''; |
516 | } |
517 | } else { |
518 | $lc->addBadLinkObj( $this->mTitle ); |
519 | |
520 | $this->mTitle->loadFromRow( false ); |
521 | |
522 | $this->clearCacheFields(); |
523 | |
524 | $this->mId = 0; |
525 | } |
526 | |
527 | $this->mDataLoaded = true; |
528 | $this->mDataLoadedFrom = self::convertSelectType( $from ); |
529 | } |
530 | |
531 | /** |
532 | * @param string|false $wikiId |
533 | * |
534 | * @return int Page ID |
535 | */ |
536 | public function getId( $wikiId = self::LOCAL ): int { |
537 | $this->assertWiki( $wikiId ); |
538 | |
539 | if ( !$this->mDataLoaded ) { |
540 | $this->loadPageData(); |
541 | } |
542 | return $this->mId; |
543 | } |
544 | |
545 | /** |
546 | * @return bool Whether or not the page exists in the database |
547 | */ |
548 | public function exists(): bool { |
549 | if ( !$this->mDataLoaded ) { |
550 | $this->loadPageData(); |
551 | } |
552 | return $this->mId > 0; |
553 | } |
554 | |
555 | /** |
556 | * Check if this page is something we're going to be showing |
557 | * some sort of sensible content for. If we return false, page |
558 | * views (plain action=view) will return an HTTP 404 response, |
559 | * so spiders and robots can know they're following a bad link. |
560 | * |
561 | * @return bool |
562 | */ |
563 | public function hasViewableContent() { |
564 | return $this->mTitle->isKnown(); |
565 | } |
566 | |
567 | /** |
568 | * Is the page a redirect, according to secondary tracking tables? |
569 | * If this is true, getRedirectTarget() will return a Title. |
570 | * |
571 | * @return bool |
572 | */ |
573 | public function isRedirect() { |
574 | $this->loadPageData(); |
575 | if ( $this->mPageIsRedirectField ) { |
576 | return MediaWikiServices::getInstance()->getRedirectLookup() |
577 | ->getRedirectTarget( $this->getTitle() ) !== null; |
578 | } |
579 | |
580 | return false; |
581 | } |
582 | |
583 | /** |
584 | * Tests if the page is new (only has one revision). |
585 | * May produce false negatives for some old pages. |
586 | * |
587 | * @since 1.36 |
588 | * |
589 | * @return bool |
590 | */ |
591 | public function isNew() { |
592 | if ( !$this->mDataLoaded ) { |
593 | $this->loadPageData(); |
594 | } |
595 | |
596 | return $this->mIsNew; |
597 | } |
598 | |
599 | /** |
600 | * Returns the page's content model id (see the CONTENT_MODEL_XXX constants). |
601 | * |
602 | * Will use the revisions actual content model if the page exists, |
603 | * and the page's default if the page doesn't exist yet. |
604 | * |
605 | * @return string |
606 | * |
607 | * @since 1.21 |
608 | */ |
609 | public function getContentModel() { |
610 | if ( $this->exists() ) { |
611 | $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); |
612 | |
613 | return $cache->getWithSetCallback( |
614 | $cache->makeKey( 'page-content-model', $this->getLatest() ), |
615 | $cache::TTL_MONTH, |
616 | function () { |
617 | $rev = $this->getRevisionRecord(); |
618 | if ( $rev ) { |
619 | // Look at the revision's actual content model |
620 | $slot = $rev->getSlot( |
621 | SlotRecord::MAIN, |
622 | RevisionRecord::RAW |
623 | ); |
624 | return $slot->getModel(); |
625 | } else { |
626 | LoggerFactory::getInstance( 'wikipage' )->warning( |
627 | 'Page exists but has no (visible) revisions!', |
628 | [ |
629 | 'page-title' => $this->mTitle->getPrefixedDBkey(), |
630 | 'page-id' => $this->getId(), |
631 | ] |
632 | ); |
633 | return $this->mTitle->getContentModel(); |
634 | } |
635 | }, |
636 | [ 'pcTTL' => $cache::TTL_PROC_LONG ] |
637 | ); |
638 | } |
639 | |
640 | // use the default model for this page |
641 | return $this->mTitle->getContentModel(); |
642 | } |
643 | |
644 | /** |
645 | * Loads page_touched and returns a value indicating if it should be used |
646 | * @return bool True if this page exists and is not a redirect |
647 | */ |
648 | public function checkTouched() { |
649 | return ( $this->exists() && !$this->isRedirect() ); |
650 | } |
651 | |
652 | /** |
653 | * Get the page_touched field |
654 | * @return string Timestamp in TS_MW format |
655 | */ |
656 | public function getTouched() { |
657 | if ( !$this->mDataLoaded ) { |
658 | $this->loadPageData(); |
659 | } |
660 | return $this->mTouched; |
661 | } |
662 | |
663 | /** |
664 | * @return ?string language code for the page |
665 | */ |
666 | public function getLanguage() { |
667 | if ( !$this->mDataLoaded ) { |
668 | $this->loadLastEdit(); |
669 | } |
670 | |
671 | return $this->mLanguage; |
672 | } |
673 | |
674 | /** |
675 | * Get the page_links_updated field |
676 | * @return string|null Timestamp in TS_MW format |
677 | */ |
678 | public function getLinksTimestamp() { |
679 | if ( !$this->mDataLoaded ) { |
680 | $this->loadPageData(); |
681 | } |
682 | return $this->mLinksUpdated; |
683 | } |
684 | |
685 | /** |
686 | * Get the page_latest field |
687 | * @param string|false $wikiId |
688 | * @return int The rev_id of current revision |
689 | */ |
690 | public function getLatest( $wikiId = self::LOCAL ) { |
691 | $this->assertWiki( $wikiId ); |
692 | |
693 | if ( !$this->mDataLoaded ) { |
694 | $this->loadPageData(); |
695 | } |
696 | return (int)$this->mLatest; |
697 | } |
698 | |
699 | /** |
700 | * Loads everything except the text |
701 | * This isn't necessary for all uses, so it's only done if needed. |
702 | */ |
703 | protected function loadLastEdit() { |
704 | if ( $this->mLastRevision !== null ) { |
705 | return; // already loaded |
706 | } |
707 | |
708 | $latest = $this->getLatest(); |
709 | if ( !$latest ) { |
710 | return; // page doesn't exist or is missing page_latest info |
711 | } |
712 | |
713 | if ( $this->mDataLoadedFrom == IDBAccessObject::READ_LOCKING ) { |
714 | // T39225: if session S1 loads the page row FOR UPDATE, the result always |
715 | // includes the latest changes committed. This is true even within REPEATABLE-READ |
716 | // transactions, where S1 normally only sees changes committed before the first S1 |
717 | // SELECT. Thus we need S1 to also gets the revision row FOR UPDATE; otherwise, it |
718 | // may not find it since a page row UPDATE and revision row INSERT by S2 may have |
719 | // happened after the first S1 SELECT. |
720 | // https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html#isolevel_repeatable-read |
721 | $revision = $this->getRevisionStore() |
722 | ->getRevisionByPageId( $this->getId(), $latest, IDBAccessObject::READ_LOCKING ); |
723 | } elseif ( $this->mDataLoadedFrom == IDBAccessObject::READ_LATEST ) { |
724 | // Bug T93976: if page_latest was loaded from the primary DB, fetch the |
725 | // revision from there as well, as it may not exist yet on a replica DB. |
726 | // Also, this keeps the queries in the same REPEATABLE-READ snapshot. |
727 | $revision = $this->getRevisionStore() |
728 | ->getRevisionByPageId( $this->getId(), $latest, IDBAccessObject::READ_LATEST ); |
729 | } else { |
730 | $revision = $this->getRevisionStore()->getKnownCurrentRevision( $this->getTitle(), $latest ); |
731 | } |
732 | |
733 | if ( $revision ) { |
734 | $this->setLastEdit( $revision ); |
735 | } |
736 | } |
737 | |
738 | /** |
739 | * Set the latest revision |
740 | */ |
741 | private function setLastEdit( RevisionRecord $revRecord ) { |
742 | $this->mLastRevision = $revRecord; |
743 | $this->mLatest = $revRecord->getId(); |
744 | $this->mTimestamp = $revRecord->getTimestamp(); |
745 | $this->mTouched = max( $this->mTouched, $revRecord->getTimestamp() ); |
746 | } |
747 | |
748 | /** |
749 | * Get the latest revision |
750 | * @since 1.32 |
751 | * @return RevisionRecord|null |
752 | */ |
753 | public function getRevisionRecord() { |
754 | $this->loadLastEdit(); |
755 | return $this->mLastRevision; |
756 | } |
757 | |
758 | /** |
759 | * Get the content of the current revision. No side-effects... |
760 | * |
761 | * @param int $audience One of: |
762 | * RevisionRecord::FOR_PUBLIC to be displayed to all users |
763 | * RevisionRecord::FOR_THIS_USER to be displayed to the given user |
764 | * RevisionRecord::RAW get the text regardless of permissions |
765 | * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed |
766 | * to the $audience parameter |
767 | * @return Content|null The content of the current revision |
768 | * |
769 | * @since 1.21 |
770 | */ |
771 | public function getContent( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) { |
772 | $this->loadLastEdit(); |
773 | if ( $this->mLastRevision ) { |
774 | return $this->mLastRevision->getContent( SlotRecord::MAIN, $audience, $performer ); |
775 | } |
776 | return null; |
777 | } |
778 | |
779 | /** |
780 | * @return string MW timestamp of last article revision |
781 | */ |
782 | public function getTimestamp() { |
783 | // Check if the field has been filled by WikiPage::setTimestamp() |
784 | if ( !$this->mTimestamp ) { |
785 | $this->loadLastEdit(); |
786 | } |
787 | |
788 | return MWTimestamp::convert( TS_MW, $this->mTimestamp ); |
789 | } |
790 | |
791 | /** |
792 | * Set the page timestamp (use only to avoid DB queries) |
793 | * @param string $ts MW timestamp of last article revision |
794 | * @return void |
795 | */ |
796 | public function setTimestamp( $ts ) { |
797 | $this->mTimestamp = MWTimestamp::convert( TS_MW, $ts ); |
798 | } |
799 | |
800 | /** |
801 | * @param int $audience One of: |
802 | * RevisionRecord::FOR_PUBLIC to be displayed to all users |
803 | * RevisionRecord::FOR_THIS_USER to be displayed to the given user |
804 | * RevisionRecord::RAW get the text regardless of permissions |
805 | * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed |
806 | * to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying |
807 | * a user no fallback is provided and the RevisionRecord method will throw an error) |
808 | * @return int User ID for the user that made the last article revision |
809 | */ |
810 | public function getUser( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) { |
811 | $this->loadLastEdit(); |
812 | if ( $this->mLastRevision ) { |
813 | $revUser = $this->mLastRevision->getUser( $audience, $performer ); |
814 | return $revUser ? $revUser->getId() : 0; |
815 | } else { |
816 | return -1; |
817 | } |
818 | } |
819 | |
820 | /** |
821 | * Get the User object of the user who created the page |
822 | * @param int $audience One of: |
823 | * RevisionRecord::FOR_PUBLIC to be displayed to all users |
824 | * RevisionRecord::FOR_THIS_USER to be displayed to the given user |
825 | * RevisionRecord::RAW get the text regardless of permissions |
826 | * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed |
827 | * to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying |
828 | * a user no fallback is provided and the RevisionRecord method will throw an error) |
829 | * @return UserIdentity|null |
830 | */ |
831 | public function getCreator( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) { |
832 | $revRecord = $this->getRevisionStore()->getFirstRevision( $this->getTitle() ); |
833 | if ( $revRecord ) { |
834 | return $revRecord->getUser( $audience, $performer ); |
835 | } else { |
836 | return null; |
837 | } |
838 | } |
839 | |
840 | /** |
841 | * @param int $audience One of: |
842 | * RevisionRecord::FOR_PUBLIC to be displayed to all users |
843 | * RevisionRecord::FOR_THIS_USER to be displayed to the given user |
844 | * RevisionRecord::RAW get the text regardless of permissions |
845 | * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed |
846 | * to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying |
847 | * a user no fallback is provided and the RevisionRecord method will throw an error) |
848 | * @return string Username of the user that made the last article revision |
849 | */ |
850 | public function getUserText( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) { |
851 | $this->loadLastEdit(); |
852 | if ( $this->mLastRevision ) { |
853 | $revUser = $this->mLastRevision->getUser( $audience, $performer ); |
854 | return $revUser ? $revUser->getName() : ''; |
855 | } else { |
856 | return ''; |
857 | } |
858 | } |
859 | |
860 | /** |
861 | * @param int $audience One of: |
862 | * RevisionRecord::FOR_PUBLIC to be displayed to all users |
863 | * RevisionRecord::FOR_THIS_USER to be displayed to the given user |
864 | * RevisionRecord::RAW get the text regardless of permissions |
865 | * @param Authority|null $performer object to check for, only if FOR_THIS_USER is passed |
866 | * to the $audience parameter (since 1.36, if using FOR_THIS_USER and not specifying |
867 | * a user no fallback is provided and the RevisionRecord method will throw an error) |
868 | * @return string|null Comment stored for the last article revision, or null if the specified |
869 | * audience does not have access to the comment. |
870 | */ |
871 | public function getComment( $audience = RevisionRecord::FOR_PUBLIC, ?Authority $performer = null ) { |
872 | $this->loadLastEdit(); |
873 | if ( $this->mLastRevision ) { |
874 | $revComment = $this->mLastRevision->getComment( $audience, $performer ); |
875 | return $revComment ? $revComment->text : ''; |
876 | } else { |
877 | return ''; |
878 | } |
879 | } |
880 | |
881 | /** |
882 | * Returns true if last revision was marked as "minor edit" |
883 | * |
884 | * @return bool Minor edit indicator for the last article revision. |
885 | */ |
886 | public function getMinorEdit() { |
887 | $this->loadLastEdit(); |
888 | if ( $this->mLastRevision ) { |
889 | return $this->mLastRevision->isMinor(); |
890 | } else { |
891 | return false; |
892 | } |
893 | } |
894 | |
895 | /** |
896 | * Whether the page may count towards the the site's number of "articles". |
897 | * |
898 | * This is tracked in the `site_stats` table, and calculated based on the |
899 | * namespace, page metadata, and content. |
900 | * |
901 | * @see $wgArticleCountMethod |
902 | * @see SlotRoleHandler::supportsArticleCount |
903 | * @see Content::isCountable |
904 | * @see WikitextContent::isCountable |
905 | * @param PreparedEdit|PreparedUpdate|false $editInfo (false): |
906 | * An object returned by prepareTextForEdit() or getCurrentUpdate() respectively; |
907 | * If false is given, the current database state will be used. |
908 | * |
909 | * @return bool |
910 | */ |
911 | public function isCountable( $editInfo = false ) { |
912 | $mwServices = MediaWikiServices::getInstance(); |
913 | $articleCountMethod = $mwServices->getMainConfig()->get( MainConfigNames::ArticleCountMethod ); |
914 | |
915 | // NOTE: Keep in sync with DerivedPageDataUpdater::isCountable. |
916 | |
917 | if ( !$this->mTitle->isContentPage() ) { |
918 | return false; |
919 | } |
920 | |
921 | if ( $editInfo instanceof PreparedEdit ) { |
922 | // NOTE: only the main slot can make a page a redirect |
923 | $content = $editInfo->pstContent; |
924 | } elseif ( $editInfo instanceof PreparedUpdate ) { |
925 | // NOTE: only the main slot can make a page a redirect |
926 | $content = $editInfo->getRawContent( SlotRecord::MAIN ); |
927 | } else { |
928 | $content = $this->getContent(); |
929 | } |
930 | |
931 | if ( !$content || $content->isRedirect() ) { |
932 | return false; |
933 | } |
934 | |
935 | $hasLinks = null; |
936 | |
937 | if ( $articleCountMethod === 'link' ) { |
938 | // nasty special case to avoid re-parsing to detect links |
939 | |
940 | if ( $editInfo ) { |
941 | $hasLinks = $editInfo->output->hasLinks(); |
942 | } else { |
943 | // NOTE: keep in sync with RevisionRenderer::getLinkCount |
944 | // NOTE: keep in sync with DerivedPageDataUpdater::isCountable |
945 | $dbr = $mwServices->getConnectionProvider()->getReplicaDatabase(); |
946 | $hasLinks = (bool)$dbr->newSelectQueryBuilder() |
947 | ->select( '1' ) |
948 | ->from( 'pagelinks' ) |
949 | ->where( [ 'pl_from' => $this->getId() ] ) |
950 | ->caller( __METHOD__ )->fetchField(); |
951 | } |
952 | } |
953 | |
954 | // TODO: MCR: determine $hasLinks for each slot, and use that info |
955 | // with that slot's Content's isCountable method. That requires per- |
956 | // slot ParserOutput in the ParserCache, or per-slot info in the |
957 | // pagelinks table. |
958 | return $content->isCountable( $hasLinks ); |
959 | } |
960 | |
961 | /** |
962 | * If this page is a redirect, get its target |
963 | * |
964 | * The target will be fetched from the redirect table if possible. |
965 | * |
966 | * @deprecated since 1.38 Use RedirectLookup::getRedirectTarget() instead. |
967 | * |
968 | * @return Title|null Title object, or null if this page is not a redirect |
969 | */ |
970 | public function getRedirectTarget() { |
971 | $target = MediaWikiServices::getInstance()->getRedirectLookup()->getRedirectTarget( $this ); |
972 | return Title::castFromLinkTarget( $target ); |
973 | } |
974 | |
975 | /** |
976 | * Insert or update the redirect table entry for this page to indicate it redirects to $rt |
977 | * @deprecated since 1.43; use {@link RedirectStore::updateRedirectTarget()} instead. |
978 | * @param LinkTarget $rt Redirect target |
979 | * @param int|null $oldLatest Prior page_latest for check and set |
980 | * @return bool Success |
981 | */ |
982 | public function insertRedirectEntry( LinkTarget $rt, $oldLatest = null ) { |
983 | return MediaWikiServices::getInstance()->getRedirectStore() |
984 | ->updateRedirectTarget( $this, $rt ); |
985 | } |
986 | |
987 | /** |
988 | * Get the Title object or URL this page redirects to |
989 | * |
990 | * @return bool|Title|string False, Title of in-wiki target, or string with URL |
991 | */ |
992 | public function followRedirect() { |
993 | return $this->getRedirectURL( $this->getRedirectTarget() ); |
994 | } |
995 | |
996 | /** |
997 | * Get the Title object or URL to use for a redirect. We use Title |
998 | * objects for same-wiki, non-special redirects and URLs for everything |
999 | * else. |
1000 | * @param Title $rt Redirect target |
1001 | * @return Title|string|false False, Title object of local target, or string with URL |
1002 | */ |
1003 | public function getRedirectURL( $rt ) { |
1004 | if ( !$rt ) { |
1005 | return false; |
1006 | } |
1007 | |
1008 | if ( $rt->isExternal() ) { |
1009 | if ( $rt->isLocal() ) { |
1010 | // Offsite wikis need an HTTP redirect. |
1011 | // This can be hard to reverse and may produce loops, |
1012 | // so they may be disabled in the site configuration. |
1013 | $source = $this->mTitle->getFullURL( 'redirect=no' ); |
1014 | return $rt->getFullURL( [ 'rdfrom' => $source ] ); |
1015 | } else { |
1016 | // External pages without "local" bit set are not valid |
1017 | // redirect targets |
1018 | return false; |
1019 | } |
1020 | } |
1021 | |
1022 | if ( $rt->isSpecialPage() ) { |
1023 | // Gotta handle redirects to special pages differently: |
1024 | // Fill the HTTP response "Location" header and ignore the rest of the page we're on. |
1025 | // Some pages are not valid targets. |
1026 | if ( $rt->isValidRedirectTarget() ) { |
1027 | return $rt->getFullURL(); |
1028 | } else { |
1029 | return false; |
1030 | } |
1031 | } elseif ( !$rt->isValidRedirectTarget() ) { |
1032 | // We somehow got a bad redirect target into the database (T278367) |
1033 | return false; |
1034 | } |
1035 | |
1036 | return $rt; |
1037 | } |
1038 | |
1039 | /** |
1040 | * Get a list of users who have edited this article, not including the user who made |
1041 | * the most recent revision, which you can get from $article->getUser() if you want it |
1042 | * @return UserArray |
1043 | */ |
1044 | public function getContributors() { |
1045 | // @todo: This is expensive; cache this info somewhere. |
1046 | |
1047 | $services = MediaWikiServices::getInstance(); |
1048 | $dbr = $services->getConnectionProvider()->getReplicaDatabase(); |
1049 | $actorNormalization = $services->getActorNormalization(); |
1050 | $userIdentityLookup = $services->getUserIdentityLookup(); |
1051 | |
1052 | $user = $this->getUser() |
1053 | ? User::newFromId( $this->getUser() ) |
1054 | : User::newFromName( $this->getUserText(), false ); |
1055 | |
1056 | $res = $dbr->newSelectQueryBuilder() |
1057 | ->select( [ |
1058 | 'user_id' => 'actor_user', |
1059 | 'user_name' => 'actor_name', |
1060 | 'actor_id' => 'MIN(rev_actor)', |
1061 | 'user_real_name' => 'MIN(user_real_name)', |
1062 | 'timestamp' => 'MAX(rev_timestamp)', |
1063 | ] ) |
1064 | ->from( 'revision' ) |
1065 | ->join( 'actor', null, 'rev_actor = actor_id' ) |
1066 | ->leftJoin( 'user', null, 'actor_user = user_id' ) |
1067 | ->where( [ |
1068 | 'rev_page' => $this->getId(), |
1069 | // The user who made the top revision gets credited as "this page was last edited by |
1070 | // John, based on contributions by Tom, Dick and Harry", so don't include them twice. |
1071 | $dbr->expr( 'rev_actor', '!=', $actorNormalization->findActorId( $user, $dbr ) ), |
1072 | // Username hidden? |
1073 | $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . ' = 0', |
1074 | ] ) |
1075 | ->groupBy( [ 'actor_user', 'actor_name' ] ) |
1076 | ->orderBy( 'timestamp', SelectQueryBuilder::SORT_DESC ) |
1077 | ->caller( __METHOD__ ) |
1078 | ->fetchResultSet(); |
1079 | return new UserArrayFromResult( $res ); |
1080 | } |
1081 | |
1082 | /** |
1083 | * Should the parser cache be used? |
1084 | * |
1085 | * @param ParserOptions $parserOptions ParserOptions to check |
1086 | * @param int $oldId |
1087 | * @return bool |
1088 | */ |
1089 | public function shouldCheckParserCache( ParserOptions $parserOptions, $oldId ) { |
1090 | // NOTE: Keep in sync with ParserOutputAccess::shouldUseCache(). |
1091 | // TODO: Once ParserOutputAccess is stable, deprecated this method. |
1092 | return $this->exists() |
1093 | && ( $oldId === null || $oldId === 0 || $oldId === $this->getLatest() ) |
1094 | && $this->getContentHandler()->isParserCacheSupported(); |
1095 | } |
1096 | |
1097 | /** |
1098 | * Get a ParserOutput for the given ParserOptions and revision ID. |
1099 | * |
1100 | * The parser cache will be used if possible. Cache misses that result |
1101 | * in parser runs are debounced with PoolCounter. |
1102 | * |
1103 | * XXX merge this with updateParserCache()? |
1104 | * |
1105 | * @since 1.19 |
1106 | * @param ParserOptions|null $parserOptions ParserOptions to use for the parse operation |
1107 | * @param null|int $oldid Revision ID to get the text from, passing null or 0 will |
1108 | * get the current revision (default value) |
1109 | * @param bool $noCache Do not read from or write to caches. |
1110 | * @return ParserOutput|false ParserOutput or false if the revision was not found or is not public |
1111 | */ |
1112 | public function getParserOutput( |
1113 | ?ParserOptions $parserOptions = null, $oldid = null, $noCache = false |
1114 | ) { |
1115 | if ( $oldid ) { |
1116 | $revision = $this->getRevisionStore()->getRevisionByTitle( $this->getTitle(), $oldid ); |
1117 | |
1118 | if ( !$revision ) { |
1119 | return false; |
1120 | } |
1121 | } else { |
1122 | $revision = $this->getRevisionRecord(); |
1123 | } |
1124 | |
1125 | if ( !$parserOptions ) { |
1126 | $parserOptions = ParserOptions::newFromAnon(); |
1127 | } |
1128 | |
1129 | $options = $noCache ? ParserOutputAccess::OPT_NO_CACHE : 0; |
1130 | |
1131 | $status = MediaWikiServices::getInstance()->getParserOutputAccess()->getParserOutput( |
1132 | $this, $parserOptions, $revision, $options |
1133 | ); |
1134 | return $status->isOK() ? $status->getValue() : false; // convert null to false |
1135 | } |
1136 | |
1137 | /** |
1138 | * Do standard deferred updates after page view (existing or missing page) |
1139 | * @param Authority $performer The viewing user |
1140 | * @param int $oldid Revision id being viewed; if not given or 0, latest revision is assumed |
1141 | * @param RevisionRecord|null $oldRev The RevisionRecord associated with $oldid. |
1142 | */ |
1143 | public function doViewUpdates( |
1144 | Authority $performer, |
1145 | $oldid = 0, |
1146 | ?RevisionRecord $oldRev = null |
1147 | ) { |
1148 | if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) { |
1149 | return; |
1150 | } |
1151 | |
1152 | DeferredUpdates::addCallableUpdate( |
1153 | function () use ( $performer ) { |
1154 | // In practice, these hook handlers simply debounce into a post-send |
1155 | // to do their work since none of the use cases for this hook require |
1156 | // a blocking pre-send callback. |
1157 | // |
1158 | // TODO: Move this hook to post-send. |
1159 | // |
1160 | // For now, it is unofficially possible for an extension to use |
1161 | // onPageViewUpdates to try to insert JavaScript via global $wgOut. |
1162 | // This isn't supported (the hook doesn't pass OutputPage), and |
1163 | // can't be since OutputPage may be disabled or replaced on some |
1164 | // pages that we do support page view updates for. We also run |
1165 | // this hook after HTMLFileCache, which also naturally can't |
1166 | // support modifying OutputPage. Handlers that modify the page |
1167 | // may use onBeforePageDisplay instead, which runs behind |
1168 | // HTMLFileCache and won't run on non-OutputPage responses. |
1169 | $legacyUser = MediaWikiServices::getInstance() |
1170 | ->getUserFactory() |
1171 | ->newFromAuthority( $performer ); |
1172 | $this->getHookRunner()->onPageViewUpdates( $this, $legacyUser ); |
1173 | }, |
1174 | DeferredUpdates::PRESEND |
1175 | ); |
1176 | |
1177 | // Update newtalk and watchlist notification status |
1178 | MediaWikiServices::getInstance() |
1179 | ->getWatchlistManager() |
1180 | ->clearTitleUserNotifications( $performer, $this, $oldid, $oldRev ); |
1181 | } |
1182 | |
1183 | /** |
1184 | * Perform the actions of a page purging |
1185 | * @return bool |
1186 | * @note In 1.28 (and only 1.28), this took a $flags parameter that |
1187 | * controlled how much purging was done. |
1188 | */ |
1189 | public function doPurge() { |
1190 | if ( !$this->getHookRunner()->onArticlePurge( $this ) ) { |
1191 | return false; |
1192 | } |
1193 | |
1194 | $this->mTitle->invalidateCache(); |
1195 | |
1196 | // Clear file cache and send purge after above page_touched update was committed |
1197 | $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater(); |
1198 | $hcu->purgeTitleUrls( $this->mTitle, $hcu::PURGE_PRESEND ); |
1199 | |
1200 | if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI ) { |
1201 | MediaWikiServices::getInstance()->getMessageCache() |
1202 | ->updateMessageOverride( $this->mTitle, $this->getContent() ); |
1203 | } |
1204 | InfoAction::invalidateCache( $this->mTitle, $this->getLatest() ); |
1205 | |
1206 | return true; |
1207 | } |
1208 | |
1209 | /** |
1210 | * Insert a new empty page record for this article. |
1211 | * This *must* be followed up by creating a revision |
1212 | * and running $this->updateRevisionOn( ... ); |
1213 | * or else the record will be left in a funky state. |
1214 | * Best if all done inside a transaction. |
1215 | * |
1216 | * @todo Factor out into a PageStore service, to be used by PageUpdater. |
1217 | * |
1218 | * @param IDatabase $dbw |
1219 | * @param int|null $pageId Custom page ID that will be used for the insert statement |
1220 | * |
1221 | * @return int|false The newly created page_id key; false if the row was not |
1222 | * inserted, e.g. because the title already existed or because the specified |
1223 | * page ID is already in use. |
1224 | */ |
1225 | public function insertOn( $dbw, $pageId = null ) { |
1226 | $pageIdForInsert = $pageId ? [ 'page_id' => $pageId ] : []; |
1227 | $dbw->newInsertQueryBuilder() |
1228 | ->insertInto( 'page' ) |
1229 | ->ignore() |
1230 | ->row( [ |
1231 | 'page_namespace' => $this->mTitle->getNamespace(), |
1232 | 'page_title' => $this->mTitle->getDBkey(), |
1233 | 'page_is_redirect' => 0, // Will set this shortly... |
1234 | 'page_is_new' => 1, |
1235 | 'page_random' => wfRandom(), |
1236 | 'page_touched' => $dbw->timestamp(), |
1237 | 'page_latest' => 0, // Fill this in shortly... |
1238 | 'page_len' => 0, // Fill this in shortly... |
1239 | ] + $pageIdForInsert ) |
1240 | ->caller( __METHOD__ )->execute(); |
1241 | |
1242 | if ( $dbw->affectedRows() > 0 ) { |
1243 | $newid = $pageId ? (int)$pageId : $dbw->insertId(); |
1244 | $this->mId = $newid; |
1245 | $this->mTitle->resetArticleID( $newid ); |
1246 | |
1247 | return $newid; |
1248 | } else { |
1249 | return false; // nothing changed |
1250 | } |
1251 | } |
1252 | |
1253 | /** |
1254 | * Update the page record to point to a newly saved revision. |
1255 | * |
1256 | * @todo Factor out into a PageStore service, or move into PageUpdater. |
1257 | * |
1258 | * @param IDatabase $dbw |
1259 | * @param RevisionRecord $revision For ID number, and text used to set |
1260 | * length and redirect status fields. |
1261 | * @param int|null $lastRevision If given, will not overwrite the page field |
1262 | * when different from the currently set value. |
1263 | * Giving 0 indicates the new page flag should be set on. |
1264 | * @param bool|null $lastRevIsRedirect If given, will optimize adding and |
1265 | * removing rows in redirect table. |
1266 | * @return bool Success; false if the page row was missing or page_latest changed |
1267 | */ |
1268 | public function updateRevisionOn( |
1269 | $dbw, |
1270 | RevisionRecord $revision, |
1271 | $lastRevision = null, |
1272 | $lastRevIsRedirect = null |
1273 | ) { |
1274 | // TODO: move into PageUpdater or PageStore |
1275 | // NOTE: when doing that, make sure cached fields get reset in doUserEditContent, |
1276 | // and in the compat stub! |
1277 | |
1278 | $revId = $revision->getId(); |
1279 | Assert::parameter( $revId > 0, '$revision->getId()', 'must be > 0' ); |
1280 | |
1281 | $content = $revision->getContent( SlotRecord::MAIN ); |
1282 | $len = $content ? $content->getSize() : 0; |
1283 | $rt = $content ? $content->getRedirectTarget() : null; |
1284 | $isNew = $lastRevision === 0; |
1285 | $isRedirect = $rt !== null; |
1286 | |
1287 | $conditions = [ 'page_id' => $this->getId() ]; |
1288 | |
1289 | if ( $lastRevision !== null ) { |
1290 | // An extra check against threads stepping on each other |
1291 | $conditions['page_latest'] = $lastRevision; |
1292 | } |
1293 | |
1294 | $model = $revision->getMainContentModel(); |
1295 | |
1296 | $row = [ /* SET */ |
1297 | 'page_latest' => $revId, |
1298 | 'page_touched' => $dbw->timestamp( $revision->getTimestamp() ), |
1299 | 'page_is_new' => $isNew ? 1 : 0, |
1300 | 'page_is_redirect' => $isRedirect ? 1 : 0, |
1301 | 'page_len' => $len, |
1302 | 'page_content_model' => $model, |
1303 | ]; |
1304 | |
1305 | $dbw->newUpdateQueryBuilder() |
1306 | ->update( 'page' ) |
1307 | ->set( $row ) |
1308 | ->where( $conditions ) |
1309 | ->caller( __METHOD__ )->execute(); |
1310 | |
1311 | $result = $dbw->affectedRows() > 0; |
1312 | if ( $result ) { |
1313 | $insertedRow = $this->pageData( $dbw, [ 'page_id' => $this->getId() ] ); |
1314 | |
1315 | if ( !$insertedRow ) { |
1316 | throw new RuntimeException( 'Failed to load freshly inserted row' ); |
1317 | } |
1318 | |
1319 | $this->mTitle->loadFromRow( $insertedRow ); |
1320 | MediaWikiServices::getInstance()->getRedirectStore() |
1321 | ->updateRedirectTarget( $this, $rt, $lastRevIsRedirect ); |
1322 | $this->setLastEdit( $revision ); |
1323 | $this->mPageIsRedirectField = (bool)$rt; |
1324 | $this->mIsNew = $isNew; |
1325 | |
1326 | // Update the LinkCache. |
1327 | $linkCache = MediaWikiServices::getInstance()->getLinkCache(); |
1328 | $linkCache->addGoodLinkObjFromRow( |
1329 | $this->mTitle, |
1330 | $insertedRow |
1331 | ); |
1332 | } |
1333 | |
1334 | return $result; |
1335 | } |
1336 | |
1337 | /** |
1338 | * Helper method for checking whether two revisions have differences that go |
1339 | * beyond the main slot. |
1340 | * |
1341 | * MCR migration note: this method should go away! |
1342 | * |
1343 | * @deprecated since 1.43; Use only as a stop-gap before refactoring to support MCR. |
1344 | * |
1345 | * @param RevisionRecord $a |
1346 | * @param RevisionRecord $b |
1347 | * @return bool |
1348 | */ |
1349 | public static function hasDifferencesOutsideMainSlot( RevisionRecord $a, RevisionRecord $b ) { |
1350 | $aSlots = $a->getSlots(); |
1351 | $bSlots = $b->getSlots(); |
1352 | $changedRoles = $aSlots->getRolesWithDifferentContent( $bSlots ); |
1353 | |
1354 | return ( $changedRoles !== [ SlotRecord::MAIN ] && $changedRoles !== [] ); |
1355 | } |
1356 | |
1357 | /** |
1358 | * Returns true if this page's content model supports sections. |
1359 | * |
1360 | * @return bool |
1361 | * |
1362 | * @todo The skin should check this and not offer section functionality if |
1363 | * sections are not supported. |
1364 | * @todo The EditPage should check this and not offer section functionality |
1365 | * if sections are not supported. |
1366 | */ |
1367 | public function supportsSections() { |
1368 | return $this->getContentHandler()->supportsSections(); |
1369 | } |
1370 | |
1371 | /** |
1372 | * @param string|int|null|false $sectionId Section identifier as a number or string |
1373 | * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page |
1374 | * or 'new' for a new section. |
1375 | * @param Content $sectionContent New content of the section. |
1376 | * @param string $sectionTitle New section's subject, only if $section is "new". |
1377 | * @param string $edittime Revision timestamp or null to use the current revision. |
1378 | * |
1379 | * @return Content|null New complete article content, or null if error. |
1380 | * |
1381 | * @since 1.21 |
1382 | * @deprecated since 1.24, use replaceSectionAtRev instead |
1383 | */ |
1384 | public function replaceSectionContent( |
1385 | $sectionId, Content $sectionContent, $sectionTitle = '', $edittime = null |
1386 | ) { |
1387 | $baseRevId = null; |
1388 | if ( $edittime && $sectionId !== 'new' ) { |
1389 | $lb = $this->getDBLoadBalancer(); |
1390 | $rev = $this->getRevisionStore()->getRevisionByTimestamp( $this->mTitle, $edittime ); |
1391 | // Try the primary database if this thread may have just added it. |
1392 | // The logic to fallback to the primary database if the replica is missing |
1393 | // the revision could be generalized into RevisionStore, but we don't want |
1394 | // to encourage loading of revisions by timestamp. |
1395 | if ( !$rev |
1396 | && $lb->hasReplicaServers() |
1397 | && $lb->hasOrMadeRecentPrimaryChanges() |
1398 | ) { |
1399 | $rev = $this->getRevisionStore()->getRevisionByTimestamp( |
1400 | $this->mTitle, $edittime, IDBAccessObject::READ_LATEST ); |
1401 | } |
1402 | if ( $rev ) { |
1403 | $baseRevId = $rev->getId(); |
1404 | } |
1405 | } |
1406 | |
1407 | return $this->replaceSectionAtRev( $sectionId, $sectionContent, $sectionTitle, $baseRevId ); |
1408 | } |
1409 | |
1410 | /** |
1411 | * @param string|int|null|false $sectionId Section identifier as a number or string |
1412 | * (e.g. 0, 1 or 'T-1'), null/false or an empty string for the whole page |
1413 | * or 'new' for a new section. |
1414 | * @param Content $sectionContent New content of the section. |
1415 | * @param string $sectionTitle New section's subject, only if $section is "new". |
1416 | * @param int|null $baseRevId |
1417 | * |
1418 | * @return Content|null New complete article content, or null if error. |
1419 | * |
1420 | * @since 1.24 |
1421 | */ |
1422 | public function replaceSectionAtRev( $sectionId, Content $sectionContent, |
1423 | $sectionTitle = '', $baseRevId = null |
1424 | ) { |
1425 | if ( strval( $sectionId ) === '' ) { |
1426 | // Whole-page edit; let the whole text through |
1427 | $newContent = $sectionContent; |
1428 | } else { |
1429 | if ( !$this->supportsSections() ) { |
1430 | throw new BadMethodCallException( "sections not supported for content model " . |
1431 | $this->getContentHandler()->getModelID() ); |
1432 | } |
1433 | |
1434 | // T32711: always use current version when adding a new section |
1435 | if ( $baseRevId === null || $sectionId === 'new' ) { |
1436 | $oldContent = $this->getContent(); |
1437 | } else { |
1438 | $revRecord = $this->getRevisionStore()->getRevisionById( $baseRevId ); |
1439 | if ( !$revRecord ) { |
1440 | wfDebug( __METHOD__ . " asked for bogus section (page: " . |
1441 | $this->getId() . "; section: $sectionId)" ); |
1442 | return null; |
1443 | } |
1444 | |
1445 | $oldContent = $revRecord->getContent( SlotRecord::MAIN ); |
1446 | } |
1447 | |
1448 | if ( !$oldContent ) { |
1449 | wfDebug( __METHOD__ . ": no page text" ); |
1450 | return null; |
1451 | } |
1452 | |
1453 | $newContent = $oldContent->replaceSection( $sectionId, $sectionContent, $sectionTitle ); |
1454 | } |
1455 | |
1456 | return $newContent; |
1457 | } |
1458 | |
1459 | /** |
1460 | * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed. |
1461 | * |
1462 | * @deprecated since 1.32, use exists() instead, or simply omit the EDIT_UPDATE |
1463 | * and EDIT_NEW flags. To protect against race conditions, use PageUpdater::grabParentRevision. |
1464 | * |
1465 | * @param int $flags |
1466 | * @return int Updated $flags |
1467 | */ |
1468 | public function checkFlags( $flags ) { |
1469 | if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) { |
1470 | if ( $this->exists() ) { |
1471 | $flags |= EDIT_UPDATE; |
1472 | } else { |
1473 | $flags |= EDIT_NEW; |
1474 | } |
1475 | } |
1476 | |
1477 | return $flags; |
1478 | } |
1479 | |
1480 | /** |
1481 | * Returns a DerivedPageDataUpdater for use with the given target revision or new content. |
1482 | * This method attempts to re-use the same DerivedPageDataUpdater instance for subsequent calls. |
1483 | * The parameters passed to this method are used to ensure that the DerivedPageDataUpdater |
1484 | * returned matches that caller's expectations, allowing an existing instance to be re-used |
1485 | * if the given parameters match that instance's internal state according to |
1486 | * DerivedPageDataUpdater::isReusableFor(), and creating a new instance of the parameters do not |
1487 | * match the existing one. |
1488 | * |
1489 | * If neither $forRevision nor $forUpdate is given, a new DerivedPageDataUpdater is always |
1490 | * created, replacing any DerivedPageDataUpdater currently cached. |
1491 | * |
1492 | * MCR migration note: this replaces WikiPage::prepareContentForEdit. |
1493 | * |
1494 | * @since 1.32 |
1495 | * |
1496 | * @param UserIdentity|null $forUser The user that will be used for, or was used for, PST. |
1497 | * @param RevisionRecord|null $forRevision The revision created by the edit for which |
1498 | * to perform updates, if the edit was already saved. |
1499 | * @param RevisionSlotsUpdate|null $forUpdate The new content to be saved by the edit (pre PST), |
1500 | * if the edit was not yet saved. |
1501 | * @param bool $forEdit Only re-use if the cached DerivedPageDataUpdater has the current |
1502 | * revision as the edit's parent revision. This ensures that the same |
1503 | * DerivedPageDataUpdater cannot be re-used for two consecutive edits. |
1504 | * |
1505 | * @return DerivedPageDataUpdater |
1506 | */ |
1507 | private function getDerivedDataUpdater( |
1508 | ?UserIdentity $forUser = null, |
1509 | ?RevisionRecord $forRevision = null, |
1510 | ?RevisionSlotsUpdate $forUpdate = null, |
1511 | $forEdit = false |
1512 | ) { |
1513 | if ( !$forRevision && !$forUpdate ) { |
1514 | // NOTE: can't re-use an existing derivedDataUpdater if we don't know what the caller is |
1515 | // going to use it with. |
1516 | $this->derivedDataUpdater = null; |
1517 | } |
1518 | |
1519 | if ( $this->derivedDataUpdater && !$this->derivedDataUpdater->isContentPrepared() ) { |
1520 | // NOTE: can't re-use an existing derivedDataUpdater if other code that has a reference |
1521 | // to it did not yet initialize it, because we don't know what data it will be |
1522 | // initialized with. |
1523 | $this->derivedDataUpdater = null; |
1524 | } |
1525 | |
1526 | // XXX: It would be nice to have an LRU cache instead of trying to re-use a single instance. |
1527 | // However, there is no good way to construct a cache key. We'd need to check against all |
1528 | // cached instances. |
1529 | |
1530 | if ( $this->derivedDataUpdater |
1531 | && !$this->derivedDataUpdater->isReusableFor( |
1532 | $forUser, |
1533 | $forRevision, |
1534 | $forUpdate, |
1535 | $forEdit ? $this->getLatest() : null |
1536 | ) |
1537 | ) { |
1538 | $this->derivedDataUpdater = null; |
1539 | } |
1540 | |
1541 | if ( !$this->derivedDataUpdater ) { |
1542 | $this->derivedDataUpdater = |
1543 | $this->getPageUpdaterFactory()->newDerivedPageDataUpdater( $this ); |
1544 | } |
1545 | |
1546 | return $this->derivedDataUpdater; |
1547 | } |
1548 | |
1549 | /** |
1550 | * Change an existing article or create a new article. Updates RC and all necessary caches, |
1551 | * optionally via the deferred update array. |
1552 | * |
1553 | * @param Content $content New content |
1554 | * @param Authority $performer doing the edit |
1555 | * @param string|CommentStoreComment $summary Edit summary |
1556 | * @param int $flags Bitfield, see the EDIT_XXX constants such as EDIT_NEW |
1557 | * or EDIT_FORCE_BOT. |
1558 | * |
1559 | * If neither EDIT_NEW nor EDIT_UPDATE is specified, the status of the |
1560 | * article will be detected. If EDIT_UPDATE is specified and the article |
1561 | * doesn't exist, the function will return an edit-gone-missing error. If |
1562 | * EDIT_NEW is specified and the article does exist, an edit-already-exists |
1563 | * error will be returned. These two conditions are also possible with |
1564 | * auto-detection due to MediaWiki's performance-optimised locking strategy. |
1565 | * |
1566 | * @param int|false $originalRevId: The ID of an original revision that the edit |
1567 | * restores or repeats. The new revision is expected to have the exact same content as |
1568 | * the given original revision. This is used with rollbacks and with dummy "null" revisions |
1569 | * which are created to record things like page moves. Default is false, meaning we are not |
1570 | * making a rollback edit. |
1571 | * @param array|null $tags Change tags to apply to this edit |
1572 | * Callers are responsible for permission checks |
1573 | * (with ChangeTags::canAddTagsAccompanyingChange) |
1574 | * @param int $undidRevId Id of revision that was undone or 0 |
1575 | * |
1576 | * @return PageUpdateStatus Possible errors: |
1577 | * edit-hook-aborted: The ArticleSave hook aborted the edit but didn't |
1578 | * set the fatal flag of $status. |
1579 | * edit-gone-missing: In update mode, but the article didn't exist. |
1580 | * edit-conflict: In update mode, the article changed unexpectedly. |
1581 | * edit-no-change: Warning that the text was the same as before. |
1582 | * edit-already-exists: In creation mode, but the article already exists. |
1583 | * |
1584 | * Extensions may define additional errors. |
1585 | * |
1586 | * $return->value will contain an associative array with members as follows: |
1587 | * new: Boolean indicating if the function attempted to create a new article. |
1588 | * revision-record: The revision record object for the inserted revision, or null. |
1589 | * |
1590 | * @deprecated since 1.36, use PageUpdater::saveRevision instead. Note that the new method |
1591 | * expects callers to take care of checking EDIT_MINOR against the minoredit right, and to |
1592 | * apply the autopatrol right as appropriate. |
1593 | * |
1594 | * @since 1.36 |
1595 | */ |
1596 | public function doUserEditContent( |
1597 | Content $content, |
1598 | Authority $performer, |
1599 | $summary, |
1600 | $flags = 0, |
1601 | $originalRevId = false, |
1602 | $tags = [], |
1603 | $undidRevId = 0 |
1604 | ): PageUpdateStatus { |
1605 | $useNPPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( |
1606 | MainConfigNames::UseNPPatrol ); |
1607 | $useRCPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( |
1608 | MainConfigNames::UseRCPatrol ); |
1609 | if ( !( $summary instanceof CommentStoreComment ) ) { |
1610 | $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) ); |
1611 | } |
1612 | |
1613 | // TODO: this check is here for backwards-compatibility with 1.31 behavior. |
1614 | // Checking the minoredit right should be done in the same place the 'bot' right is |
1615 | // checked for the EDIT_FORCE_BOT flag, which is currently in EditPage::attemptSave. |
1616 | if ( ( $flags & EDIT_MINOR ) && !$performer->isAllowed( 'minoredit' ) ) { |
1617 | $flags &= ~EDIT_MINOR; |
1618 | } |
1619 | |
1620 | $slotsUpdate = new RevisionSlotsUpdate(); |
1621 | $slotsUpdate->modifyContent( SlotRecord::MAIN, $content ); |
1622 | |
1623 | // NOTE: while doUserEditContent() executes, callbacks to getDerivedDataUpdater and |
1624 | // prepareContentForEdit will generally use the DerivedPageDataUpdater that is also |
1625 | // used by this PageUpdater. However, there is no guarantee for this. |
1626 | $updater = $this->newPageUpdater( $performer, $slotsUpdate ) |
1627 | ->setContent( SlotRecord::MAIN, $content ) |
1628 | ->setOriginalRevisionId( $originalRevId ); |
1629 | if ( $undidRevId ) { |
1630 | $updater->setCause( PageUpdateCauses::CAUSE_UNDO ); |
1631 | $updater->markAsRevert( |
1632 | EditResult::REVERT_UNDO, |
1633 | $undidRevId, |
1634 | $originalRevId ?: null |
1635 | ); |
1636 | } |
1637 | |
1638 | $needsPatrol = $useRCPatrol || ( $useNPPatrol && !$this->exists() ); |
1639 | |
1640 | // TODO: this logic should not be in the storage layer, it's here for compatibility |
1641 | // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same |
1642 | // place the 'bot' right is handled, which is currently in EditPage::attemptSave. |
1643 | |
1644 | if ( $needsPatrol && $performer->authorizeWrite( 'autopatrol', $this->getTitle() ) ) { |
1645 | $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED ); |
1646 | } |
1647 | |
1648 | $updater->addTags( $tags ); |
1649 | |
1650 | $revRec = $updater->saveRevision( |
1651 | $summary, |
1652 | $flags |
1653 | ); |
1654 | |
1655 | // $revRec will be null if the edit failed, or if no new revision was created because |
1656 | // the content did not change. |
1657 | if ( $revRec ) { |
1658 | // update cached fields |
1659 | // TODO: this is currently redundant to what is done in updateRevisionOn. |
1660 | // But updateRevisionOn() should move into PageStore, and then this will be needed. |
1661 | $this->setLastEdit( $revRec ); |
1662 | } |
1663 | |
1664 | return $updater->getStatus(); |
1665 | } |
1666 | |
1667 | /** |
1668 | * Returns a PageUpdater for creating new revisions on this page (or creating the page). |
1669 | * |
1670 | * The PageUpdater can also be used to detect the need for edit conflict resolution, |
1671 | * and to protected such conflict resolution from concurrent edits using a check-and-set |
1672 | * mechanism. |
1673 | * |
1674 | * @since 1.32 |
1675 | * |
1676 | * @note Once extensions no longer rely on WikiPage to get access to the state of an ongoing |
1677 | * edit via prepareContentForEdit() and WikiPage::getCurrentUpdate(), |
1678 | * this method should be deprecated and callers should be migrated to using |
1679 | * PageUpdaterFactory::newPageUpdater() instead. |
1680 | * |
1681 | * @param Authority|UserIdentity $performer |
1682 | * @param RevisionSlotsUpdate|null $forUpdate If given, allows any cached ParserOutput |
1683 | * that may already have been returned via getDerivedDataUpdater to be re-used. |
1684 | * |
1685 | * @return PageUpdater |
1686 | */ |
1687 | public function newPageUpdater( $performer, ?RevisionSlotsUpdate $forUpdate = null ) { |
1688 | if ( $performer instanceof Authority ) { |
1689 | // TODO: Deprecate this. But better get rid of this method entirely. |
1690 | $performer = $performer->getUser(); |
1691 | } |
1692 | |
1693 | $pageUpdater = $this->getPageUpdaterFactory()->newPageUpdaterForDerivedPageDataUpdater( |
1694 | $this, |
1695 | $performer, |
1696 | $this->getDerivedDataUpdater( $performer, null, $forUpdate, true ) |
1697 | ); |
1698 | |
1699 | return $pageUpdater; |
1700 | } |
1701 | |
1702 | /** |
1703 | * Get parser options suitable for rendering the primary article wikitext |
1704 | * |
1705 | * @see ParserOptions::newCanonical |
1706 | * |
1707 | * @param IContextSource|UserIdentity|string $context One of the following: |
1708 | * - IContextSource: Use the User and the Language of the provided |
1709 | * context |
1710 | * - UserIdentity: Use the provided UserIdentity object and $wgLang |
1711 | * for the language, so use an IContextSource object if possible. |
1712 | * - 'canonical': Canonical options (anonymous user with default |
1713 | * preferences and content language). |
1714 | * @return ParserOptions |
1715 | */ |
1716 | public function makeParserOptions( $context ) { |
1717 | return self::makeParserOptionsFromTitleAndModel( |
1718 | $this->getTitle(), $this->getContentModel(), $context |
1719 | ); |
1720 | } |
1721 | |
1722 | /** |
1723 | * Create canonical parser options for a given title and content model. |
1724 | * @internal |
1725 | * @param PageReference $pageRef |
1726 | * @param string $contentModel |
1727 | * @param IContextSource|UserIdentity|string $context See ::makeParserOptions |
1728 | * @return ParserOptions |
1729 | */ |
1730 | public static function makeParserOptionsFromTitleAndModel( |
1731 | PageReference $pageRef, string $contentModel, $context |
1732 | ) { |
1733 | $options = ParserOptions::newCanonical( $context ); |
1734 | |
1735 | $title = Title::newFromPageReference( $pageRef ); |
1736 | if ( $title->isConversionTable() ) { |
1737 | // @todo ConversionTable should become a separate content model, so |
1738 | // we don't need special cases like this one, but see T313455. |
1739 | $options->disableContentConversion(); |
1740 | } |
1741 | |
1742 | return $options; |
1743 | } |
1744 | |
1745 | /** |
1746 | * Prepare content which is about to be saved. |
1747 | * |
1748 | * Prior to 1.30, this returned a stdClass. |
1749 | * |
1750 | * @deprecated since 1.32, use newPageUpdater() or getCurrentUpdate() instead. |
1751 | * @note Calling without a UserIdentity was separately deprecated from 1.37 to 1.39, since |
1752 | * 1.39 the UserIdentity has been required. |
1753 | * |
1754 | * @param Content $content |
1755 | * @param RevisionRecord|null $revision |
1756 | * Used with vary-revision or vary-revision-id. |
1757 | * @param UserIdentity $user |
1758 | * @param string|null $serialFormat IGNORED |
1759 | * @param bool $useStash Use prepared edit stash |
1760 | * |
1761 | * @return PreparedEdit |
1762 | * |
1763 | * @since 1.21 |
1764 | */ |
1765 | public function prepareContentForEdit( |
1766 | Content $content, |
1767 | ?RevisionRecord $revision, |
1768 | UserIdentity $user, |
1769 | $serialFormat = null, |
1770 | $useStash = true |
1771 | ) { |
1772 | $slots = RevisionSlotsUpdate::newFromContent( [ SlotRecord::MAIN => $content ] ); |
1773 | $updater = $this->getDerivedDataUpdater( $user, $revision, $slots ); |
1774 | |
1775 | if ( !$updater->isUpdatePrepared() ) { |
1776 | $updater->prepareContent( $user, $slots, $useStash ); |
1777 | |
1778 | if ( $revision ) { |
1779 | $updater->prepareUpdate( |
1780 | $revision, |
1781 | [ |
1782 | 'causeAction' => 'prepare-edit', |
1783 | 'causeAgent' => $user->getName(), |
1784 | ] |
1785 | ); |
1786 | } |
1787 | } |
1788 | |
1789 | return $updater->getPreparedEdit(); |
1790 | } |
1791 | |
1792 | /** |
1793 | * Do standard deferred updates after page edit. |
1794 | * Update links tables, site stats, search index and message cache. |
1795 | * Purges pages that include this page if the text was changed here. |
1796 | * Every 100th edit, prune the recent changes table. |
1797 | * |
1798 | * @deprecated since 1.32, use DerivedPageDataUpdater::doUpdates instead. |
1799 | * Emitting warnings since 1.44 |
1800 | * |
1801 | * @param RevisionRecord $revisionRecord (Switched from the old Revision class to |
1802 | * RevisionRecord since 1.35) |
1803 | * @param UserIdentity $user User object that did the revision |
1804 | * @param array $options Array of options, see DerivedPageDataUpdater::prepareUpdate. |
1805 | */ |
1806 | public function doEditUpdates( |
1807 | RevisionRecord $revisionRecord, |
1808 | UserIdentity $user, |
1809 | array $options = [] |
1810 | ) { |
1811 | wfDeprecated( __METHOD__, '1.32' ); // emitting warnings since 1.44 |
1812 | |
1813 | $options += [ |
1814 | 'causeAction' => 'edit-page', |
1815 | 'causeAgent' => $user->getName(), |
1816 | ]; |
1817 | |
1818 | $updater = $this->getDerivedDataUpdater( $user, $revisionRecord ); |
1819 | |
1820 | $updater->prepareUpdate( $revisionRecord, $options ); |
1821 | |
1822 | $updater->doUpdates(); |
1823 | } |
1824 | |
1825 | /** |
1826 | * Update the parser cache. |
1827 | * |
1828 | * @note This does not update links tables. Use doSecondaryDataUpdates() for that. |
1829 | * |
1830 | * @param array $options |
1831 | * - causeAction: an arbitrary string identifying the reason for the update. |
1832 | * See DataUpdate::getCauseAction(). (default 'edit-page') |
1833 | * - causeAgent: name of the user who caused the update (string, defaults to the |
1834 | * user who created the revision) |
1835 | * @since 1.32 |
1836 | */ |
1837 | public function updateParserCache( array $options = [] ) { |
1838 | $revision = $this->getRevisionRecord(); |
1839 | if ( !$revision || !$revision->getId() ) { |
1840 | LoggerFactory::getInstance( 'wikipage' )->info( |
1841 | __METHOD__ . ' called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision' |
1842 | ); |
1843 | return; |
1844 | } |
1845 | $userIdentity = $revision->getUser( RevisionRecord::RAW ); |
1846 | |
1847 | $updater = $this->getDerivedDataUpdater( $userIdentity, $revision ); |
1848 | $updater->prepareUpdate( $revision, $options ); |
1849 | $updater->doParserCacheUpdate(); |
1850 | } |
1851 | |
1852 | /** |
1853 | * Do secondary data updates (such as updating link tables). |
1854 | * Secondary data updates are only a small part of the updates needed after saving |
1855 | * a new revision; normally PageUpdater::doUpdates should be used instead (which includes |
1856 | * secondary data updates). This method is provided for partial purges. |
1857 | * |
1858 | * @note This does not update the parser cache. Use updateParserCache() for that. |
1859 | * |
1860 | * @param array $options |
1861 | * - recursive (bool, default true): whether to do a recursive update (update pages that |
1862 | * depend on this page, e.g. transclude it). This will set the $recursive parameter of |
1863 | * Content::getSecondaryDataUpdates. Typically this should be true unless the update |
1864 | * was something that did not really change the page, such as a null edit. |
1865 | * - triggeringUser: The user triggering the update (UserIdentity, defaults to the |
1866 | * user who created the revision) |
1867 | * - causeAction: an arbitrary string identifying the reason for the update. |
1868 | * See DataUpdate::getCauseAction(). (default 'unknown') |
1869 | * - causeAgent: name of the user who caused the update (string, default 'unknown') |
1870 | * - defer: one of the DeferredUpdates constants, or false to run immediately (default: false). |
1871 | * Note that even when this is set to false, some updates might still get deferred (as |
1872 | * some update might directly add child updates to DeferredUpdates). |
1873 | * - known-revision-output: a combined canonical ParserOutput for the revision, perhaps |
1874 | * from some cache. The caller is responsible for ensuring that the ParserOutput indeed |
1875 | * matched the $rev and $options. This mechanism is intended as a temporary stop-gap, |
1876 | * for the time until caches have been changed to store RenderedRevision states instead |
1877 | * of ParserOutput objects. (default: null) (since 1.33) |
1878 | * @since 1.32 |
1879 | */ |
1880 | public function doSecondaryDataUpdates( array $options = [] ) { |
1881 | $options['recursive'] ??= true; |
1882 | $revision = $this->getRevisionRecord(); |
1883 | if ( !$revision || !$revision->getId() ) { |
1884 | LoggerFactory::getInstance( 'wikipage' )->info( |
1885 | __METHOD__ . ' called with ' . ( $revision ? 'unsaved' : 'no' ) . ' revision' |
1886 | ); |
1887 | return; |
1888 | } |
1889 | $userIdentity = $revision->getUser( RevisionRecord::RAW ); |
1890 | |
1891 | $updater = $this->getDerivedDataUpdater( $userIdentity, $revision ); |
1892 | $updater->prepareUpdate( $revision, $options ); |
1893 | $updater->doSecondaryDataUpdates( $options ); |
1894 | } |
1895 | |
1896 | /** |
1897 | * Update the article's restriction field, and leave a log entry. |
1898 | * This works for protection both existing and non-existing pages. |
1899 | * |
1900 | * @param array $limit Set of restriction keys |
1901 | * @param array $expiry Per restriction type expiration |
1902 | * @param bool &$cascade Set to false if cascading protection isn't allowed. |
1903 | * @param string $reason |
1904 | * @param UserIdentity $user The user updating the restrictions |
1905 | * @param string[] $tags Change tags to add to the pages and protection log entries |
1906 | * ($user should be able to add the specified tags before this is called) |
1907 | * @return Status Status object; if action is taken, $status->value is the log_id of the |
1908 | * protection log entry. |
1909 | */ |
1910 | public function doUpdateRestrictions( array $limit, array $expiry, |
1911 | &$cascade, $reason, UserIdentity $user, $tags = [] |
1912 | ) { |
1913 | $services = MediaWikiServices::getInstance(); |
1914 | $readOnlyMode = $services->getReadOnlyMode(); |
1915 | if ( $readOnlyMode->isReadOnly() ) { |
1916 | return Status::newFatal( wfMessage( 'readonlytext', $readOnlyMode->getReason() ) ); |
1917 | } |
1918 | |
1919 | $this->loadPageData( 'fromdbmaster' ); |
1920 | $restrictionStore = $services->getRestrictionStore(); |
1921 | $restrictionStore->loadRestrictions( $this->mTitle, IDBAccessObject::READ_LATEST ); |
1922 | $restrictionTypes = $restrictionStore->listApplicableRestrictionTypes( $this->mTitle ); |
1923 | $id = $this->getId(); |
1924 | |
1925 | if ( !$cascade ) { |
1926 | $cascade = false; |
1927 | } |
1928 | |
1929 | // Take this opportunity to purge out expired restrictions |
1930 | Title::purgeExpiredRestrictions(); |
1931 | |
1932 | // @todo: Same limitations as described in ProtectionForm.php (line 37); |
1933 | // we expect a single selection, but the schema allows otherwise. |
1934 | $isProtected = false; |
1935 | $protect = false; |
1936 | $changed = false; |
1937 | |
1938 | $dbw = $services->getConnectionProvider()->getPrimaryDatabase(); |
1939 | |
1940 | foreach ( $restrictionTypes as $action ) { |
1941 | if ( !isset( $expiry[$action] ) || $expiry[$action] === $dbw->getInfinity() ) { |
1942 | $expiry[$action] = 'infinity'; |
1943 | } |
1944 | if ( !isset( $limit[$action] ) ) { |
1945 | $limit[$action] = ''; |
1946 | } elseif ( $limit[$action] != '' ) { |
1947 | $protect = true; |
1948 | } |
1949 | |
1950 | // Get current restrictions on $action |
1951 | $current = implode( '', $restrictionStore->getRestrictions( $this->mTitle, $action ) ); |
1952 | if ( $current != '' ) { |
1953 | $isProtected = true; |
1954 | } |
1955 | |
1956 | if ( $limit[$action] != $current ) { |
1957 | $changed = true; |
1958 | } elseif ( $limit[$action] != '' ) { |
1959 | // Only check expiry change if the action is actually being |
1960 | // protected, since expiry does nothing on an not-protected |
1961 | // action. |
1962 | if ( $restrictionStore->getRestrictionExpiry( $this->mTitle, $action ) != $expiry[$action] ) { |
1963 | $changed = true; |
1964 | } |
1965 | } |
1966 | } |
1967 | |
1968 | if ( !$changed && $protect && $restrictionStore->areRestrictionsCascading( $this->mTitle ) != $cascade ) { |
1969 | $changed = true; |
1970 | } |
1971 | |
1972 | // If nothing has changed, do nothing |
1973 | if ( !$changed ) { |
1974 | return Status::newGood(); |
1975 | } |
1976 | |
1977 | if ( !$protect ) { // No protection at all means unprotection |
1978 | $revCommentMsg = 'unprotectedarticle-comment'; |
1979 | $logAction = 'unprotect'; |
1980 | } elseif ( $isProtected ) { |
1981 | $revCommentMsg = 'modifiedarticleprotection-comment'; |
1982 | $logAction = 'modify'; |
1983 | } else { |
1984 | $revCommentMsg = 'protectedarticle-comment'; |
1985 | $logAction = 'protect'; |
1986 | } |
1987 | |
1988 | $logRelationsValues = []; |
1989 | $logRelationsField = null; |
1990 | $logParamsDetails = []; |
1991 | |
1992 | // Null revision (used for change tag insertion) |
1993 | $nullRevisionRecord = null; |
1994 | |
1995 | $legacyUser = $services->getUserFactory()->newFromUserIdentity( $user ); |
1996 | if ( !$this->getHookRunner()->onArticleProtect( $this, $legacyUser, $limit, $reason ) ) { |
1997 | return Status::newGood(); |
1998 | } |
1999 | |
2000 | if ( $id ) { // Protection of existing page |
2001 | // Only certain restrictions can cascade... |
2002 | $editrestriction = isset( $limit['edit'] ) |
2003 | ? [ $limit['edit'] ] |
2004 | : $restrictionStore->getRestrictions( $this->mTitle, 'edit' ); |
2005 | foreach ( array_keys( $editrestriction, 'sysop' ) as $key ) { |
2006 | $editrestriction[$key] = 'editprotected'; // backwards compatibility |
2007 | } |
2008 | foreach ( array_keys( $editrestriction, 'autoconfirmed' ) as $key ) { |
2009 | $editrestriction[$key] = 'editsemiprotected'; // backwards compatibility |
2010 | } |
2011 | |
2012 | $cascadingRestrictionLevels = $services->getMainConfig() |
2013 | ->get( MainConfigNames::CascadingRestrictionLevels ); |
2014 | |
2015 | foreach ( array_keys( $cascadingRestrictionLevels, 'sysop' ) as $key ) { |
2016 | $cascadingRestrictionLevels[$key] = 'editprotected'; // backwards compatibility |
2017 | } |
2018 | foreach ( array_keys( $cascadingRestrictionLevels, 'autoconfirmed' ) as $key ) { |
2019 | $cascadingRestrictionLevels[$key] = 'editsemiprotected'; // backwards compatibility |
2020 | } |
2021 | |
2022 | // The schema allows multiple restrictions |
2023 | if ( !array_intersect( $editrestriction, $cascadingRestrictionLevels ) ) { |
2024 | $cascade = false; |
2025 | } |
2026 | |
2027 | // insert dummy revision to identify the page protection change as edit summary |
2028 | $nullRevisionRecord = $this->insertNullProtectionRevision( |
2029 | $revCommentMsg, |
2030 | $limit, |
2031 | $expiry, |
2032 | $cascade, |
2033 | $reason, |
2034 | $user |
2035 | ); |
2036 | |
2037 | if ( $nullRevisionRecord === null ) { |
2038 | return Status::newFatal( 'no-null-revision', $this->mTitle->getPrefixedText() ); |
2039 | } |
2040 | |
2041 | $logRelationsField = 'pr_id'; |
2042 | |
2043 | // T214035: Avoid deadlock on MySQL. |
2044 | // Do a DELETE by primary key (pr_id) for any existing protection rows. |
2045 | // On MySQL and derivatives, unconditionally deleting by page ID (pr_page) would. |
2046 | // place a gap lock if there are no matching rows. This can deadlock when another |
2047 | // thread modifies protection settings for page IDs in the same gap. |
2048 | $existingProtectionIds = $dbw->newSelectQueryBuilder() |
2049 | ->select( 'pr_id' ) |
2050 | ->from( 'page_restrictions' ) |
2051 | ->where( [ 'pr_page' => $id, 'pr_type' => array_map( 'strval', array_keys( $limit ) ) ] ) |
2052 | ->caller( __METHOD__ )->fetchFieldValues(); |
2053 | |
2054 | if ( $existingProtectionIds ) { |
2055 | $dbw->newDeleteQueryBuilder() |
2056 | ->deleteFrom( 'page_restrictions' ) |
2057 | ->where( [ 'pr_id' => $existingProtectionIds ] ) |
2058 | ->caller( __METHOD__ )->execute(); |
2059 | } |
2060 | |
2061 | // Update restrictions table |
2062 | foreach ( $limit as $action => $restrictions ) { |
2063 | if ( $restrictions != '' ) { |
2064 | $cascadeValue = ( $cascade && $action == 'edit' ) ? 1 : 0; |
2065 | $dbw->newInsertQueryBuilder() |
2066 | ->insertInto( 'page_restrictions' ) |
2067 | ->row( [ |
2068 | 'pr_page' => $id, |
2069 | 'pr_type' => $action, |
2070 | 'pr_level' => $restrictions, |
2071 | 'pr_cascade' => $cascadeValue, |
2072 | 'pr_expiry' => $dbw->encodeExpiry( $expiry[$action] ) |
2073 | ] ) |
2074 | ->caller( __METHOD__ )->execute(); |
2075 | $logRelationsValues[] = $dbw->insertId(); |
2076 | $logParamsDetails[] = [ |
2077 | 'type' => $action, |
2078 | 'level' => $restrictions, |
2079 | 'expiry' => $expiry[$action], |
2080 | 'cascade' => (bool)$cascadeValue, |
2081 | ]; |
2082 | } |
2083 | } |
2084 | } else { // Protection of non-existing page (also known as "title protection") |
2085 | // Cascade protection is meaningless in this case |
2086 | $cascade = false; |
2087 | |
2088 | if ( $limit['create'] != '' ) { |
2089 | $commentFields = $services->getCommentStore()->insert( $dbw, 'pt_reason', $reason ); |
2090 | $dbw->newReplaceQueryBuilder() |
2091 | ->table( 'protected_titles' ) |
2092 | ->uniqueIndexFields( [ 'pt_namespace', 'pt_title' ] ) |
2093 | ->rows( [ |
2094 | 'pt_namespace' => $this->mTitle->getNamespace(), |
2095 | 'pt_title' => $this->mTitle->getDBkey(), |
2096 | 'pt_create_perm' => $limit['create'], |
2097 | 'pt_timestamp' => $dbw->timestamp(), |
2098 | 'pt_expiry' => $dbw->encodeExpiry( $expiry['create'] ), |
2099 | 'pt_user' => $user->getId(), |
2100 | ] + $commentFields ) |
2101 | ->caller( __METHOD__ )->execute(); |
2102 | $logParamsDetails[] = [ |
2103 | 'type' => 'create', |
2104 | 'level' => $limit['create'], |
2105 | 'expiry' => $expiry['create'], |
2106 | ]; |
2107 | } else { |
2108 | $dbw->newDeleteQueryBuilder() |
2109 | ->deleteFrom( 'protected_titles' ) |
2110 | ->where( [ |
2111 | 'pt_namespace' => $this->mTitle->getNamespace(), |
2112 | 'pt_title' => $this->mTitle->getDBkey() |
2113 | ] ) |
2114 | ->caller( __METHOD__ )->execute(); |
2115 | } |
2116 | } |
2117 | |
2118 | $this->getHookRunner()->onArticleProtectComplete( $this, $legacyUser, $limit, $reason ); |
2119 | |
2120 | $services->getRestrictionStore()->flushRestrictions( $this->mTitle ); |
2121 | |
2122 | InfoAction::invalidateCache( $this->mTitle ); |
2123 | |
2124 | if ( $logAction == 'unprotect' ) { |
2125 | $params = []; |
2126 | } else { |
2127 | $protectDescriptionLog = $this->protectDescriptionLog( $limit, $expiry ); |
2128 | $params = [ |
2129 | '4::description' => $protectDescriptionLog, // parameter for IRC |
2130 | '5:bool:cascade' => $cascade, |
2131 | 'details' => $logParamsDetails, // parameter for localize and api |
2132 | ]; |
2133 | } |
2134 | |
2135 | // Update the protection log |
2136 | $logEntry = new ManualLogEntry( 'protect', $logAction ); |
2137 | $logEntry->setTarget( $this->mTitle ); |
2138 | $logEntry->setComment( $reason ); |
2139 | $logEntry->setPerformer( $user ); |
2140 | $logEntry->setParameters( $params ); |
2141 | if ( $nullRevisionRecord !== null ) { |
2142 | $logEntry->setAssociatedRevId( $nullRevisionRecord->getId() ); |
2143 | } |
2144 | $logEntry->addTags( $tags ); |
2145 | if ( $logRelationsField !== null && count( $logRelationsValues ) ) { |
2146 | $logEntry->setRelations( [ $logRelationsField => $logRelationsValues ] ); |
2147 | } |
2148 | $logId = $logEntry->insert(); |
2149 | $logEntry->publish( $logId ); |
2150 | |
2151 | return Status::newGood( $logId ); |
2152 | } |
2153 | |
2154 | /** |
2155 | * Get the state of an ongoing update, shortly before or just after it is saved to the database. |
2156 | * If there is no ongoing edit tracked by this WikiPage instance, this methods throws a |
2157 | * PreconditionException. |
2158 | * |
2159 | * If possible, state is shared with subsequent calls of getPreparedUpdate(), |
2160 | * prepareContentForEdit(), and newPageUpdater(). |
2161 | * |
2162 | * @note This method should generally be avoided, since it forces WikiPage to maintain state |
2163 | * representing ongoing edits. Code that initiates an edit should use newPageUpdater() |
2164 | * instead. Hooks that interact with the edit should have a the relevant |
2165 | * information provided as a PageUpdater, PreparedUpdate, or RenderedRevision. |
2166 | * |
2167 | * @throws PreconditionException if there is no ongoing update. This method must only be |
2168 | * called after newPageUpdater() had already been called, typically while executing |
2169 | * a handler for a hook that is triggered during a page edit. |
2170 | * @return PreparedUpdate |
2171 | * |
2172 | * @since 1.38 |
2173 | */ |
2174 | public function getCurrentUpdate(): PreparedUpdate { |
2175 | Assert::precondition( |
2176 | $this->derivedDataUpdater !== null, |
2177 | 'There is no ongoing update tracked by this instance of WikiPage!' |
2178 | ); |
2179 | |
2180 | return $this->derivedDataUpdater; |
2181 | } |
2182 | |
2183 | /** |
2184 | * Insert a new dummy revision (aka null revision) for this page, |
2185 | * to mark a change in page protection. |
2186 | * |
2187 | * @since 1.35 |
2188 | * |
2189 | * @param string $revCommentMsg Comment message key for the revision |
2190 | * @param array $limit Set of restriction keys |
2191 | * @param array $expiry Per restriction type expiration |
2192 | * @param bool $cascade Set to false if cascading protection isn't allowed. |
2193 | * @param string $reason |
2194 | * @param UserIdentity $user User to attribute to |
2195 | * @return RevisionRecord|null Null on error |
2196 | */ |
2197 | public function insertNullProtectionRevision( |
2198 | string $revCommentMsg, |
2199 | array $limit, |
2200 | array $expiry, |
2201 | bool $cascade, |
2202 | string $reason, |
2203 | UserIdentity $user |
2204 | ): ?RevisionRecord { |
2205 | // Prepare a null revision to be added to the history |
2206 | $editComment = wfMessage( |
2207 | $revCommentMsg, |
2208 | $this->mTitle->getPrefixedText(), |
2209 | $user->getName() |
2210 | )->inContentLanguage()->text(); |
2211 | if ( $reason ) { |
2212 | $editComment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; |
2213 | } |
2214 | $protectDescription = $this->protectDescription( $limit, $expiry ); |
2215 | if ( $protectDescription ) { |
2216 | $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text(); |
2217 | $editComment .= wfMessage( 'parentheses' )->params( $protectDescription ) |
2218 | ->inContentLanguage()->text(); |
2219 | } |
2220 | if ( $cascade ) { |
2221 | $editComment .= wfMessage( 'word-separator' )->inContentLanguage()->text(); |
2222 | $editComment .= wfMessage( 'brackets' )->params( |
2223 | wfMessage( 'protect-summary-cascade' )->inContentLanguage()->text() |
2224 | )->inContentLanguage()->text(); |
2225 | } |
2226 | |
2227 | return $this->newPageUpdater( $user ) |
2228 | ->setCause( PageUpdater::CAUSE_PROTECTION_CHANGE ) |
2229 | ->saveDummyRevision( $editComment, EDIT_SILENT | EDIT_MINOR ); |
2230 | } |
2231 | |
2232 | /** |
2233 | * @param string $expiry 14-char timestamp or "infinity", or false if the input was invalid |
2234 | * @return string |
2235 | */ |
2236 | protected function formatExpiry( $expiry ) { |
2237 | if ( $expiry != 'infinity' ) { |
2238 | $contLang = MediaWikiServices::getInstance()->getContentLanguage(); |
2239 | return wfMessage( |
2240 | 'protect-expiring', |
2241 | $contLang->timeanddate( $expiry, false, false ), |
2242 | $contLang->date( $expiry, false, false ), |
2243 | $contLang->time( $expiry, false, false ) |
2244 | )->inContentLanguage()->text(); |
2245 | } else { |
2246 | return wfMessage( 'protect-expiry-indefinite' ) |
2247 | ->inContentLanguage()->text(); |
2248 | } |
2249 | } |
2250 | |
2251 | /** |
2252 | * Builds the description to serve as comment for the edit. |
2253 | * |
2254 | * @param array $limit Set of restriction keys |
2255 | * @param array $expiry Per restriction type expiration |
2256 | * @return string |
2257 | */ |
2258 | public function protectDescription( array $limit, array $expiry ) { |
2259 | $protectDescription = ''; |
2260 | |
2261 | foreach ( array_filter( $limit ) as $action => $restrictions ) { |
2262 | # $action is one of $wgRestrictionTypes = [ 'create', 'edit', 'move', 'upload' ]. |
2263 | # All possible message keys are listed here for easier grepping: |
2264 | # * restriction-create |
2265 | # * restriction-edit |
2266 | # * restriction-move |
2267 | # * restriction-upload |
2268 | $actionText = wfMessage( 'restriction-' . $action )->inContentLanguage()->text(); |
2269 | # $restrictions is one of $wgRestrictionLevels = [ '', 'autoconfirmed', 'sysop' ], |
2270 | # with '' filtered out. All possible message keys are listed below: |
2271 | # * protect-level-autoconfirmed |
2272 | # * protect-level-sysop |
2273 | $restrictionsText = wfMessage( 'protect-level-' . $restrictions ) |
2274 | ->inContentLanguage()->text(); |
2275 | |
2276 | $expiryText = $this->formatExpiry( $expiry[$action] ); |
2277 | |
2278 | if ( $protectDescription !== '' ) { |
2279 | $protectDescription .= wfMessage( 'word-separator' )->inContentLanguage()->text(); |
2280 | } |
2281 | $protectDescription .= wfMessage( 'protect-summary-desc' ) |
2282 | ->params( $actionText, $restrictionsText, $expiryText ) |
2283 | ->inContentLanguage()->text(); |
2284 | } |
2285 | |
2286 | return $protectDescription; |
2287 | } |
2288 | |
2289 | /** |
2290 | * Builds the description to serve as comment for the log entry. |
2291 | * |
2292 | * Some bots may parse IRC lines, which are generated from log entries which contain plain |
2293 | * protect description text. Keep them in old format to avoid breaking compatibility. |
2294 | * TODO: Fix protection log to store structured description and format it on-the-fly. |
2295 | * |
2296 | * @param array $limit Set of restriction keys |
2297 | * @param array $expiry Per restriction type expiration |
2298 | * @return string |
2299 | */ |
2300 | public function protectDescriptionLog( array $limit, array $expiry ) { |
2301 | $protectDescriptionLog = ''; |
2302 | |
2303 | $dirMark = MediaWikiServices::getInstance()->getContentLanguage()->getDirMark(); |
2304 | foreach ( array_filter( $limit ) as $action => $restrictions ) { |
2305 | $expiryText = $this->formatExpiry( $expiry[$action] ); |
2306 | $protectDescriptionLog .= |
2307 | $dirMark . |
2308 | "[$action=$restrictions] ($expiryText)"; |
2309 | } |
2310 | |
2311 | return trim( $protectDescriptionLog ); |
2312 | } |
2313 | |
2314 | /** |
2315 | * Determines if deletion of this page would be batched (executed over time by the job queue) |
2316 | * or not (completed in the same request as the delete call). |
2317 | * |
2318 | * It is unlikely but possible that an edit from another request could push the page over the |
2319 | * batching threshold after this function is called, but before the caller acts upon the |
2320 | * return value. Callers must decide for themselves how to deal with this. $safetyMargin |
2321 | * is provided as an unreliable but situationally useful help for some common cases. |
2322 | * |
2323 | * @deprecated since 1.37 Use DeletePage::isBatchedDelete instead. |
2324 | * |
2325 | * @param int $safetyMargin Added to the revision count when checking for batching |
2326 | * @return bool True if deletion would be batched, false otherwise |
2327 | */ |
2328 | public function isBatchedDelete( $safetyMargin = 0 ) { |
2329 | $deleteRevisionsBatchSize = MediaWikiServices::getInstance() |
2330 | ->getMainConfig()->get( MainConfigNames::DeleteRevisionsBatchSize ); |
2331 | |
2332 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
2333 | $revCount = $this->getRevisionStore()->countRevisionsByPageId( $dbr, $this->getId() ); |
2334 | $revCount += $safetyMargin; |
2335 | |
2336 | return $revCount >= $deleteRevisionsBatchSize; |
2337 | } |
2338 | |
2339 | /** |
2340 | * Back-end article deletion |
2341 | * Deletes the article with database consistency, writes logs, purges caches |
2342 | * |
2343 | * @since 1.19 |
2344 | * @since 1.35 Signature changed, user moved to second parameter to prepare for requiring |
2345 | * a user to be passed |
2346 | * @since 1.36 User second parameter is required |
2347 | * @deprecated since 1.37 Use DeletePage instead. Calling ::deleteIfAllowed and letting DeletePage handle |
2348 | * permission checks is preferred over doing permission checks yourself and then calling ::deleteUnsafe. |
2349 | * Note that DeletePage returns a good status with false value in case of scheduled deletion, instead of |
2350 | * a status with a warning. Also, the new method doesn't have an $error parameter, since any error is |
2351 | * added to the returned Status. |
2352 | * |
2353 | * @param string $reason Delete reason for deletion log |
2354 | * @param UserIdentity $deleter The deleting user |
2355 | * @param bool $suppress Suppress all revisions and log the deletion in |
2356 | * the suppression log instead of the deletion log |
2357 | * @param bool|null $u1 Unused |
2358 | * @param array|string &$error Array of errors to append to |
2359 | * @param mixed $u2 Unused |
2360 | * @param string[]|null $tags Tags to apply to the deletion action |
2361 | * @param string $logsubtype |
2362 | * @param bool $immediate false allows deleting over time via the job queue |
2363 | * @return Status Status object; if successful, $status->value is the log_id of the |
2364 | * deletion log entry. If the page couldn't be deleted because it wasn't |
2365 | * found, $status is a non-fatal 'cannotdelete' error |
2366 | */ |
2367 | public function doDeleteArticleReal( |
2368 | $reason, UserIdentity $deleter, $suppress = false, $u1 = null, &$error = '', $u2 = null, |
2369 | $tags = [], $logsubtype = 'delete', $immediate = false |
2370 | ) { |
2371 | $services = MediaWikiServices::getInstance(); |
2372 | $deletePage = $services->getDeletePageFactory()->newDeletePage( |
2373 | $this, |
2374 | $services->getUserFactory()->newFromUserIdentity( $deleter ) |
2375 | ); |
2376 | |
2377 | $status = $deletePage |
2378 | ->setSuppress( $suppress ) |
2379 | ->setTags( $tags ?: [] ) |
2380 | ->setLogSubtype( $logsubtype ) |
2381 | ->forceImmediate( $immediate ) |
2382 | ->keepLegacyHookErrorsSeparate() |
2383 | ->deleteUnsafe( $reason ); |
2384 | $error = $deletePage->getLegacyHookErrors(); |
2385 | if ( $status->isGood() ) { |
2386 | // BC with old return format |
2387 | if ( $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) { |
2388 | $status->warning( 'delete-scheduled', wfEscapeWikiText( $this->getTitle()->getPrefixedText() ) ); |
2389 | } else { |
2390 | $status->value = $deletePage->getSuccessfulDeletionsIDs()[DeletePage::PAGE_BASE]; |
2391 | } |
2392 | } |
2393 | return $status; |
2394 | } |
2395 | |
2396 | /** |
2397 | * Lock the page row for this title+id and return page_latest (or 0) |
2398 | * |
2399 | * @return int Returns 0 if no row was found with this title+id |
2400 | * @since 1.27 |
2401 | */ |
2402 | public function lockAndGetLatest() { |
2403 | $dbw = $this->getConnectionProvider()->getPrimaryDatabase(); |
2404 | return (int)$dbw->newSelectQueryBuilder() |
2405 | ->select( 'page_latest' ) |
2406 | ->forUpdate() |
2407 | ->from( 'page' ) |
2408 | ->where( [ |
2409 | 'page_id' => $this->getId(), |
2410 | // Typically page_id is enough, but some code might try to do |
2411 | // updates assuming the title is the same, so verify that |
2412 | 'page_namespace' => $this->getTitle()->getNamespace(), |
2413 | 'page_title' => $this->getTitle()->getDBkey() |
2414 | ] ) |
2415 | ->caller( __METHOD__ )->fetchField(); |
2416 | } |
2417 | |
2418 | /** |
2419 | * The onArticle*() functions are supposed to be a kind of hooks |
2420 | * which should be called whenever any of the specified actions |
2421 | * are done. |
2422 | * |
2423 | * This is a good place to put code to clear caches, for instance. |
2424 | * |
2425 | * This is called on page move and undelete, as well as edit |
2426 | * |
2427 | * @param Title $title |
2428 | * @param bool $maybeIsRedirect True if the page may have been created as a redirect. |
2429 | * If false, this is used as a hint to skip some unnecessary updates. |
2430 | */ |
2431 | public static function onArticleCreate( Title $title, $maybeIsRedirect = true ) { |
2432 | // TODO: move this into a PageEventEmitter service |
2433 | |
2434 | // Update existence markers on article/talk tabs... |
2435 | $other = $title->getOtherPage(); |
2436 | |
2437 | $services = MediaWikiServices::getInstance(); |
2438 | $hcu = $services->getHtmlCacheUpdater(); |
2439 | $hcu->purgeTitleUrls( [ $title, $other ], $hcu::PURGE_INTENT_TXROUND_REFLECTED ); |
2440 | |
2441 | $title->touchLinks(); |
2442 | $title->deleteTitleProtection(); |
2443 | |
2444 | $services->getLinkCache()->invalidateTitle( $title ); |
2445 | |
2446 | DeferredUpdates::addCallableUpdate( |
2447 | static function () use ( $title, $maybeIsRedirect ) { |
2448 | self::queueBacklinksJobs( $title, true, $maybeIsRedirect, 'create-page' ); |
2449 | } |
2450 | ); |
2451 | |
2452 | if ( $title->getNamespace() === NS_CATEGORY ) { |
2453 | // Load the Category object, which will schedule a job to create |
2454 | // the category table row if necessary. Checking a replica DB is ok |
2455 | // here, in the worst case it'll run an unnecessary recount job on |
2456 | // a category that probably doesn't have many members. |
2457 | Category::newFromTitle( $title )->getID(); |
2458 | } |
2459 | } |
2460 | |
2461 | /** |
2462 | * Clears caches when article is deleted |
2463 | * |
2464 | * @internal for use by DeletePage and MovePage. |
2465 | * @todo pull this into DeletePage |
2466 | * |
2467 | * @param Title $title |
2468 | */ |
2469 | public static function onArticleDelete( Title $title ) { |
2470 | // TODO: move this into a PageEventEmitter service |
2471 | |
2472 | // Update existence markers on article/talk tabs... |
2473 | $other = $title->getOtherPage(); |
2474 | |
2475 | $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater(); |
2476 | $hcu->purgeTitleUrls( [ $title, $other ], $hcu::PURGE_INTENT_TXROUND_REFLECTED ); |
2477 | |
2478 | $title->touchLinks(); |
2479 | |
2480 | $services = MediaWikiServices::getInstance(); |
2481 | $services->getLinkCache()->invalidateTitle( $title ); |
2482 | |
2483 | InfoAction::invalidateCache( $title ); |
2484 | |
2485 | // Invalidate caches of articles which include this page |
2486 | DeferredUpdates::addCallableUpdate( static function () use ( $title ) { |
2487 | self::queueBacklinksJobs( $title, true, true, 'delete-page' ); |
2488 | } ); |
2489 | |
2490 | // TODO: Move to ChangeTrackingEventIngress when ready, |
2491 | // but make sure it happens on deletions and page moves by adding |
2492 | // the appropriate assertions to ChangeTrackingEventIngressSpyTrait. |
2493 | // Messages |
2494 | // User talk pages |
2495 | if ( $title->getNamespace() === NS_USER_TALK ) { |
2496 | $user = User::newFromName( $title->getText(), false ); |
2497 | if ( $user ) { |
2498 | MediaWikiServices::getInstance() |
2499 | ->getTalkPageNotificationManager() |
2500 | ->removeUserHasNewMessages( $user ); |
2501 | } |
2502 | } |
2503 | |
2504 | // TODO: Create MediaEventIngress and move this there. |
2505 | // Image redirects |
2506 | $services->getRepoGroup()->getLocalRepo()->invalidateImageRedirect( $title ); |
2507 | |
2508 | // Purge cross-wiki cache entities referencing this page |
2509 | self::purgeInterwikiCheckKey( $title ); |
2510 | } |
2511 | |
2512 | /** |
2513 | * Purge caches on page update etc |
2514 | * |
2515 | * @param Title $title |
2516 | * @param RevisionRecord|null $revRecord revision that was just saved, may be null |
2517 | * @param string[]|null $slotsChanged The role names of the slots that were changed. |
2518 | * If not given, all slots are assumed to have changed. |
2519 | * @param bool $maybeRedirectChanged True if the page's redirect target may have changed in the |
2520 | * latest revision. If false, this is used as a hint to skip some unnecessary updates. |
2521 | */ |
2522 | public static function onArticleEdit( |
2523 | Title $title, |
2524 | ?RevisionRecord $revRecord = null, |
2525 | $slotsChanged = null, |
2526 | $maybeRedirectChanged = true |
2527 | ) { |
2528 | // TODO: move this into a PageEventEmitter service |
2529 | |
2530 | DeferredUpdates::addCallableUpdate( |
2531 | static function () use ( $title, $slotsChanged, $maybeRedirectChanged ) { |
2532 | self::queueBacklinksJobs( |
2533 | $title, |
2534 | $slotsChanged === null || in_array( SlotRecord::MAIN, $slotsChanged ), |
2535 | $maybeRedirectChanged, |
2536 | 'edit-page' |
2537 | ); |
2538 | } |
2539 | ); |
2540 | |
2541 | $services = MediaWikiServices::getInstance(); |
2542 | $services->getLinkCache()->invalidateTitle( $title ); |
2543 | |
2544 | $hcu = MediaWikiServices::getInstance()->getHtmlCacheUpdater(); |
2545 | $hcu->purgeTitleUrls( $title, $hcu::PURGE_INTENT_TXROUND_REFLECTED ); |
2546 | |
2547 | // Purge ?action=info cache |
2548 | $revid = $revRecord ? $revRecord->getId() : null; |
2549 | DeferredUpdates::addCallableUpdate( static function () use ( $title, $revid ) { |
2550 | InfoAction::invalidateCache( $title, $revid ); |
2551 | } ); |
2552 | |
2553 | // Purge cross-wiki cache entities referencing this page |
2554 | self::purgeInterwikiCheckKey( $title ); |
2555 | } |
2556 | |
2557 | private static function queueBacklinksJobs( |
2558 | Title $title, $mainSlotChanged, $maybeRedirectChanged, $causeAction |
2559 | ) { |
2560 | $services = MediaWikiServices::getInstance(); |
2561 | $backlinkCache = $services->getBacklinkCacheFactory()->getBacklinkCache( $title ); |
2562 | |
2563 | $jobs = []; |
2564 | if ( $mainSlotChanged |
2565 | && $backlinkCache->hasLinks( 'templatelinks' ) |
2566 | ) { |
2567 | // Invalidate caches of articles which include this page. |
2568 | // Only for the main slot, because only the main slot is transcluded. |
2569 | // TODO: MCR: not true for TemplateStyles! [SlotHandler] |
2570 | $jobs[] = HTMLCacheUpdateJob::newForBacklinks( |
2571 | $title, |
2572 | 'templatelinks', |
2573 | [ 'causeAction' => $causeAction ] |
2574 | ); |
2575 | } |
2576 | // Images |
2577 | if ( $maybeRedirectChanged && $title->getNamespace() === NS_FILE |
2578 | && $backlinkCache->hasLinks( 'imagelinks' ) |
2579 | ) { |
2580 | // Process imagelinks in case the redirect target has changed |
2581 | $jobs[] = HTMLCacheUpdateJob::newForBacklinks( |
2582 | $title, |
2583 | 'imagelinks', |
2584 | [ 'causeAction' => $causeAction ] |
2585 | ); |
2586 | } |
2587 | // Invalidate the caches of all pages which redirect here |
2588 | if ( $backlinkCache->hasLinks( 'redirect' ) ) { |
2589 | $jobs[] = HTMLCacheUpdateJob::newForBacklinks( |
2590 | $title, |
2591 | 'redirect', |
2592 | [ 'causeAction' => $causeAction ] |
2593 | ); |
2594 | } |
2595 | if ( $jobs ) { |
2596 | $services->getJobQueueGroup()->push( $jobs ); |
2597 | } |
2598 | } |
2599 | |
2600 | /** |
2601 | * Purge the check key for cross-wiki cache entries referencing this page |
2602 | */ |
2603 | private static function purgeInterwikiCheckKey( Title $title ) { |
2604 | $enableScaryTranscluding = MediaWikiServices::getInstance()->getMainConfig()->get( |
2605 | MainConfigNames::EnableScaryTranscluding ); |
2606 | |
2607 | if ( !$enableScaryTranscluding ) { |
2608 | return; // @todo: perhaps this wiki is only used as a *source* for content? |
2609 | } |
2610 | |
2611 | DeferredUpdates::addCallableUpdate( static function () use ( $title ) { |
2612 | $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); |
2613 | $cache->resetCheckKey( |
2614 | // Do not include the namespace since there can be multiple aliases to it |
2615 | // due to different namespace text definitions on different wikis. This only |
2616 | // means that some cache invalidations happen that are not strictly needed. |
2617 | $cache->makeGlobalKey( |
2618 | 'interwiki-page', |
2619 | WikiMap::getCurrentWikiDbDomain()->getId(), |
2620 | $title->getDBkey() |
2621 | ) |
2622 | ); |
2623 | } ); |
2624 | } |
2625 | |
2626 | /** |
2627 | * Returns a list of categories this page is a member of. |
2628 | * Results will include hidden categories |
2629 | * |
2630 | * @return TitleArrayFromResult |
2631 | */ |
2632 | public function getCategories() { |
2633 | $services = MediaWikiServices::getInstance(); |
2634 | $id = $this->getId(); |
2635 | if ( $id == 0 ) { |
2636 | return $services->getTitleFactory()->newTitleArrayFromResult( new FakeResultWrapper( [] ) ); |
2637 | } |
2638 | |
2639 | $dbr = $services->getConnectionProvider()->getReplicaDatabase(); |
2640 | $res = $dbr->newSelectQueryBuilder() |
2641 | ->select( [ 'page_title' => 'cl_to', 'page_namespace' => (string)NS_CATEGORY ] ) |
2642 | ->from( 'categorylinks' ) |
2643 | ->where( [ 'cl_from' => $id ] ) |
2644 | ->caller( __METHOD__ )->fetchResultSet(); |
2645 | |
2646 | return $services->getTitleFactory()->newTitleArrayFromResult( $res ); |
2647 | } |
2648 | |
2649 | /** |
2650 | * Returns a list of hidden categories this page is a member of. |
2651 | * Uses the page_props and categorylinks tables. |
2652 | * |
2653 | * @return Title[] |
2654 | */ |
2655 | public function getHiddenCategories() { |
2656 | $result = []; |
2657 | $id = $this->getId(); |
2658 | |
2659 | if ( $id == 0 ) { |
2660 | return []; |
2661 | } |
2662 | |
2663 | $dbr = $this->getConnectionProvider()->getReplicaDatabase(); |
2664 | $res = $dbr->newSelectQueryBuilder() |
2665 | ->select( [ 'cl_to' ] ) |
2666 | ->from( 'categorylinks' ) |
2667 | ->join( 'page', null, 'page_title=cl_to' ) |
2668 | ->join( 'page_props', null, 'pp_page=page_id' ) |
2669 | ->where( [ 'cl_from' => $id, 'pp_propname' => 'hiddencat', 'page_namespace' => NS_CATEGORY ] ) |
2670 | ->caller( __METHOD__ )->fetchResultSet(); |
2671 | |
2672 | foreach ( $res as $row ) { |
2673 | $result[] = Title::makeTitle( NS_CATEGORY, $row->cl_to ); |
2674 | } |
2675 | |
2676 | return $result; |
2677 | } |
2678 | |
2679 | /** |
2680 | * Auto-generates a deletion reason |
2681 | * |
2682 | * @param bool &$hasHistory Whether the page has a history |
2683 | * @return string|false String containing deletion reason or empty string, or boolean false |
2684 | * if no revision occurred |
2685 | */ |
2686 | public function getAutoDeleteReason( &$hasHistory = false ) { |
2687 | if ( func_num_args() === 1 ) { |
2688 | wfDeprecated( __METHOD__ . ': $hasHistory parameter', '1.38' ); |
2689 | return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle(), $hasHistory ); |
2690 | } |
2691 | return $this->getContentHandler()->getAutoDeleteReason( $this->getTitle() ); |
2692 | } |
2693 | |
2694 | /** |
2695 | * Update all the appropriate counts in the category table, given that |
2696 | * we've added the categories $added and deleted the categories $deleted. |
2697 | * |
2698 | * This should only be called from deferred updates or jobs to avoid contention. |
2699 | * |
2700 | * @param string[] $added The names of categories that were added |
2701 | * @param string[] $deleted The names of categories that were deleted |
2702 | * @param int $id Page ID (this should be the original deleted page ID) |
2703 | */ |
2704 | public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) { |
2705 | $id = $id ?: $this->getId(); |
2706 | // Guard against data corruption T301433 |
2707 | $added = array_map( 'strval', $added ); |
2708 | $deleted = array_map( 'strval', $deleted ); |
2709 | $type = MediaWikiServices::getInstance()->getNamespaceInfo()-> |
2710 | getCategoryLinkType( $this->getTitle()->getNamespace() ); |
2711 | |
2712 | $addFields = [ 'cat_pages' => new RawSQLValue( 'cat_pages + 1' ) ]; |
2713 | $removeFields = [ 'cat_pages' => new RawSQLValue( 'cat_pages - 1' ) ]; |
2714 | if ( $type !== 'page' ) { |
2715 | $addFields["cat_{$type}s"] = new RawSQLValue( "cat_{$type}s + 1" ); |
2716 | $removeFields["cat_{$type}s"] = new RawSQLValue( "cat_{$type}s - 1" ); |
2717 | } |
2718 | |
2719 | $dbw = $this->getConnectionProvider()->getPrimaryDatabase(); |
2720 | $res = $dbw->newSelectQueryBuilder() |
2721 | ->select( [ 'cat_id', 'cat_title' ] ) |
2722 | ->from( 'category' ) |
2723 | ->where( [ 'cat_title' => array_merge( $added, $deleted ) ] ) |
2724 | ->caller( __METHOD__ ) |
2725 | ->fetchResultSet(); |
2726 | $existingCategories = []; |
2727 | foreach ( $res as $row ) { |
2728 | $existingCategories[$row->cat_id] = $row->cat_title; |
2729 | } |
2730 | $existingAdded = array_intersect( $existingCategories, $added ); |
2731 | $existingDeleted = array_intersect( $existingCategories, $deleted ); |
2732 | $missingAdded = array_diff( $added, $existingAdded ); |
2733 | |
2734 | // For category rows that already exist, do a plain |
2735 | // UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE |
2736 | // to avoid creating gaps in the cat_id sequence. |
2737 | if ( $existingAdded ) { |
2738 | $dbw->newUpdateQueryBuilder() |
2739 | ->update( 'category' ) |
2740 | ->set( $addFields ) |
2741 | ->where( [ 'cat_id' => array_keys( $existingAdded ) ] ) |
2742 | ->caller( __METHOD__ )->execute(); |
2743 | } |
2744 | |
2745 | if ( $missingAdded ) { |
2746 | $queryBuilder = $dbw->newInsertQueryBuilder() |
2747 | ->insertInto( 'category' ) |
2748 | ->onDuplicateKeyUpdate() |
2749 | ->uniqueIndexFields( [ 'cat_title' ] ) |
2750 | ->set( $addFields ); |
2751 | foreach ( $missingAdded as $cat ) { |
2752 | $queryBuilder->row( [ |
2753 | 'cat_title' => $cat, |
2754 | 'cat_pages' => 1, |
2755 | 'cat_subcats' => ( $type === 'subcat' ) ? 1 : 0, |
2756 | 'cat_files' => ( $type === 'file' ) ? 1 : 0, |
2757 | ] ); |
2758 | } |
2759 | $queryBuilder->caller( __METHOD__ )->execute(); |
2760 | } |
2761 | |
2762 | if ( $existingDeleted ) { |
2763 | $dbw->newUpdateQueryBuilder() |
2764 | ->update( 'category' ) |
2765 | ->set( $removeFields ) |
2766 | ->where( [ 'cat_id' => array_keys( $existingDeleted ) ] ) |
2767 | ->caller( __METHOD__ )->execute(); |
2768 | } |
2769 | |
2770 | foreach ( $added as $catName ) { |
2771 | $cat = Category::newFromName( $catName ); |
2772 | $this->getHookRunner()->onCategoryAfterPageAdded( $cat, $this ); |
2773 | } |
2774 | |
2775 | foreach ( $deleted as $catName ) { |
2776 | $cat = Category::newFromName( $catName ); |
2777 | $this->getHookRunner()->onCategoryAfterPageRemoved( $cat, $this, $id ); |
2778 | // Refresh counts on categories that should be empty now (after commit, T166757) |
2779 | DeferredUpdates::addCallableUpdate( static function () use ( $cat ) { |
2780 | $cat->refreshCountsIfEmpty(); |
2781 | } ); |
2782 | } |
2783 | } |
2784 | |
2785 | /** |
2786 | * Opportunistically enqueue link update jobs after a fresh parser output was generated. |
2787 | * |
2788 | * This method should only be called by PoolWorkArticleViewCurrent, after a page view |
2789 | * experienced a miss from the ParserCache, and a new ParserOutput was generated. |
2790 | * Specifically, for load reasons, this method must not get called during page views that |
2791 | * use a cached ParserOutput. |
2792 | * |
2793 | * @since 1.25 |
2794 | * @internal For use by PoolWorkArticleViewCurrent |
2795 | * @param ParserOutput $parserOutput Current version page output |
2796 | */ |
2797 | public function triggerOpportunisticLinksUpdate( ParserOutput $parserOutput ) { |
2798 | if ( MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) { |
2799 | return; |
2800 | } |
2801 | |
2802 | if ( !$this->getHookRunner()->onOpportunisticLinksUpdate( $this, |
2803 | $this->mTitle, $parserOutput ) |
2804 | ) { |
2805 | return; |
2806 | } |
2807 | |
2808 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
2809 | |
2810 | $params = [ |
2811 | 'isOpportunistic' => true, |
2812 | 'rootJobTimestamp' => $parserOutput->getCacheTime() |
2813 | ]; |
2814 | |
2815 | if ( MediaWikiServices::getInstance()->getRestrictionStore()->areRestrictionsCascading( $this->mTitle ) ) { |
2816 | // In general, MediaWiki does not re-run LinkUpdate (e.g. for search index, category |
2817 | // listings, and backlinks for Whatlinkshere), unless either the page was directly |
2818 | // edited, or was re-generate following a template edit propagating to an affected |
2819 | // page. As such, during page views when there is no valid ParserCache entry, |
2820 | // we re-parse and save, but leave indexes as-is. |
2821 | // |
2822 | // We make an exception for pages that have cascading protection (perhaps for a wiki's |
2823 | // "Main Page"). When such page is re-parsed on-demand after a parser cache miss, we |
2824 | // queue a high-priority LinksUpdate job, to ensure that we really protect all |
2825 | // content that is currently transcluded onto the page. This is important, because |
2826 | // wikitext supports conditional statements based on the current time, which enables |
2827 | // transcluding of a different subpage based on which day it is, and then show that |
2828 | // information on the Main Page, without the Main Page itself being edited. |
2829 | MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush( |
2830 | RefreshLinksJob::newPrioritized( $this->mTitle, $params ) |
2831 | ); |
2832 | } elseif ( !$config->get( MainConfigNames::MiserMode ) && |
2833 | $parserOutput->hasReducedExpiry() |
2834 | ) { |
2835 | // Assume the output contains "dynamic" time/random based magic words. |
2836 | // Only update pages that expired due to dynamic content and NOT due to edits |
2837 | // to referenced templates/files. When the cache expires due to dynamic content, |
2838 | // page_touched is unchanged. We want to avoid triggering redundant jobs due to |
2839 | // views of pages that were just purged via HTMLCacheUpdateJob. In that case, the |
2840 | // template/file edit already triggered recursive RefreshLinksJob jobs. |
2841 | if ( $this->getLinksTimestamp() > $this->getTouched() ) { |
2842 | // If a page is uncacheable, do not keep spamming a job for it. |
2843 | // Although it would be de-duplicated, it would still waste I/O. |
2844 | $services = MediaWikiServices::getInstance()->getObjectCacheFactory(); |
2845 | $cache = $services->getLocalClusterInstance(); |
2846 | $key = $cache->makeKey( 'dynamic-linksupdate', 'last', $this->getId() ); |
2847 | $ttl = max( $parserOutput->getCacheExpiry(), 3600 ); |
2848 | if ( $cache->add( $key, time(), $ttl ) ) { |
2849 | MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush( |
2850 | RefreshLinksJob::newDynamic( $this->mTitle, $params ) |
2851 | ); |
2852 | } |
2853 | } |
2854 | } |
2855 | } |
2856 | |
2857 | /** |
2858 | * Whether this content displayed on this page |
2859 | * comes from the local database |
2860 | * |
2861 | * @since 1.28 |
2862 | * @return bool |
2863 | */ |
2864 | public function isLocal() { |
2865 | return true; |
2866 | } |
2867 | |
2868 | /** |
2869 | * The display name for the site this content |
2870 | * come from. If a subclass overrides isLocal(), |
2871 | * this could return something other than the |
2872 | * current site name |
2873 | * |
2874 | * @since 1.28 |
2875 | * @return string |
2876 | */ |
2877 | public function getWikiDisplayName() { |
2878 | $sitename = MediaWikiServices::getInstance()->getMainConfig()->get( |
2879 | MainConfigNames::Sitename ); |
2880 | return $sitename; |
2881 | } |
2882 | |
2883 | /** |
2884 | * Get the source URL for the content on this page, |
2885 | * typically the canonical URL, but may be a remote |
2886 | * link if the content comes from another site |
2887 | * |
2888 | * @since 1.28 |
2889 | * @return string |
2890 | */ |
2891 | public function getSourceURL() { |
2892 | return $this->getTitle()->getCanonicalURL(); |
2893 | } |
2894 | |
2895 | /** |
2896 | * Ensure consistency when unserializing. |
2897 | * @note WikiPage objects should never be serialized in the first place. |
2898 | * But some extensions like AbuseFilter did (see T213006), |
2899 | * and we need to be able to read old data (see T187153). |
2900 | */ |
2901 | public function __wakeup() { |
2902 | // Make sure we re-fetch the latest state from the database. |
2903 | // In particular, the latest revision may have changed. |
2904 | // As a side-effect, this makes sure mLastRevision doesn't |
2905 | // end up being an instance of the old Revision class (see T259181), |
2906 | // especially since that class was removed entirely in 1.37. |
2907 | $this->clear(); |
2908 | } |
2909 | |
2910 | /** |
2911 | * @inheritDoc |
2912 | * @since 1.36 |
2913 | */ |
2914 | public function getNamespace(): int { |
2915 | return $this->getTitle()->getNamespace(); |
2916 | } |
2917 | |
2918 | /** |
2919 | * @inheritDoc |
2920 | * @since 1.36 |
2921 | */ |
2922 | public function getDBkey(): string { |
2923 |